feat(life): replace multiple tags with single category for posts
Add default category support and enforce one category per Life Post Update UI filters, forms, and translations to reflect category semantics
This commit is contained in:
19
DESIGN.md
19
DESIGN.md
@@ -59,7 +59,7 @@
|
|||||||
- 地图
|
- 地图
|
||||||
- 栖息地
|
- 栖息地
|
||||||
- 每日 CheckList Task
|
- 每日 CheckList Task
|
||||||
- Life 标签
|
- Life Category
|
||||||
- 支持翻译的字段:
|
- 支持翻译的字段:
|
||||||
- `name`
|
- `name`
|
||||||
- `title`
|
- `title`
|
||||||
@@ -379,9 +379,10 @@
|
|||||||
- 名称
|
- 名称
|
||||||
- 用于栖息地中 Pokemon 出现地点。
|
- 用于栖息地中 Pokemon 出现地点。
|
||||||
|
|
||||||
### Life 标签
|
### Life Category
|
||||||
|
|
||||||
- 名称
|
- 名称
|
||||||
|
- 是否默认选中:最多一个 Life Category 可设为默认;新建 Life Post 时默认选中该分类。
|
||||||
- 用于 Life Post 分类展示和 Feed 筛选。
|
- 用于 Life Post 分类展示和 Feed 筛选。
|
||||||
|
|
||||||
## Pokemon
|
## Pokemon
|
||||||
@@ -640,7 +641,7 @@ Life 是社区生活分享信息流,类似轻量社交动态。
|
|||||||
Life Post 可配置:
|
Life Post 可配置:
|
||||||
|
|
||||||
- Post 内容正文
|
- Post 内容正文
|
||||||
- 标签:使用 Life 标签配置,至少选择 1 个,可多选
|
- Category:使用 Life Category 配置,必须且只能选择 1 个
|
||||||
- 创建者、最后编辑者、创建时间、最后编辑时间
|
- 创建者、最后编辑者、创建时间、最后编辑时间
|
||||||
- 评论
|
- 评论
|
||||||
- 评论回复:仅支持回复顶层评论,不做无限嵌套
|
- 评论回复:仅支持回复顶层评论,不做无限嵌套
|
||||||
@@ -653,17 +654,17 @@ Life Post 可配置:
|
|||||||
- 已注册并完成邮箱验证且拥有 `life.posts.create` 权限的用户可以发布 Life Post。
|
- 已注册并完成邮箱验证且拥有 `life.posts.create` 权限的用户可以发布 Life Post。
|
||||||
- 作者本人拥有 `life.posts.update` / `life.posts.delete` 权限时可以编辑、删除自己的 Life Post;删除 Life Post 使用软删除。
|
- 作者本人拥有 `life.posts.update` / `life.posts.delete` 权限时可以编辑、删除自己的 Life Post;删除 Life Post 使用软删除。
|
||||||
- 拥有 `life.posts.update-any` / `life.posts.delete-any` 权限的用户可以管理其他用户的 Life Post。
|
- 拥有 `life.posts.update-any` / `life.posts.delete-any` 权限的用户可以管理其他用户的 Life Post。
|
||||||
- 已注册并完成邮箱验证且拥有 `life.posts.create` 或 `life.posts.update` 权限的用户发布或编辑 Life Post 时必须选择至少 1 个 Life 标签,可选择多个。
|
- 已注册并完成邮箱验证且拥有 `life.posts.create` 或 `life.posts.update` 权限的用户发布或编辑 Life Post 时必须选择 1 个 Life Category。
|
||||||
- 已注册并完成邮箱验证且拥有 `life.comments.create` 权限的用户可以评论 Life Post,并回复顶层评论。
|
- 已注册并完成邮箱验证且拥有 `life.comments.create` 权限的用户可以评论 Life Post,并回复顶层评论。
|
||||||
- 评论作者拥有 `life.comments.delete` 权限时可以删除自己的评论;拥有 `life.comments.delete-any` 权限的用户可以删除其他用户评论;删除评论后正文不再展示,已有回复保留在原位置。
|
- 评论作者拥有 `life.comments.delete` 权限时可以删除自己的评论;拥有 `life.comments.delete-any` 权限的用户可以删除其他用户评论;删除评论后正文不再展示,已有回复保留在原位置。
|
||||||
- 已软删除的 Life Post 不出现在信息流、搜索或标签筛选结果中,也不能继续编辑、评论或设置 Reaction。
|
- 已软删除的 Life Post 不出现在信息流、搜索或 Category 筛选结果中,也不能继续编辑、评论或设置 Reaction。
|
||||||
- 每条 Life Post 默认只展示评论入口与评论数量;评论列表、回复和评论输入默认折叠,用户点击后展开。
|
- 每条 Life Post 默认只展示评论入口与评论数量;评论列表、回复和评论输入默认折叠,用户点击后展开。
|
||||||
- Life Feed 只随每条 Life Post 返回评论总数和最近少量评论预览;完整评论列表在展开评论区后通过独立分页接口按顶层评论正序读取,每页顶层评论携带其一层回复。
|
- Life Feed 只随每条 Life Post 返回评论总数和最近少量评论预览;完整评论列表在展开评论区后通过独立分页接口按顶层评论正序读取,每页顶层评论携带其一层回复。
|
||||||
- 已注册并完成邮箱验证且拥有 `life.reactions.set` 权限的用户可以对每条 Life Post 选择一个 Reaction;普通点击默认设置 `like`,再次点击 `like` 会取消,当前为其他 Reaction 时普通点击会替换为 `like`。
|
- 已注册并完成邮箱验证且拥有 `life.reactions.set` 权限的用户可以对每条 Life Post 选择一个 Reaction;普通点击默认设置 `like`,再次点击 `like` 会取消,当前为其他 Reaction 时普通点击会替换为 `like`。
|
||||||
- Life Reaction 的其他类型通过右键 / context menu 或可见展开按钮打开 Popup 选择;再次选择当前 Reaction 会取消,选择其他 Reaction 会替换原 Reaction。
|
- Life Reaction 的其他类型通过右键 / context menu 或可见展开按钮打开 Popup 选择;再次选择当前 Reaction 会取消,选择其他 Reaction 会替换原 Reaction。
|
||||||
- 支持按 Life Post 正文搜索;用户按 Enter 或点击 Search 按钮后提交搜索,不随输入实时请求;搜索结果仍按创建时间倒序展示并分页加载。
|
- 支持按 Life Post 正文搜索;用户按 Enter 或点击 Search 按钮后提交搜索,不随输入实时请求;搜索结果仍按创建时间倒序展示并分页加载。
|
||||||
- Feed 使用 Tabs 展示 Life 标签筛选;包含 All 和后台配置的 Life 标签;点击标签后按该标签筛选,搜索和标签筛选可以同时生效。
|
- Feed 使用 Tabs 展示 Life Category 筛选;包含 All 和后台配置的 Life Category;点击 Category 后按该 Category 筛选,搜索和 Category 筛选可以同时生效。
|
||||||
- Feed 使用语言筛选展示 All languages 和启用语言;语言区筛选独立于系统 UI 语言,搜索、标签和语言筛选可以同时生效。
|
- Feed 使用语言筛选展示 All languages 和启用语言;语言区筛选独立于系统 UI 语言,搜索、Category 和语言筛选可以同时生效。
|
||||||
- 信息流分页加载,初始展示最新一页,滚动到底部自动加载更多。
|
- 信息流分页加载,初始展示最新一页,滚动到底部自动加载更多。
|
||||||
- 当前没有图片上传、转发或置顶。
|
- 当前没有图片上传、转发或置顶。
|
||||||
- Life Post 和 Life Comment 必须进入 AI 审核;未审核通过的内容不向普通访客公开。
|
- Life Post 和 Life Comment 必须进入 AI 审核;未审核通过的内容不向普通访客公开。
|
||||||
@@ -677,7 +678,7 @@ Life Post 可配置:
|
|||||||
API 暴露边界:
|
API 暴露边界:
|
||||||
|
|
||||||
- Life Post 作者信息只返回 `id` 和 `displayName`。
|
- Life Post 作者信息只返回 `id` 和 `displayName`。
|
||||||
- Life Post 标签只返回 `id` 和按当前语言解析后的 `name`。
|
- Life Post Category 只返回 `id` 和按当前语言解析后的 `name`。
|
||||||
- Life Post 可返回面向用户展示所需的审核状态、审核语言区和是否可重审;不返回内部错误、AI prompt、模型响应或 retry 细节。
|
- Life Post 可返回面向用户展示所需的审核状态、审核语言区和是否可重审;不返回内部错误、AI prompt、模型响应或 retry 细节。
|
||||||
- Life Comment 作者信息只返回 `id` 和 `displayName`。
|
- Life Comment 作者信息只返回 `id` 和 `displayName`。
|
||||||
- Life Reaction 对外只返回按类型汇总的数量和当前用户自己的 Reaction,不返回其他用户的 Reaction 明细。
|
- Life Reaction 对外只返回按类型汇总的数量和当前用户自己的 Reaction,不返回其他用户的 Reaction 明细。
|
||||||
@@ -769,7 +770,7 @@ API 暴露边界:
|
|||||||
- `GET /api/items/:id`
|
- `GET /api/items/:id`
|
||||||
- `GET /api/recipes`
|
- `GET /api/recipes`
|
||||||
- `GET /api/recipes/:id`
|
- `GET /api/recipes/:id`
|
||||||
- `GET /api/life-posts`:支持 `cursor` / `limit` 分页读取;支持 `search` 按 Life Post 正文搜索;支持 `tagId` 按 Life 标签筛选;支持 `language` 按审核语言区筛选,`all` 表示全部语言区。
|
- `GET /api/life-posts`:支持 `cursor` / `limit` 分页读取;支持 `search` 按 Life Post 正文搜索;支持 `categoryId` 按 Life Category 筛选;支持 `language` 按审核语言区筛选,`all` 表示全部语言区。
|
||||||
- `GET /api/life-posts/:postId/comments`:支持 `cursor` / `limit` 分页读取 Life Post 评论;支持 `language` 按审核语言区筛选。
|
- `GET /api/life-posts/:postId/comments`:支持 `cursor` / `limit` 分页读取 Life Post 评论;支持 `language` 按审核语言区筛选。
|
||||||
- `GET /api/users/:id/profile`:读取公开用户 Profile 摘要、Wiki 贡献统计和公开社区统计。
|
- `GET /api/users/:id/profile`:读取公开用户 Profile 摘要、Wiki 贡献统计和公开社区统计。
|
||||||
- `GET /api/users/:id/life-posts`:分页读取该用户发布过且未删除的 Life Post。
|
- `GET /api/users/:id/life-posts`:分页读取该用户发布过且未删除的 Life Post。
|
||||||
|
|||||||
@@ -523,6 +523,7 @@ CREATE INDEX IF NOT EXISTS daily_checklist_items_sort_order_idx
|
|||||||
CREATE TABLE IF NOT EXISTS life_tags (
|
CREATE TABLE IF NOT EXISTS life_tags (
|
||||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
name text NOT NULL UNIQUE,
|
name text NOT NULL UNIQUE,
|
||||||
|
is_default boolean NOT NULL DEFAULT false,
|
||||||
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
|
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
|
||||||
created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
|
created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
|
||||||
updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
|
updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
|
||||||
@@ -533,6 +534,7 @@ CREATE TABLE IF NOT EXISTS life_tags (
|
|||||||
CREATE TABLE IF NOT EXISTS life_posts (
|
CREATE TABLE IF NOT EXISTS life_posts (
|
||||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
body text NOT NULL CHECK (length(body) BETWEEN 1 AND 2000),
|
body text NOT NULL CHECK (length(body) BETWEEN 1 AND 2000),
|
||||||
|
category_id integer REFERENCES life_tags(id) ON DELETE RESTRICT,
|
||||||
ai_moderation_status text NOT NULL DEFAULT 'unreviewed' CHECK (ai_moderation_status IN ('unreviewed', 'reviewing', 'approved', 'rejected', 'failed')),
|
ai_moderation_status text NOT NULL DEFAULT 'unreviewed' CHECK (ai_moderation_status IN ('unreviewed', 'reviewing', 'approved', 'rejected', 'failed')),
|
||||||
ai_moderation_language_code text REFERENCES languages(code) ON DELETE SET NULL,
|
ai_moderation_language_code text REFERENCES languages(code) ON DELETE SET NULL,
|
||||||
ai_moderation_content_hash text,
|
ai_moderation_content_hash text,
|
||||||
@@ -818,6 +820,9 @@ CREATE TABLE IF NOT EXISTS habitat_pokemon (
|
|||||||
PRIMARY KEY (habitat_id, pokemon_id, map_id, time_of_day, weather)
|
PRIMARY KEY (habitat_id, pokemon_id, map_id, time_of_day, weather)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
ALTER TABLE life_tags
|
||||||
|
ADD COLUMN IF NOT EXISTS is_default boolean NOT NULL DEFAULT false;
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS environments_sort_order_idx ON environments(sort_order, id);
|
CREATE INDEX IF NOT EXISTS environments_sort_order_idx ON environments(sort_order, id);
|
||||||
CREATE INDEX IF NOT EXISTS skills_sort_order_idx ON skills(sort_order, id);
|
CREATE INDEX IF NOT EXISTS skills_sort_order_idx ON skills(sort_order, id);
|
||||||
CREATE INDEX IF NOT EXISTS favorite_things_sort_order_idx ON favorite_things(sort_order, id);
|
CREATE INDEX IF NOT EXISTS favorite_things_sort_order_idx ON favorite_things(sort_order, id);
|
||||||
@@ -825,6 +830,7 @@ CREATE INDEX IF NOT EXISTS pokemon_types_sort_order_idx ON pokemon_types(sort_or
|
|||||||
CREATE INDEX IF NOT EXISTS pokemon_sort_order_idx ON pokemon(sort_order, id);
|
CREATE INDEX IF NOT EXISTS pokemon_sort_order_idx ON pokemon(sort_order, id);
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS pokemon_display_event_item_key ON pokemon(display_id, is_event_item);
|
CREATE UNIQUE INDEX IF NOT EXISTS pokemon_display_event_item_key ON pokemon(display_id, is_event_item);
|
||||||
CREATE INDEX IF NOT EXISTS life_tags_sort_order_idx ON life_tags(sort_order, id);
|
CREATE INDEX IF NOT EXISTS life_tags_sort_order_idx ON life_tags(sort_order, id);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS life_tags_single_default_idx ON life_tags(is_default) WHERE is_default = true;
|
||||||
CREATE INDEX IF NOT EXISTS item_categories_sort_order_idx ON item_categories(sort_order, id);
|
CREATE INDEX IF NOT EXISTS item_categories_sort_order_idx ON item_categories(sort_order, id);
|
||||||
CREATE INDEX IF NOT EXISTS item_usages_sort_order_idx ON item_usages(sort_order, id);
|
CREATE INDEX IF NOT EXISTS item_usages_sort_order_idx ON item_usages(sort_order, id);
|
||||||
CREATE INDEX IF NOT EXISTS acquisition_methods_sort_order_idx ON acquisition_methods(sort_order, id);
|
CREATE INDEX IF NOT EXISTS acquisition_methods_sort_order_idx ON acquisition_methods(sort_order, id);
|
||||||
@@ -900,12 +906,28 @@ CREATE INDEX IF NOT EXISTS entity_discussion_comments_user_idx
|
|||||||
|
|
||||||
ALTER TABLE life_posts
|
ALTER TABLE life_posts
|
||||||
ADD COLUMN IF NOT EXISTS ai_moderation_status text NOT NULL DEFAULT 'unreviewed' CHECK (ai_moderation_status IN ('unreviewed', 'reviewing', 'approved', 'rejected', 'failed')),
|
ADD COLUMN IF NOT EXISTS ai_moderation_status text NOT NULL DEFAULT 'unreviewed' CHECK (ai_moderation_status IN ('unreviewed', 'reviewing', 'approved', 'rejected', 'failed')),
|
||||||
|
ADD COLUMN IF NOT EXISTS category_id integer REFERENCES life_tags(id) ON DELETE RESTRICT,
|
||||||
ADD COLUMN IF NOT EXISTS ai_moderation_language_code text REFERENCES languages(code) ON DELETE SET NULL,
|
ADD COLUMN IF NOT EXISTS ai_moderation_language_code text REFERENCES languages(code) ON DELETE SET NULL,
|
||||||
ADD COLUMN IF NOT EXISTS ai_moderation_content_hash text,
|
ADD COLUMN IF NOT EXISTS ai_moderation_content_hash text,
|
||||||
ADD COLUMN IF NOT EXISTS ai_moderation_checked_at timestamptz,
|
ADD COLUMN IF NOT EXISTS ai_moderation_checked_at timestamptz,
|
||||||
ADD COLUMN IF NOT EXISTS ai_moderation_retry_count integer NOT NULL DEFAULT 0 CHECK (ai_moderation_retry_count >= 0),
|
ADD COLUMN IF NOT EXISTS ai_moderation_retry_count integer NOT NULL DEFAULT 0 CHECK (ai_moderation_retry_count >= 0),
|
||||||
ADD COLUMN IF NOT EXISTS ai_moderation_updated_at timestamptz NOT NULL DEFAULT now();
|
ADD COLUMN IF NOT EXISTS ai_moderation_updated_at timestamptz NOT NULL DEFAULT now();
|
||||||
|
|
||||||
|
UPDATE life_posts lp
|
||||||
|
SET category_id = selected.tag_id
|
||||||
|
FROM (
|
||||||
|
SELECT DISTINCT ON (lpt.post_id) lpt.post_id, lpt.tag_id
|
||||||
|
FROM life_post_tags lpt
|
||||||
|
JOIN life_tags lt ON lt.id = lpt.tag_id
|
||||||
|
ORDER BY lpt.post_id, lt.sort_order, lt.id
|
||||||
|
) selected
|
||||||
|
WHERE lp.id = selected.post_id
|
||||||
|
AND lp.category_id IS NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS life_posts_category_idx
|
||||||
|
ON life_posts(category_id, created_at DESC, id DESC)
|
||||||
|
WHERE deleted_at IS NULL;
|
||||||
|
|
||||||
ALTER TABLE life_post_comments
|
ALTER TABLE life_post_comments
|
||||||
ADD COLUMN IF NOT EXISTS ai_moderation_status text NOT NULL DEFAULT 'unreviewed' CHECK (ai_moderation_status IN ('unreviewed', 'reviewing', 'approved', 'rejected', 'failed')),
|
ADD COLUMN IF NOT EXISTS ai_moderation_status text NOT NULL DEFAULT 'unreviewed' CHECK (ai_moderation_status IN ('unreviewed', 'reviewing', 'approved', 'rejected', 'failed')),
|
||||||
ADD COLUMN IF NOT EXISTS ai_moderation_language_code text REFERENCES languages(code) ON DELETE SET NULL,
|
ADD COLUMN IF NOT EXISTS ai_moderation_language_code text REFERENCES languages(code) ON DELETE SET NULL,
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ type ConfigDefinition = {
|
|||||||
table: string;
|
table: string;
|
||||||
entityType: EntityType;
|
entityType: EntityType;
|
||||||
hasItemDrop?: boolean;
|
hasItemDrop?: boolean;
|
||||||
|
hasDefault?: boolean;
|
||||||
};
|
};
|
||||||
type SortableContentType = 'pokemon' | 'items' | 'recipes' | 'habitats';
|
type SortableContentType = 'pokemon' | 'items' | 'recipes' | 'habitats';
|
||||||
type SortableContentDefinition = {
|
type SortableContentDefinition = {
|
||||||
@@ -178,7 +179,7 @@ type DailyChecklistPayload = {
|
|||||||
|
|
||||||
type LifePostPayload = {
|
type LifePostPayload = {
|
||||||
body: string;
|
body: string;
|
||||||
tagIds: number[];
|
categoryId: number;
|
||||||
languageCode: string | null;
|
languageCode: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -250,7 +251,7 @@ type LifePostRow = {
|
|||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
author: { id: number; displayName: string } | null;
|
author: { id: number; displayName: string } | null;
|
||||||
updatedBy: { id: number; displayName: string } | null;
|
updatedBy: { id: number; displayName: string } | null;
|
||||||
tags: Array<{ id: number; name: string }>;
|
category: { id: number; name: string } | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type LifePost = Omit<LifePostRow, 'createdAtCursor'> & {
|
type LifePost = Omit<LifePostRow, 'createdAtCursor'> & {
|
||||||
@@ -459,7 +460,7 @@ const configDefinitions: Record<ConfigType, ConfigDefinition> = {
|
|||||||
'item-usages': { table: 'item_usages', entityType: 'item-usages' },
|
'item-usages': { table: 'item_usages', entityType: 'item-usages' },
|
||||||
'acquisition-methods': { table: 'acquisition_methods', entityType: 'acquisition-methods' },
|
'acquisition-methods': { table: 'acquisition_methods', entityType: 'acquisition-methods' },
|
||||||
maps: { table: 'maps', entityType: 'maps' },
|
maps: { table: 'maps', entityType: 'maps' },
|
||||||
'life-tags': { table: 'life_tags', entityType: 'life-tags' }
|
'life-tags': { table: 'life_tags', entityType: 'life-tags', hasDefault: true }
|
||||||
};
|
};
|
||||||
|
|
||||||
const sortableContentDefinitions: Record<SortableContentType, SortableContentDefinition> = {
|
const sortableContentDefinitions: Record<SortableContentType, SortableContentDefinition> = {
|
||||||
@@ -684,6 +685,11 @@ function optionSelect(
|
|||||||
return query(`SELECT o.id, ${name} AS name FROM ${tableName} o ORDER BY ${orderByEntity('o')}`);
|
return query(`SELECT o.id, ${name} AS name FROM ${tableName} o ORDER BY ${orderByEntity('o')}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function lifeCategoryOptions(locale: string): Promise<Array<{ id: number; name: string; isDefault: boolean }>> {
|
||||||
|
const name = localizedName('life-tags', 'lc', locale);
|
||||||
|
return query(`SELECT lc.id, ${name} AS name, lc.is_default AS "isDefault" FROM life_tags lc ORDER BY ${orderByEntity('lc')}`);
|
||||||
|
}
|
||||||
|
|
||||||
function skillOptions(locale: string): Promise<Array<{ id: number; name: string; hasItemDrop: boolean }>> {
|
function skillOptions(locale: string): Promise<Array<{ id: number; name: string; hasItemDrop: boolean }>> {
|
||||||
const name = localizedName('skills', 's', locale);
|
const name = localizedName('skills', 's', locale);
|
||||||
return query(`SELECT s.id, ${name} AS name, s.has_item_drop AS "hasItemDrop" FROM skills s ORDER BY ${orderByEntity('s')}`);
|
return query(`SELECT s.id, ${name} AS name, s.has_item_drop AS "hasItemDrop" FROM skills s ORDER BY ${orderByEntity('s')}`);
|
||||||
@@ -718,9 +724,14 @@ function configOrder(): string {
|
|||||||
function configSelect(definition: ConfigDefinition, locale: string): string {
|
function configSelect(definition: ConfigDefinition, locale: string): string {
|
||||||
const name = localizedName(definition.entityType, 'c', locale);
|
const name = localizedName(definition.entityType, 'c', locale);
|
||||||
const translations = translationsSelect(definition.entityType, 'c.id');
|
const translations = translationsSelect(definition.entityType, 'c.id');
|
||||||
return definition.hasItemDrop
|
const columns = [`c.id`, `${name} AS name`, `c.name AS "baseName"`, `${translations} AS translations`];
|
||||||
? `c.id, ${name} AS name, c.name AS "baseName", ${translations} AS translations, c.has_item_drop AS "hasItemDrop"`
|
if (definition.hasItemDrop) {
|
||||||
: `c.id, ${name} AS name, c.name AS "baseName", ${translations} AS translations`;
|
columns.push(`c.has_item_drop AS "hasItemDrop"`);
|
||||||
|
}
|
||||||
|
if (definition.hasDefault) {
|
||||||
|
columns.push(`c.is_default AS "isDefault"`);
|
||||||
|
}
|
||||||
|
return columns.join(', ');
|
||||||
}
|
}
|
||||||
|
|
||||||
function validationError(message: string): ValidationError {
|
function validationError(message: string): ValidationError {
|
||||||
@@ -2045,7 +2056,7 @@ export async function getOptions(locale = defaultLocale) {
|
|||||||
itemUsages,
|
itemUsages,
|
||||||
acquisitionMethods,
|
acquisitionMethods,
|
||||||
maps,
|
maps,
|
||||||
lifeTags
|
lifeCategories
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
optionSelect('pokemon_types', 'pokemon-types', locale),
|
optionSelect('pokemon_types', 'pokemon-types', locale),
|
||||||
skillOptions(locale),
|
skillOptions(locale),
|
||||||
@@ -2055,7 +2066,7 @@ export async function getOptions(locale = defaultLocale) {
|
|||||||
optionSelect('item_usages', 'item-usages', locale),
|
optionSelect('item_usages', 'item-usages', locale),
|
||||||
optionSelect('acquisition_methods', 'acquisition-methods', locale),
|
optionSelect('acquisition_methods', 'acquisition-methods', locale),
|
||||||
optionSelect('maps', 'maps', locale),
|
optionSelect('maps', 'maps', locale),
|
||||||
optionSelect('life_tags', 'life-tags', locale)
|
lifeCategoryOptions(locale)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -2068,7 +2079,7 @@ export async function getOptions(locale = defaultLocale) {
|
|||||||
acquisitionMethods,
|
acquisitionMethods,
|
||||||
itemTags: favoriteThings,
|
itemTags: favoriteThings,
|
||||||
maps,
|
maps,
|
||||||
lifeTags
|
lifeCategories
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2209,14 +2220,11 @@ function cleanLifePostPayload(payload: Record<string, unknown>): LifePostPayload
|
|||||||
if (body.length > 2000) {
|
if (body.length > 2000) {
|
||||||
throw validationError('server.validation.postTooLong');
|
throw validationError('server.validation.postTooLong');
|
||||||
}
|
}
|
||||||
const tagIds = cleanIds(payload.tagIds);
|
const categoryId = requirePositiveInteger(payload.categoryId, 'server.validation.lifeCategoryRequired');
|
||||||
if (tagIds.length === 0) {
|
|
||||||
throw validationError('server.validation.lifeTagRequired');
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
body,
|
body,
|
||||||
tagIds,
|
categoryId,
|
||||||
languageCode: cleanModerationLanguageCode(payload.languageCode)
|
languageCode: cleanModerationLanguageCode(payload.languageCode)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -2313,7 +2321,7 @@ function addModerationLanguageCondition(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function lifePostProjection(locale = defaultLocale): string {
|
function lifePostProjection(locale = defaultLocale): string {
|
||||||
const tagName = localizedName('life-tags', 'lt', locale);
|
const categoryName = localizedName('life-tags', 'lc', locale);
|
||||||
|
|
||||||
return `
|
return `
|
||||||
SELECT
|
SELECT
|
||||||
@@ -2332,13 +2340,12 @@ function lifePostProjection(locale = defaultLocale): string {
|
|||||||
WHEN updated_user.id IS NULL THEN NULL
|
WHEN updated_user.id IS NULL THEN NULL
|
||||||
ELSE json_build_object('id', updated_user.id, 'displayName', updated_user.display_name)
|
ELSE json_build_object('id', updated_user.id, 'displayName', updated_user.display_name)
|
||||||
END AS "updatedBy",
|
END AS "updatedBy",
|
||||||
COALESCE((
|
CASE
|
||||||
SELECT json_agg(json_build_object('id', lt.id, 'name', ${tagName}) ORDER BY ${orderByEntity('lt')})
|
WHEN lc.id IS NULL THEN NULL
|
||||||
FROM life_post_tags lpt
|
ELSE json_build_object('id', lc.id, 'name', ${categoryName})
|
||||||
JOIN life_tags lt ON lt.id = lpt.tag_id
|
END AS category
|
||||||
WHERE lpt.post_id = lp.id
|
|
||||||
), '[]'::json) AS tags
|
|
||||||
FROM life_posts lp
|
FROM life_posts lp
|
||||||
|
LEFT JOIN life_tags lc ON lc.id = lp.category_id
|
||||||
LEFT JOIN users created_user ON created_user.id = lp.created_by_user_id
|
LEFT JOIN users created_user ON created_user.id = lp.created_by_user_id
|
||||||
LEFT JOIN users updated_user ON updated_user.id = lp.updated_by_user_id
|
LEFT JOIN users updated_user ON updated_user.id = lp.updated_by_user_id
|
||||||
`;
|
`;
|
||||||
@@ -2447,7 +2454,7 @@ function hydrateLifePost(
|
|||||||
updatedAt: post.updatedAt,
|
updatedAt: post.updatedAt,
|
||||||
author: post.author,
|
author: post.author,
|
||||||
updatedBy: post.updatedBy,
|
updatedBy: post.updatedBy,
|
||||||
tags: post.tags,
|
category: post.category,
|
||||||
commentPreview: commentPreviewByPost.get(post.id) ?? [],
|
commentPreview: commentPreviewByPost.get(post.id) ?? [],
|
||||||
commentCount: commentCountsByPost.get(post.id) ?? 0,
|
commentCount: commentCountsByPost.get(post.id) ?? 0,
|
||||||
reactionCounts: countsByPost.get(post.id) ?? emptyLifeReactionCounts(),
|
reactionCounts: countsByPost.get(post.id) ?? emptyLifeReactionCounts(),
|
||||||
@@ -2759,7 +2766,7 @@ async function listLifePostsWithFilters(
|
|||||||
const cursor = decodeLifePostCursor(paramsQuery.cursor);
|
const cursor = decodeLifePostCursor(paramsQuery.cursor);
|
||||||
const limit = cleanLifePostLimit(paramsQuery.limit);
|
const limit = cleanLifePostLimit(paramsQuery.limit);
|
||||||
const search = asString(paramsQuery.search)?.trim();
|
const search = asString(paramsQuery.search)?.trim();
|
||||||
const tagIdValue = asString(paramsQuery.tagId)?.trim();
|
const categoryIdValue = asString(paramsQuery.categoryId)?.trim();
|
||||||
const languageCode = cleanModerationLanguageFilter(paramsQuery.language);
|
const languageCode = cleanModerationLanguageFilter(paramsQuery.language);
|
||||||
const params: unknown[] = [];
|
const params: unknown[] = [];
|
||||||
const conditions: string[] = ['lp.deleted_at IS NULL'];
|
const conditions: string[] = ['lp.deleted_at IS NULL'];
|
||||||
@@ -2777,15 +2784,10 @@ async function listLifePostsWithFilters(
|
|||||||
conditions.push(`lp.body ILIKE $${params.length}`);
|
conditions.push(`lp.body ILIKE $${params.length}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tagIdValue) {
|
if (categoryIdValue) {
|
||||||
const tagId = requirePositiveInteger(tagIdValue, 'server.validation.tagInvalid');
|
const categoryId = requirePositiveInteger(categoryIdValue, 'server.validation.lifeCategoryInvalid');
|
||||||
params.push(tagId);
|
params.push(categoryId);
|
||||||
conditions.push(`EXISTS (
|
conditions.push(`lp.category_id = $${params.length}`);
|
||||||
SELECT 1
|
|
||||||
FROM life_post_tags lpt_filter
|
|
||||||
WHERE lpt_filter.post_id = lp.id
|
|
||||||
AND lpt_filter.tag_id = $${params.length}
|
|
||||||
)`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cursor) {
|
if (cursor) {
|
||||||
@@ -3224,35 +3226,40 @@ async function getLifePostById(id: number, userId: number | null = null, locale
|
|||||||
return hydrateLifePost(post, commentPreviewByPost, commentCountsByPost, countsByPost, myReactionsByPost);
|
return hydrateLifePost(post, commentPreviewByPost, commentCountsByPost, countsByPost, myReactionsByPost);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function replaceLifePostTags(client: DbClient, postId: number, tagIds: number[]): Promise<void> {
|
async function ensureLifeCategory(client: DbClient, categoryId: number): Promise<void> {
|
||||||
await client.query('DELETE FROM life_post_tags WHERE post_id = $1', [postId]);
|
const result = await client.query<{ id: number }>('SELECT id FROM life_tags WHERE id = $1', [categoryId]);
|
||||||
|
if (result.rowCount === 0) {
|
||||||
|
throw validationError('server.validation.lifeCategoryInvalid');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const tagId of tagIds) {
|
async function replaceLifePostCategoryLink(client: DbClient, postId: number, categoryId: number): Promise<void> {
|
||||||
|
await client.query('DELETE FROM life_post_tags WHERE post_id = $1', [postId]);
|
||||||
await client.query(
|
await client.query(
|
||||||
`
|
`
|
||||||
INSERT INTO life_post_tags (post_id, tag_id)
|
INSERT INTO life_post_tags (post_id, tag_id)
|
||||||
VALUES ($1, $2)
|
VALUES ($1, $2)
|
||||||
`,
|
`,
|
||||||
[postId, tagId]
|
[postId, categoryId]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
export async function createLifePost(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
|
export async function createLifePost(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
|
||||||
const cleanPayload = cleanLifePostPayload(payload);
|
const cleanPayload = cleanLifePostPayload(payload);
|
||||||
|
|
||||||
const id = await withTransaction(async (client) => {
|
const id = await withTransaction(async (client) => {
|
||||||
|
await ensureLifeCategory(client, cleanPayload.categoryId);
|
||||||
const result = await client.query<{ id: number }>(
|
const result = await client.query<{ id: number }>(
|
||||||
`
|
`
|
||||||
INSERT INTO life_posts (body, ai_moderation_status, ai_moderation_language_code, created_by_user_id, updated_by_user_id)
|
INSERT INTO life_posts (body, category_id, ai_moderation_status, ai_moderation_language_code, created_by_user_id, updated_by_user_id)
|
||||||
VALUES ($1, 'reviewing', NULL, $2, $2)
|
VALUES ($1, $2, 'reviewing', NULL, $3, $3)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`,
|
`,
|
||||||
[cleanPayload.body, userId]
|
[cleanPayload.body, cleanPayload.categoryId, userId]
|
||||||
);
|
);
|
||||||
|
|
||||||
const createdId = result.rows[0].id;
|
const createdId = result.rows[0].id;
|
||||||
await replaceLifePostTags(client, createdId, cleanPayload.tagIds);
|
await replaceLifePostCategoryLink(client, createdId, cleanPayload.categoryId);
|
||||||
return createdId;
|
return createdId;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -3270,24 +3277,26 @@ export async function updateLifePost(
|
|||||||
const cleanPayload = cleanLifePostPayload(payload);
|
const cleanPayload = cleanLifePostPayload(payload);
|
||||||
|
|
||||||
const updatedId = await withTransaction(async (client) => {
|
const updatedId = await withTransaction(async (client) => {
|
||||||
|
await ensureLifeCategory(client, cleanPayload.categoryId);
|
||||||
const result = await client.query<{ id: number }>(
|
const result = await client.query<{ id: number }>(
|
||||||
`
|
`
|
||||||
UPDATE life_posts
|
UPDATE life_posts
|
||||||
SET body = $1,
|
SET body = $1,
|
||||||
|
category_id = $2,
|
||||||
ai_moderation_status = 'reviewing',
|
ai_moderation_status = 'reviewing',
|
||||||
ai_moderation_language_code = NULL,
|
ai_moderation_language_code = NULL,
|
||||||
ai_moderation_content_hash = NULL,
|
ai_moderation_content_hash = NULL,
|
||||||
ai_moderation_checked_at = NULL,
|
ai_moderation_checked_at = NULL,
|
||||||
ai_moderation_retry_count = 0,
|
ai_moderation_retry_count = 0,
|
||||||
ai_moderation_updated_at = now(),
|
ai_moderation_updated_at = now(),
|
||||||
updated_by_user_id = $2,
|
updated_by_user_id = $3,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE id = $3
|
WHERE id = $4
|
||||||
AND ($4 = true OR created_by_user_id = $2)
|
AND ($5 = true OR created_by_user_id = $3)
|
||||||
AND deleted_at IS NULL
|
AND deleted_at IS NULL
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`,
|
`,
|
||||||
[cleanPayload.body, userId, id, allowAny]
|
[cleanPayload.body, cleanPayload.categoryId, userId, id, allowAny]
|
||||||
);
|
);
|
||||||
|
|
||||||
const resultId = result.rows[0]?.id ?? null;
|
const resultId = result.rows[0]?.id ?? null;
|
||||||
@@ -3295,7 +3304,7 @@ export async function updateLifePost(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
await replaceLifePostTags(client, resultId, cleanPayload.tagIds);
|
await replaceLifePostCategoryLink(client, resultId, cleanPayload.categoryId);
|
||||||
return resultId;
|
return resultId;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -3879,25 +3888,36 @@ export async function createConfig(type: ConfigType, payload: Record<string, unk
|
|||||||
const name = cleanName(payload.name);
|
const name = cleanName(payload.name);
|
||||||
const translations = cleanTranslations(payload.translations, ['name']);
|
const translations = cleanTranslations(payload.translations, ['name']);
|
||||||
const hasItemDrop = definition.hasItemDrop ? Boolean(payload.hasItemDrop) : false;
|
const hasItemDrop = definition.hasItemDrop ? Boolean(payload.hasItemDrop) : false;
|
||||||
|
const isDefault = definition.hasDefault ? Boolean(payload.isDefault) : false;
|
||||||
|
|
||||||
const id = await withTransaction(async (client) => {
|
const id = await withTransaction(async (client) => {
|
||||||
const sortOrder = await nextSortOrder(client, definition.table);
|
const sortOrder = await nextSortOrder(client, definition.table);
|
||||||
const result = definition.hasItemDrop
|
if (definition.hasDefault && isDefault) {
|
||||||
? await client.query<{ id: number }>(
|
await client.query(
|
||||||
|
`UPDATE ${definition.table} SET is_default = false, updated_by_user_id = $1, updated_at = now() WHERE is_default = true`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const columns = ['name'];
|
||||||
|
const values: unknown[] = [name];
|
||||||
|
if (definition.hasItemDrop) {
|
||||||
|
columns.push('has_item_drop');
|
||||||
|
values.push(hasItemDrop);
|
||||||
|
}
|
||||||
|
if (definition.hasDefault) {
|
||||||
|
columns.push('is_default');
|
||||||
|
values.push(isDefault);
|
||||||
|
}
|
||||||
|
columns.push('sort_order', 'created_by_user_id', 'updated_by_user_id');
|
||||||
|
values.push(sortOrder, userId, userId);
|
||||||
|
const placeholders = values.map((_, index) => `$${index + 1}`).join(', ');
|
||||||
|
const result = await client.query<{ id: number }>(
|
||||||
`
|
`
|
||||||
INSERT INTO ${definition.table} (name, has_item_drop, sort_order, created_by_user_id, updated_by_user_id)
|
INSERT INTO ${definition.table} (${columns.join(', ')})
|
||||||
VALUES ($1, $2, $3, $4, $4)
|
VALUES (${placeholders})
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`,
|
`,
|
||||||
[name, hasItemDrop, sortOrder, userId]
|
values
|
||||||
)
|
|
||||||
: await client.query<{ id: number }>(
|
|
||||||
`
|
|
||||||
INSERT INTO ${definition.table} (name, sort_order, created_by_user_id, updated_by_user_id)
|
|
||||||
VALUES ($1, $2, $3, $3)
|
|
||||||
RETURNING id
|
|
||||||
`,
|
|
||||||
[name, sortOrder, userId]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const createdId = result.rows[0].id;
|
const createdId = result.rows[0].id;
|
||||||
@@ -3934,24 +3954,36 @@ export async function updateConfig(
|
|||||||
const name = cleanName(payload.name);
|
const name = cleanName(payload.name);
|
||||||
const translations = cleanTranslations(payload.translations, ['name']);
|
const translations = cleanTranslations(payload.translations, ['name']);
|
||||||
const hasItemDrop = definition.hasItemDrop ? Boolean(payload.hasItemDrop) : false;
|
const hasItemDrop = definition.hasItemDrop ? Boolean(payload.hasItemDrop) : false;
|
||||||
|
const isDefault = definition.hasDefault ? Boolean(payload.isDefault) : false;
|
||||||
|
|
||||||
const updated = await withTransaction(async (client) => {
|
const updated = await withTransaction(async (client) => {
|
||||||
const result = definition.hasItemDrop
|
if (definition.hasDefault && isDefault) {
|
||||||
? await client.query(
|
await client.query(
|
||||||
|
`UPDATE ${definition.table} SET is_default = false, updated_by_user_id = $2, updated_at = now() WHERE id <> $1 AND is_default = true`,
|
||||||
|
[id, userId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const assignments = ['name = $1'];
|
||||||
|
const values: unknown[] = [name];
|
||||||
|
if (definition.hasItemDrop) {
|
||||||
|
values.push(hasItemDrop);
|
||||||
|
assignments.push(`has_item_drop = $${values.length}`);
|
||||||
|
}
|
||||||
|
if (definition.hasDefault) {
|
||||||
|
values.push(isDefault);
|
||||||
|
assignments.push(`is_default = $${values.length}`);
|
||||||
|
}
|
||||||
|
values.push(userId);
|
||||||
|
assignments.push(`updated_by_user_id = $${values.length}`, 'updated_at = now()');
|
||||||
|
values.push(id);
|
||||||
|
const result = await client.query(
|
||||||
`
|
`
|
||||||
UPDATE ${definition.table}
|
UPDATE ${definition.table}
|
||||||
SET name = $1, has_item_drop = $2, updated_by_user_id = $3, updated_at = now()
|
SET ${assignments.join(', ')}
|
||||||
WHERE id = $4
|
WHERE id = $${values.length}
|
||||||
`,
|
`,
|
||||||
[name, hasItemDrop, userId, id]
|
values
|
||||||
)
|
|
||||||
: await client.query(
|
|
||||||
`
|
|
||||||
UPDATE ${definition.table}
|
|
||||||
SET name = $1, updated_by_user_id = $2, updated_at = now()
|
|
||||||
WHERE id = $3
|
|
||||||
`,
|
|
||||||
[name, userId, id]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.rowCount === 0) {
|
if (result.rowCount === 0) {
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ export interface NamedEntity {
|
|||||||
translations?: TranslationMap;
|
translations?: TranslationMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LifeCategory extends NamedEntity {
|
||||||
|
isDefault: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Skill extends NamedEntity {
|
export interface Skill extends NamedEntity {
|
||||||
hasItemDrop: boolean;
|
hasItemDrop: boolean;
|
||||||
}
|
}
|
||||||
@@ -256,7 +260,7 @@ export interface LifePost {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
author: UserSummary | null;
|
author: UserSummary | null;
|
||||||
updatedBy: UserSummary | null;
|
updatedBy: UserSummary | null;
|
||||||
tags: NamedEntity[];
|
category: NamedEntity | null;
|
||||||
commentPreview: LifeComment[];
|
commentPreview: LifeComment[];
|
||||||
commentCount: number;
|
commentCount: number;
|
||||||
reactionCounts: LifeReactionCounts;
|
reactionCounts: LifeReactionCounts;
|
||||||
@@ -273,7 +277,7 @@ export interface LifePostsParams {
|
|||||||
cursor?: string | null;
|
cursor?: string | null;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
search?: string;
|
search?: string;
|
||||||
tagId?: string | number;
|
categoryId?: string | number;
|
||||||
language?: string;
|
language?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,7 +324,7 @@ export interface Options {
|
|||||||
acquisitionMethods: NamedEntity[];
|
acquisitionMethods: NamedEntity[];
|
||||||
itemTags: NamedEntity[];
|
itemTags: NamedEntity[];
|
||||||
maps: NamedEntity[];
|
maps: NamedEntity[];
|
||||||
lifeTags: NamedEntity[];
|
lifeCategories: LifeCategory[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthUser {
|
export interface AuthUser {
|
||||||
@@ -558,7 +562,7 @@ export interface DailyChecklistPayload {
|
|||||||
|
|
||||||
export interface LifePostPayload {
|
export interface LifePostPayload {
|
||||||
body: string;
|
body: string;
|
||||||
tagIds: number[];
|
categoryId: number;
|
||||||
languageCode?: string | null;
|
languageCode?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -876,7 +880,7 @@ export const api = {
|
|||||||
cursor: params.cursor ?? undefined,
|
cursor: params.cursor ?? undefined,
|
||||||
limit: params.limit,
|
limit: params.limit,
|
||||||
search: params.search?.trim(),
|
search: params.search?.trim(),
|
||||||
tagId: params.tagId,
|
categoryId: params.categoryId,
|
||||||
language: params.language
|
language: params.language
|
||||||
})}`
|
})}`
|
||||||
),
|
),
|
||||||
@@ -945,13 +949,13 @@ export const api = {
|
|||||||
reorderDailyChecklistItems: (ids: number[]) =>
|
reorderDailyChecklistItems: (ids: number[]) =>
|
||||||
sendJson<DailyChecklistItem[]>('/api/admin/daily-checklist/order', 'PUT', { ids }),
|
sendJson<DailyChecklistItem[]>('/api/admin/daily-checklist/order', 'PUT', { ids }),
|
||||||
deleteDailyChecklistItem: (id: string | number) => deleteJson(`/api/admin/daily-checklist/${id}`),
|
deleteDailyChecklistItem: (id: string | number) => deleteJson(`/api/admin/daily-checklist/${id}`),
|
||||||
config: (type: ConfigType) => getJson<Array<Skill | NamedEntity>>(`/api/admin/config/${type}`),
|
config: (type: ConfigType) => getJson<Array<Skill | LifeCategory | NamedEntity>>(`/api/admin/config/${type}`),
|
||||||
createConfig: (type: ConfigType, payload: { name: string; translations?: TranslationMap; hasItemDrop?: boolean }) =>
|
createConfig: (type: ConfigType, payload: { name: string; translations?: TranslationMap; hasItemDrop?: boolean; isDefault?: boolean }) =>
|
||||||
sendJson<Skill | NamedEntity>(`/api/admin/config/${type}`, 'POST', payload),
|
sendJson<Skill | LifeCategory | NamedEntity>(`/api/admin/config/${type}`, 'POST', payload),
|
||||||
reorderConfig: (type: ConfigType, ids: number[]) =>
|
reorderConfig: (type: ConfigType, ids: number[]) =>
|
||||||
sendJson<Array<Skill | NamedEntity>>(`/api/admin/config/${type}/order`, 'PUT', { ids }),
|
sendJson<Array<Skill | LifeCategory | NamedEntity>>(`/api/admin/config/${type}/order`, 'PUT', { ids }),
|
||||||
updateConfig: (type: ConfigType, id: number, payload: { name: string; translations?: TranslationMap; hasItemDrop?: boolean }) =>
|
updateConfig: (type: ConfigType, id: number, payload: { name: string; translations?: TranslationMap; hasItemDrop?: boolean; isDefault?: boolean }) =>
|
||||||
sendJson<Skill | NamedEntity>(`/api/admin/config/${type}/${id}`, 'PUT', payload),
|
sendJson<Skill | LifeCategory | NamedEntity>(`/api/admin/config/${type}/${id}`, 'PUT', payload),
|
||||||
deleteConfig: (type: ConfigType, id: number) => deleteJson(`/api/admin/config/${type}/${id}`),
|
deleteConfig: (type: ConfigType, id: number) => deleteJson(`/api/admin/config/${type}/${id}`),
|
||||||
pokemon: (params: Record<string, string | number | undefined>) =>
|
pokemon: (params: Record<string, string | number | undefined>) =>
|
||||||
getJson<Pokemon[]>(`/api/pokemon${buildQuery(params)}`),
|
getJson<Pokemon[]>(`/api/pokemon${buildQuery(params)}`),
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
type Habitat,
|
type Habitat,
|
||||||
type Item,
|
type Item,
|
||||||
type Language,
|
type Language,
|
||||||
|
type LifeCategory,
|
||||||
type NamedEntity,
|
type NamedEntity,
|
||||||
type Permission,
|
type Permission,
|
||||||
type PermissionPayload,
|
type PermissionPayload,
|
||||||
@@ -69,7 +70,7 @@ type AdminTab =
|
|||||||
type AdminGroup = 'content' | 'configuration' | 'localization' | 'access';
|
type AdminGroup = 'content' | 'configuration' | 'localization' | 'access';
|
||||||
type AdminNavItem = { key: AdminTab; label: string; permission: string | string[] };
|
type AdminNavItem = { key: AdminTab; label: string; permission: string | string[] };
|
||||||
type AdminNavGroup = { key: AdminGroup; label: string; items: AdminNavItem[] };
|
type AdminNavGroup = { key: AdminGroup; label: string; items: AdminNavItem[] };
|
||||||
type EditableConfig = (NamedEntity | Skill) & { hasItemDrop?: boolean };
|
type EditableConfig = (NamedEntity | Skill | LifeCategory) & { hasItemDrop?: boolean; isDefault?: boolean };
|
||||||
|
|
||||||
const adminTabIcons: Record<AdminTab, AppIcon> = {
|
const adminTabIcons: Record<AdminTab, AppIcon> = {
|
||||||
users: iconProfile,
|
users: iconProfile,
|
||||||
@@ -132,7 +133,7 @@ const adminNavigationGroups = computed<AdminNavGroup[]>(() => {
|
|||||||
});
|
});
|
||||||
const tabs = computed<AdminNavItem[]>(() => adminNavigationGroups.value.flatMap((group) => group.items));
|
const tabs = computed<AdminNavItem[]>(() => adminNavigationGroups.value.flatMap((group) => group.items));
|
||||||
|
|
||||||
const configTypes = computed<Array<{ key: ConfigType; label: string; supportsItemDrop?: boolean }>>(() => [
|
const configTypes = computed<Array<{ key: ConfigType; label: string; supportsItemDrop?: boolean; supportsDefault?: boolean }>>(() => [
|
||||||
{ key: 'pokemon-types', label: t('config.pokemonTypes') },
|
{ key: 'pokemon-types', label: t('config.pokemonTypes') },
|
||||||
{ key: 'skills', label: t('config.skills'), supportsItemDrop: true },
|
{ key: 'skills', label: t('config.skills'), supportsItemDrop: true },
|
||||||
{ key: 'environments', label: t('config.environments') },
|
{ key: 'environments', label: t('config.environments') },
|
||||||
@@ -141,7 +142,7 @@ const configTypes = computed<Array<{ key: ConfigType; label: string; supportsIte
|
|||||||
{ key: 'item-usages', label: t('config.itemUsages') },
|
{ key: 'item-usages', label: t('config.itemUsages') },
|
||||||
{ key: 'acquisition-methods', label: t('config.acquisitionMethods') },
|
{ key: 'acquisition-methods', label: t('config.acquisitionMethods') },
|
||||||
{ key: 'maps', label: t('config.maps') },
|
{ key: 'maps', label: t('config.maps') },
|
||||||
{ key: 'life-tags', label: t('config.lifeTags') }
|
{ key: 'life-tags', label: t('config.lifeCategories'), supportsDefault: true }
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const activeTab = ref<AdminTab>('config');
|
const activeTab = ref<AdminTab>('config');
|
||||||
@@ -162,7 +163,7 @@ const currentUser = ref<AuthUser | null>(null);
|
|||||||
const busy = ref(false);
|
const busy = ref(false);
|
||||||
const contentLoading = ref(false);
|
const contentLoading = ref(false);
|
||||||
const message = ref('');
|
const message = ref('');
|
||||||
const configForm = ref({ id: 0, name: '', translations: {} as TranslationMap, hasItemDrop: false });
|
const configForm = ref({ id: 0, name: '', translations: {} as TranslationMap, hasItemDrop: false, isDefault: false });
|
||||||
const checklistForm = ref({ id: 0, title: '', translations: {} as TranslationMap });
|
const checklistForm = ref({ id: 0, title: '', translations: {} as TranslationMap });
|
||||||
const languageForm = ref({ code: '', name: '', enabled: true, isDefault: false, sortOrder: 0 });
|
const languageForm = ref({ code: '', name: '', enabled: true, isDefault: false, sortOrder: 0 });
|
||||||
const wordingForm = ref({ key: '', locale: defaultLocale, value: '', defaultValue: '', placeholders: [] as string[] });
|
const wordingForm = ref({ key: '', locale: defaultLocale, value: '', defaultValue: '', placeholders: [] as string[] });
|
||||||
@@ -375,7 +376,7 @@ async function loadLanguages() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resetConfigForm() {
|
function resetConfigForm() {
|
||||||
configForm.value = { id: 0, name: '', translations: {}, hasItemDrop: false };
|
configForm.value = { id: 0, name: '', translations: {}, hasItemDrop: false, isDefault: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetChecklistForm() {
|
function resetChecklistForm() {
|
||||||
@@ -435,7 +436,13 @@ function closeConfigModal() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function editConfig(item: EditableConfig) {
|
function editConfig(item: EditableConfig) {
|
||||||
configForm.value = { id: item.id, name: item.baseName ?? item.name, translations: item.translations ?? {}, hasItemDrop: item.hasItemDrop === true };
|
configForm.value = {
|
||||||
|
id: item.id,
|
||||||
|
name: item.baseName ?? item.name,
|
||||||
|
translations: item.translations ?? {},
|
||||||
|
hasItemDrop: item.hasItemDrop === true,
|
||||||
|
isDefault: item.isDefault === true
|
||||||
|
};
|
||||||
configModalOpen.value = true;
|
configModalOpen.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -709,7 +716,8 @@ async function saveConfig() {
|
|||||||
const payload = {
|
const payload = {
|
||||||
name: configBaseNameForSave(),
|
name: configBaseNameForSave(),
|
||||||
translations: configForm.value.translations,
|
translations: configForm.value.translations,
|
||||||
hasItemDrop: selectedConfig.value.supportsItemDrop ? configForm.value.hasItemDrop : undefined
|
hasItemDrop: selectedConfig.value.supportsItemDrop ? configForm.value.hasItemDrop : undefined,
|
||||||
|
isDefault: selectedConfig.value.supportsDefault ? configForm.value.isDefault : undefined
|
||||||
};
|
};
|
||||||
|
|
||||||
if (configForm.value.id) {
|
if (configForm.value.id) {
|
||||||
@@ -1270,7 +1278,9 @@ onMounted(() => {
|
|||||||
>
|
>
|
||||||
<template #default="{ item }">
|
<template #default="{ item }">
|
||||||
<span class="reorderable-row-title">
|
<span class="reorderable-row-title">
|
||||||
{{ item.name }}<span v-if="item.hasItemDrop" class="config-flag">{{ t('pages.admin.hasItemDrop') }}</span>
|
{{ item.name }}
|
||||||
|
<span v-if="item.hasItemDrop" class="config-flag">{{ t('pages.admin.hasItemDrop') }}</span>
|
||||||
|
<span v-if="item.isDefault" class="config-flag">{{ t('pages.admin.defaultCategory') }}</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="row-actions">
|
<span class="row-actions">
|
||||||
<button v-if="can('admin.config.update')" type="button" :disabled="busy" @click="editConfig(item)">
|
<button v-if="can('admin.config.update')" type="button" :disabled="busy" @click="editConfig(item)">
|
||||||
@@ -1799,6 +1809,12 @@ onMounted(() => {
|
|||||||
{{ t('pages.admin.hasItemDrop') }}
|
{{ t('pages.admin.hasItemDrop') }}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="selectedConfig.supportsDefault" class="check-row">
|
||||||
|
<label>
|
||||||
|
<input v-model="configForm.isDefault" type="checkbox" />
|
||||||
|
{{ t('pages.admin.defaultCategory') }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
|
|||||||
@@ -35,10 +35,10 @@ import {
|
|||||||
type AiModerationStatus,
|
type AiModerationStatus,
|
||||||
type AuthUser,
|
type AuthUser,
|
||||||
type Language,
|
type Language,
|
||||||
|
type LifeCategory,
|
||||||
type LifeComment,
|
type LifeComment,
|
||||||
type LifePost,
|
type LifePost,
|
||||||
type LifeReactionType,
|
type LifeReactionType
|
||||||
type NamedEntity
|
|
||||||
} from '../services/api';
|
} from '../services/api';
|
||||||
|
|
||||||
type LifeCommentPageState = {
|
type LifeCommentPageState = {
|
||||||
@@ -54,7 +54,7 @@ type LifeCommentPageState = {
|
|||||||
|
|
||||||
const { locale, t } = useI18n();
|
const { locale, t } = useI18n();
|
||||||
const posts = ref<LifePost[]>([]);
|
const posts = ref<LifePost[]>([]);
|
||||||
const lifeTags = ref<NamedEntity[]>([]);
|
const lifeCategories = ref<LifeCategory[]>([]);
|
||||||
const languages = ref<Language[]>([]);
|
const languages = ref<Language[]>([]);
|
||||||
const currentUser = ref<AuthUser | null>(null);
|
const currentUser = ref<AuthUser | null>(null);
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
@@ -63,10 +63,10 @@ const authReady = ref(false);
|
|||||||
const busy = ref(false);
|
const busy = ref(false);
|
||||||
const searchDraft = ref('');
|
const searchDraft = ref('');
|
||||||
const submittedSearch = ref('');
|
const submittedSearch = ref('');
|
||||||
const activeTagId = ref('all');
|
const activeCategoryId = ref('all');
|
||||||
const activeLanguageCode = ref('all');
|
const activeLanguageCode = ref('all');
|
||||||
const body = ref('');
|
const body = ref('');
|
||||||
const selectedTagIds = ref<string[]>([]);
|
const selectedCategoryId = ref('');
|
||||||
const editingPostId = ref<number | null>(null);
|
const editingPostId = ref<number | null>(null);
|
||||||
const postModalOpen = ref(false);
|
const postModalOpen = ref(false);
|
||||||
const formError = ref('');
|
const formError = ref('');
|
||||||
@@ -97,7 +97,7 @@ let postsRequestId = 0;
|
|||||||
const nextCursor = ref<string | null>(null);
|
const nextCursor = ref<string | null>(null);
|
||||||
const hasMorePosts = ref(false);
|
const hasMorePosts = ref(false);
|
||||||
const loadMorePaused = ref(false);
|
const loadMorePaused = ref(false);
|
||||||
const allTagValue = 'all';
|
const allCategoryValue = 'all';
|
||||||
const allLanguageValue = 'all';
|
const allLanguageValue = 'all';
|
||||||
|
|
||||||
const reactionOptions = [
|
const reactionOptions = [
|
||||||
@@ -117,21 +117,25 @@ const canReact = computed(() => can('life.reactions.set'));
|
|||||||
const charactersLeft = computed(() => Math.max(0, bodyMaxLength - body.value.length));
|
const charactersLeft = computed(() => Math.max(0, bodyMaxLength - body.value.length));
|
||||||
const isEditing = computed(() => editingPostId.value !== null);
|
const isEditing = computed(() => editingPostId.value !== null);
|
||||||
const searchQuery = computed(() => submittedSearch.value.trim());
|
const searchQuery = computed(() => submittedSearch.value.trim());
|
||||||
const selectedFeedTagId = computed(() => {
|
const selectedFeedCategoryId = computed(() => {
|
||||||
const tagId = Number(activeTagId.value);
|
const categoryId = Number(activeCategoryId.value);
|
||||||
return activeTagId.value === allTagValue || !Number.isInteger(tagId) || tagId <= 0 ? undefined : tagId;
|
return activeCategoryId.value === allCategoryValue || !Number.isInteger(categoryId) || categoryId <= 0 ? undefined : categoryId;
|
||||||
});
|
});
|
||||||
const selectedFeedLanguageCode = computed(() =>
|
const selectedFeedLanguageCode = computed(() =>
|
||||||
activeLanguageCode.value === allLanguageValue ? undefined : activeLanguageCode.value
|
activeLanguageCode.value === allLanguageValue ? undefined : activeLanguageCode.value
|
||||||
);
|
);
|
||||||
const tagFilterOptions = computed<TabOption[]>(() => [
|
const categoryFilterOptions = computed<TabOption[]>(() => [
|
||||||
{ value: allTagValue, label: t('pages.life.allTags') },
|
{ value: allCategoryValue, label: t('pages.life.allCategories') },
|
||||||
...lifeTags.value.map((tag) => ({ value: String(tag.id), label: tag.name }))
|
...lifeCategories.value.map((category) => ({ value: String(category.id), label: category.name }))
|
||||||
]);
|
]);
|
||||||
const languageFilterOptions = computed<TabOption[]>(() => [
|
const languageFilterOptions = computed<TabOption[]>(() => [
|
||||||
{ value: allLanguageValue, label: t('pages.life.allLanguages') },
|
{ value: allLanguageValue, label: t('pages.life.allLanguages') },
|
||||||
...languages.value.map((language) => ({ value: language.code, label: language.name }))
|
...languages.value.map((language) => ({ value: language.code, label: language.name }))
|
||||||
]);
|
]);
|
||||||
|
const defaultLifeCategoryId = computed(() => {
|
||||||
|
const category = lifeCategories.value.find((item) => item.isDefault);
|
||||||
|
return category ? String(category.id) : '';
|
||||||
|
});
|
||||||
const postModalTitle = computed(() => (isEditing.value ? t('pages.life.editPost') : t('pages.life.newPost')));
|
const postModalTitle = computed(() => (isEditing.value ? t('pages.life.editPost') : t('pages.life.newPost')));
|
||||||
const submitLabel = computed(() => {
|
const submitLabel = computed(() => {
|
||||||
if (busy.value) return isEditing.value ? t('pages.life.updating') : t('pages.life.publishing');
|
if (busy.value) return isEditing.value ? t('pages.life.updating') : t('pages.life.publishing');
|
||||||
@@ -158,13 +162,19 @@ async function loadCurrentUser() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadLifeTags() {
|
async function loadLifeCategories() {
|
||||||
try {
|
try {
|
||||||
const options = await api.options();
|
const options = await api.options();
|
||||||
lifeTags.value = options.lifeTags;
|
lifeCategories.value = options.lifeCategories;
|
||||||
|
|
||||||
if (activeTagId.value !== allTagValue && !lifeTags.value.some((tag) => String(tag.id) === activeTagId.value)) {
|
if (activeCategoryId.value !== allCategoryValue && !lifeCategories.value.some((category) => String(category.id) === activeCategoryId.value)) {
|
||||||
activeTagId.value = allTagValue;
|
activeCategoryId.value = allCategoryValue;
|
||||||
|
}
|
||||||
|
if (!isEditing.value && postModalOpen.value && !selectedCategoryId.value) {
|
||||||
|
selectedCategoryId.value = defaultLifeCategoryId.value;
|
||||||
|
}
|
||||||
|
if (!isEditing.value && selectedCategoryId.value && !lifeCategories.value.some((category) => String(category.id) === selectedCategoryId.value)) {
|
||||||
|
selectedCategoryId.value = defaultLifeCategoryId.value;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
loadError.value = error instanceof Error && error.message ? error.message : t('errors.loadFailed');
|
loadError.value = error instanceof Error && error.message ? error.message : t('errors.loadFailed');
|
||||||
@@ -199,7 +209,7 @@ async function loadPosts() {
|
|||||||
const page = await api.lifePosts({
|
const page = await api.lifePosts({
|
||||||
limit: lifePostPageSize,
|
limit: lifePostPageSize,
|
||||||
search: searchQuery.value,
|
search: searchQuery.value,
|
||||||
tagId: selectedFeedTagId.value,
|
categoryId: selectedFeedCategoryId.value,
|
||||||
language: selectedFeedLanguageCode.value
|
language: selectedFeedLanguageCode.value
|
||||||
});
|
});
|
||||||
if (requestId !== postsRequestId) {
|
if (requestId !== postsRequestId) {
|
||||||
@@ -241,7 +251,7 @@ async function loadMorePosts() {
|
|||||||
cursor,
|
cursor,
|
||||||
limit: lifePostPageSize,
|
limit: lifePostPageSize,
|
||||||
search: searchQuery.value,
|
search: searchQuery.value,
|
||||||
tagId: selectedFeedTagId.value,
|
categoryId: selectedFeedCategoryId.value,
|
||||||
language: selectedFeedLanguageCode.value
|
language: selectedFeedLanguageCode.value
|
||||||
});
|
});
|
||||||
if (requestId !== postsRequestId) {
|
if (requestId !== postsRequestId) {
|
||||||
@@ -266,7 +276,7 @@ async function loadMorePosts() {
|
|||||||
|
|
||||||
function resetForm() {
|
function resetForm() {
|
||||||
body.value = '';
|
body.value = '';
|
||||||
selectedTagIds.value = [];
|
selectedCategoryId.value = '';
|
||||||
editingPostId.value = null;
|
editingPostId.value = null;
|
||||||
formError.value = '';
|
formError.value = '';
|
||||||
}
|
}
|
||||||
@@ -274,13 +284,14 @@ function resetForm() {
|
|||||||
function payload() {
|
function payload() {
|
||||||
return {
|
return {
|
||||||
body: body.value.trim(),
|
body: body.value.trim(),
|
||||||
tagIds: selectedLifeTagIds(),
|
categoryId: selectedLifeCategoryId() ?? 0,
|
||||||
languageCode: selectedFeedLanguageCode.value ?? null
|
languageCode: selectedFeedLanguageCode.value ?? null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectedLifeTagIds() {
|
function selectedLifeCategoryId() {
|
||||||
return selectedTagIds.value.map((tagId) => Number(tagId)).filter((tagId) => Number.isInteger(tagId) && tagId > 0);
|
const categoryId = Number(selectedCategoryId.value);
|
||||||
|
return Number.isInteger(categoryId) && categoryId > 0 ? categoryId : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function submitSearch() {
|
function submitSearch() {
|
||||||
@@ -314,16 +325,17 @@ function retryLoadMore() {
|
|||||||
|
|
||||||
function matchesCurrentFilters(post: LifePost) {
|
function matchesCurrentFilters(post: LifePost) {
|
||||||
const keyword = searchQuery.value.toLowerCase();
|
const keyword = searchQuery.value.toLowerCase();
|
||||||
const tagId = selectedFeedTagId.value;
|
const categoryId = selectedFeedCategoryId.value;
|
||||||
const matchesSearch = keyword === '' || post.body.toLowerCase().includes(keyword);
|
const matchesSearch = keyword === '' || post.body.toLowerCase().includes(keyword);
|
||||||
const matchesTag = tagId === undefined || post.tags.some((tag) => tag.id === tagId);
|
const matchesCategory = categoryId === undefined || post.category?.id === categoryId;
|
||||||
const matchesLanguage =
|
const matchesLanguage =
|
||||||
selectedFeedLanguageCode.value === undefined || post.moderationLanguageCode === selectedFeedLanguageCode.value;
|
selectedFeedLanguageCode.value === undefined || post.moderationLanguageCode === selectedFeedLanguageCode.value;
|
||||||
return matchesSearch && matchesTag && matchesLanguage;
|
return matchesSearch && matchesCategory && matchesLanguage;
|
||||||
}
|
}
|
||||||
|
|
||||||
function openCreatePostModal() {
|
function openCreatePostModal() {
|
||||||
resetForm();
|
resetForm();
|
||||||
|
selectedCategoryId.value = defaultLifeCategoryId.value;
|
||||||
postModalOpen.value = true;
|
postModalOpen.value = true;
|
||||||
void nextTick(() => bodyInput.value?.focus());
|
void nextTick(() => bodyInput.value?.focus());
|
||||||
}
|
}
|
||||||
@@ -344,9 +356,9 @@ async function submitPost() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedLifeTagIds().length === 0) {
|
if (selectedLifeCategoryId() === null) {
|
||||||
formError.value = t('pages.life.tagRequired');
|
formError.value = t('pages.life.categoryRequired');
|
||||||
document.getElementById('life-post-tags')?.focus();
|
document.getElementById('life-post-category')?.focus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -696,7 +708,7 @@ async function toggleReaction(post: LifePost, reactionType: LifeReactionType) {
|
|||||||
function startEdit(post: LifePost) {
|
function startEdit(post: LifePost) {
|
||||||
editingPostId.value = post.id;
|
editingPostId.value = post.id;
|
||||||
body.value = post.body;
|
body.value = post.body;
|
||||||
selectedTagIds.value = post.tags.map((tag) => String(tag.id));
|
selectedCategoryId.value = post.category ? String(post.category.id) : '';
|
||||||
formError.value = '';
|
formError.value = '';
|
||||||
postModalOpen.value = true;
|
postModalOpen.value = true;
|
||||||
void nextTick(() => bodyInput.value?.focus());
|
void nextTick(() => bodyInput.value?.focus());
|
||||||
@@ -879,7 +891,7 @@ function observeLoadMore() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
watch([loadMoreSentinel, hasMorePosts, loading, loadingMore, loadMorePaused], observeLoadMore, { flush: 'post' });
|
watch([loadMoreSentinel, hasMorePosts, loading, loadingMore, loadMorePaused], observeLoadMore, { flush: 'post' });
|
||||||
watch(activeTagId, () => {
|
watch(activeCategoryId, () => {
|
||||||
void loadPosts();
|
void loadPosts();
|
||||||
});
|
});
|
||||||
watch(activeLanguageCode, () => {
|
watch(activeLanguageCode, () => {
|
||||||
@@ -889,7 +901,7 @@ watch(activeLanguageCode, () => {
|
|||||||
});
|
});
|
||||||
watch(locale, () => {
|
watch(locale, () => {
|
||||||
void loadLanguages();
|
void loadLanguages();
|
||||||
void loadLifeTags();
|
void loadLifeCategories();
|
||||||
void loadPosts();
|
void loadPosts();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -898,7 +910,7 @@ onMounted(() => {
|
|||||||
document.addEventListener('keydown', closeReactionPickerFromKeyboard);
|
document.addEventListener('keydown', closeReactionPickerFromKeyboard);
|
||||||
void loadCurrentUser();
|
void loadCurrentUser();
|
||||||
void loadLanguages();
|
void loadLanguages();
|
||||||
void loadLifeTags();
|
void loadLifeCategories();
|
||||||
void loadPosts();
|
void loadPosts();
|
||||||
removeAuthListener = onAuthTokenChange(() => {
|
removeAuthListener = onAuthTokenChange(() => {
|
||||||
void loadCurrentUser();
|
void loadCurrentUser();
|
||||||
@@ -981,13 +993,14 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="life-post-tags">{{ t('pages.life.tags') }}</label>
|
<label for="life-post-category">{{ t('pages.life.category') }}</label>
|
||||||
<TagsSelect
|
<TagsSelect
|
||||||
id="life-post-tags"
|
id="life-post-category"
|
||||||
v-model="selectedTagIds"
|
v-model="selectedCategoryId"
|
||||||
:options="lifeTags"
|
:options="lifeCategories"
|
||||||
:placeholder="t('pages.life.tagPlaceholder')"
|
:multiple="false"
|
||||||
:search-placeholder="t('pages.life.searchTags')"
|
:placeholder="t('pages.life.categoryPlaceholder')"
|
||||||
|
:search-placeholder="t('pages.life.searchCategories')"
|
||||||
dropdown-strategy="fixed"
|
dropdown-strategy="fixed"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1015,7 +1028,7 @@ onUnmounted(() => {
|
|||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<Tabs id="life-language-filter" v-model="activeLanguageCode" :tabs="languageFilterOptions" :label="t('pages.life.languages')" />
|
<Tabs id="life-language-filter" v-model="activeLanguageCode" :tabs="languageFilterOptions" :label="t('pages.life.languages')" />
|
||||||
<Tabs id="life-tag-filter" v-model="activeTagId" :tabs="tagFilterOptions" :label="t('pages.life.tags')" />
|
<Tabs id="life-category-filter" v-model="activeCategoryId" :tabs="categoryFilterOptions" :label="t('pages.life.category')" />
|
||||||
|
|
||||||
<section class="life-feed" :aria-busy="loading || loadingMore" :aria-label="t('pages.life.kicker')">
|
<section class="life-feed" :aria-busy="loading || loadingMore" :aria-label="t('pages.life.kicker')">
|
||||||
<div v-if="loading" class="life-feed__list" :aria-label="t('pages.life.loading')">
|
<div v-if="loading" class="life-feed__list" :aria-label="t('pages.life.loading')">
|
||||||
@@ -1083,8 +1096,8 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<p class="life-post__body">{{ post.body }}</p>
|
<p class="life-post__body">{{ post.body }}</p>
|
||||||
|
|
||||||
<div v-if="post.tags.length" class="life-post__tags" :aria-label="t('pages.life.tags')">
|
<div v-if="post.category" class="life-post__tags" :aria-label="t('pages.life.category')">
|
||||||
<span v-for="tag in post.tags" :key="tag.id" class="life-post__tag">{{ tag.name }}</span>
|
<span class="life-post__tag">{{ post.category.name }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="life-post__engagement">
|
<div class="life-post__engagement">
|
||||||
|
|||||||
@@ -593,7 +593,7 @@ function contentTypeLabel(contentType: string): string {
|
|||||||
'item-usages': t('config.itemUsages'),
|
'item-usages': t('config.itemUsages'),
|
||||||
'acquisition-methods': t('config.acquisitionMethods'),
|
'acquisition-methods': t('config.acquisitionMethods'),
|
||||||
maps: t('config.maps'),
|
maps: t('config.maps'),
|
||||||
'life-tags': t('config.lifeTags')
|
'life-tags': t('config.lifeCategories')
|
||||||
};
|
};
|
||||||
return labels[contentType] ?? t('pages.profile.otherContributions');
|
return labels[contentType] ?? t('pages.profile.otherContributions');
|
||||||
}
|
}
|
||||||
@@ -726,8 +726,8 @@ onMounted(() => {
|
|||||||
|
|
||||||
<p class="life-post__body">{{ post.body }}</p>
|
<p class="life-post__body">{{ post.body }}</p>
|
||||||
|
|
||||||
<div v-if="post.tags.length" class="life-post__tags" :aria-label="t('pages.life.tags')">
|
<div v-if="post.category" class="life-post__tags" :aria-label="t('pages.life.category')">
|
||||||
<span v-for="tag in post.tags" :key="tag.id" class="life-post__tag">{{ tag.name }}</span>
|
<span class="life-post__tag">{{ post.category.name }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="profile-feed-card__metrics">
|
<div class="profile-feed-card__metrics">
|
||||||
|
|||||||
@@ -449,12 +449,12 @@ export const systemWordingMessages = {
|
|||||||
bodyLabel: 'Post',
|
bodyLabel: 'Post',
|
||||||
bodyPlaceholder: 'Share a thought, tip, or discovery...',
|
bodyPlaceholder: 'Share a thought, tip, or discovery...',
|
||||||
newPost: 'New Post',
|
newPost: 'New Post',
|
||||||
tags: 'Tags',
|
category: 'Category',
|
||||||
languages: 'Languages',
|
languages: 'Languages',
|
||||||
allLanguages: 'All languages',
|
allLanguages: 'All languages',
|
||||||
allTags: 'All',
|
allCategories: 'All',
|
||||||
tagPlaceholder: 'Select tags',
|
categoryPlaceholder: 'Select category',
|
||||||
searchTags: 'Search tags',
|
searchCategories: 'Search categories',
|
||||||
search: 'Search Life',
|
search: 'Search Life',
|
||||||
searchPlaceholder: 'Search post content...',
|
searchPlaceholder: 'Search post content...',
|
||||||
clearSearch: 'Clear search',
|
clearSearch: 'Clear search',
|
||||||
@@ -512,7 +512,7 @@ export const systemWordingMessages = {
|
|||||||
saveFailed: 'Save failed',
|
saveFailed: 'Save failed',
|
||||||
deleteFailed: 'Delete failed',
|
deleteFailed: 'Delete failed',
|
||||||
bodyRequired: 'Please enter a post.',
|
bodyRequired: 'Please enter a post.',
|
||||||
tagRequired: 'Please select at least one tag.',
|
categoryRequired: 'Please select a category.',
|
||||||
byUnknown: 'Community member',
|
byUnknown: 'Community member',
|
||||||
edited: 'Edited',
|
edited: 'Edited',
|
||||||
deleteConfirm: 'Delete this post?',
|
deleteConfirm: 'Delete this post?',
|
||||||
@@ -555,6 +555,7 @@ export const systemWordingMessages = {
|
|||||||
languageName: 'Language name',
|
languageName: 'Language name',
|
||||||
enabled: 'Enabled',
|
enabled: 'Enabled',
|
||||||
defaultLanguage: 'Default language',
|
defaultLanguage: 'Default language',
|
||||||
|
defaultCategory: 'Default category',
|
||||||
sortOrder: 'Sort order',
|
sortOrder: 'Sort order',
|
||||||
newLanguage: 'New language',
|
newLanguage: 'New language',
|
||||||
editLanguage: 'Edit language',
|
editLanguage: 'Edit language',
|
||||||
@@ -620,7 +621,7 @@ export const systemWordingMessages = {
|
|||||||
itemUsages: 'Item usages',
|
itemUsages: 'Item usages',
|
||||||
acquisitionMethods: 'Acquisition methods',
|
acquisitionMethods: 'Acquisition methods',
|
||||||
maps: 'Maps',
|
maps: 'Maps',
|
||||||
lifeTags: 'Life tags'
|
lifeCategories: 'Life categories'
|
||||||
},
|
},
|
||||||
appearance: {
|
appearance: {
|
||||||
time: 'Time',
|
time: 'Time',
|
||||||
@@ -763,7 +764,8 @@ export const systemWordingMessages = {
|
|||||||
taskDoesNotExist: 'Task does not exist',
|
taskDoesNotExist: 'Task does not exist',
|
||||||
postRequired: 'Please enter a post',
|
postRequired: 'Please enter a post',
|
||||||
postTooLong: 'Post is too long',
|
postTooLong: 'Post is too long',
|
||||||
lifeTagRequired: 'Please select at least one tag',
|
lifeCategoryRequired: 'Please select a category',
|
||||||
|
lifeCategoryInvalid: 'Category is invalid',
|
||||||
commentRequired: 'Please enter a comment',
|
commentRequired: 'Please enter a comment',
|
||||||
commentTooLong: 'Comment is too long',
|
commentTooLong: 'Comment is too long',
|
||||||
reactionInvalid: 'Reaction is invalid',
|
reactionInvalid: 'Reaction is invalid',
|
||||||
@@ -1270,12 +1272,12 @@ export const systemWordingMessages = {
|
|||||||
bodyLabel: '动态内容',
|
bodyLabel: '动态内容',
|
||||||
bodyPlaceholder: '分享一段想法、心得或发现……',
|
bodyPlaceholder: '分享一段想法、心得或发现……',
|
||||||
newPost: 'New Post',
|
newPost: 'New Post',
|
||||||
tags: '标签',
|
category: 'Category',
|
||||||
languages: '语言区',
|
languages: '语言区',
|
||||||
allLanguages: '全部语言',
|
allLanguages: '全部语言',
|
||||||
allTags: '全部',
|
allCategories: '全部',
|
||||||
tagPlaceholder: '选择标签',
|
categoryPlaceholder: '选择 Category',
|
||||||
searchTags: '搜索标签',
|
searchCategories: '搜索 Category',
|
||||||
search: '搜索动态',
|
search: '搜索动态',
|
||||||
searchPlaceholder: '搜索动态内容……',
|
searchPlaceholder: '搜索动态内容……',
|
||||||
clearSearch: '清除搜索',
|
clearSearch: '清除搜索',
|
||||||
@@ -1333,7 +1335,7 @@ export const systemWordingMessages = {
|
|||||||
saveFailed: '保存失败',
|
saveFailed: '保存失败',
|
||||||
deleteFailed: '删除失败',
|
deleteFailed: '删除失败',
|
||||||
bodyRequired: '请输入动态内容。',
|
bodyRequired: '请输入动态内容。',
|
||||||
tagRequired: '请至少选择 1 个标签。',
|
categoryRequired: '请选择 Category。',
|
||||||
byUnknown: '社区成员',
|
byUnknown: '社区成员',
|
||||||
edited: '已编辑',
|
edited: '已编辑',
|
||||||
deleteConfirm: '确认删除这条动态?',
|
deleteConfirm: '确认删除这条动态?',
|
||||||
@@ -1376,6 +1378,7 @@ export const systemWordingMessages = {
|
|||||||
languageName: '语言名称',
|
languageName: '语言名称',
|
||||||
enabled: '启用',
|
enabled: '启用',
|
||||||
defaultLanguage: '默认语言',
|
defaultLanguage: '默认语言',
|
||||||
|
defaultCategory: '默认 Category',
|
||||||
sortOrder: '排序',
|
sortOrder: '排序',
|
||||||
newLanguage: '新增语言',
|
newLanguage: '新增语言',
|
||||||
editLanguage: '编辑语言',
|
editLanguage: '编辑语言',
|
||||||
@@ -1441,7 +1444,7 @@ export const systemWordingMessages = {
|
|||||||
itemUsages: '物品用途',
|
itemUsages: '物品用途',
|
||||||
acquisitionMethods: '入手方式',
|
acquisitionMethods: '入手方式',
|
||||||
maps: '地图',
|
maps: '地图',
|
||||||
lifeTags: 'Life 标签'
|
lifeCategories: 'Life Categories'
|
||||||
},
|
},
|
||||||
appearance: {
|
appearance: {
|
||||||
time: '时段',
|
time: '时段',
|
||||||
@@ -1584,7 +1587,8 @@ export const systemWordingMessages = {
|
|||||||
taskDoesNotExist: '任务不存在',
|
taskDoesNotExist: '任务不存在',
|
||||||
postRequired: '请输入动态内容',
|
postRequired: '请输入动态内容',
|
||||||
postTooLong: '动态内容过长',
|
postTooLong: '动态内容过长',
|
||||||
lifeTagRequired: '请至少选择 1 个标签',
|
lifeCategoryRequired: '请选择 Category',
|
||||||
|
lifeCategoryInvalid: 'Category 不合法',
|
||||||
commentRequired: '请输入评论内容',
|
commentRequired: '请输入评论内容',
|
||||||
commentTooLong: '评论内容过长',
|
commentTooLong: '评论内容过长',
|
||||||
reactionInvalid: '互动类型不合法',
|
reactionInvalid: '互动类型不合法',
|
||||||
|
|||||||
Reference in New Issue
Block a user