feat(life): add tags to life posts and feed filtering

Allow users to select tags when creating or editing life posts
Add tag tabs to the life feed for filtering posts by tag
This commit is contained in:
2026-05-02 00:16:30 +08:00
parent 866d7add16
commit 433b19eb67
10 changed files with 411 additions and 66 deletions

View File

@@ -59,6 +59,7 @@
- 地图 - 地图
- 栖息地 - 栖息地
- 每日 CheckList Task - 每日 CheckList Task
- Life 标签
- 支持翻译的字段: - 支持翻译的字段:
- `name` - `name`
- `title` - `title`
@@ -166,6 +167,11 @@
- 名称 - 名称
- 用于栖息地中 Pokemon 出现地点。 - 用于栖息地中 Pokemon 出现地点。
### Life 标签
- 名称
- 用于 Life Post 分类展示和 Feed 筛选。
## Pokemon ## Pokemon
Pokemon 可配置: Pokemon 可配置:
@@ -361,6 +367,7 @@ Life 是社区生活分享信息流,类似轻量社交动态。
Life Post 可配置: Life Post 可配置:
- Post 内容正文 - Post 内容正文
- 标签:使用 Life 标签配置,可多选
- 创建者、最后编辑者、创建时间、最后编辑时间 - 创建者、最后编辑者、创建时间、最后编辑时间
- 评论 - 评论
- 评论回复:仅支持回复顶层评论,不做无限嵌套 - 评论回复:仅支持回复顶层评论,不做无限嵌套
@@ -372,12 +379,14 @@ Life Post 可配置:
- 信息流按创建时间倒序展示。 - 信息流按创建时间倒序展示。
- 已注册并完成邮箱验证的用户可以发布 Life Post。 - 已注册并完成邮箱验证的用户可以发布 Life Post。
- 作者本人可以编辑、删除自己的 Life Post。 - 作者本人可以编辑、删除自己的 Life Post。
- 已注册并完成邮箱验证的用户发布或编辑 Life Post 时可以选择一个或多个 Life 标签。
- 已注册并完成邮箱验证的用户可以评论 Life Post并回复顶层评论。 - 已注册并完成邮箱验证的用户可以评论 Life Post并回复顶层评论。
- 评论作者可以删除自己的评论;删除评论后正文不再展示,已有回复保留在原位置。 - 评论作者可以删除自己的评论;删除评论后正文不再展示,已有回复保留在原位置。
- 每条 Life Post 默认只展示评论入口与评论数量;评论列表、回复和评论输入默认折叠,用户点击后展开。 - 每条 Life Post 默认只展示评论入口与评论数量;评论列表、回复和评论输入默认折叠,用户点击后展开。
- 已注册并完成邮箱验证的用户可以对每条 Life Post 选择一个 Reaction普通点击默认设置 `like`,再次点击 `like` 会取消,当前为其他 Reaction 时普通点击会替换为 `like` - 已注册并完成邮箱验证的用户可以对每条 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 顶部展示 Life 标签 Tabs包含 All 和后台配置的 Life 标签;点击标签后按该标签筛选,搜索和标签筛选可以同时生效。
- 信息流分页加载,初始展示最新一页,滚动到底部自动加载更多。 - 信息流分页加载,初始展示最新一页,滚动到底部自动加载更多。
- 当前没有图片上传、转发、置顶或单独审核流程。 - 当前没有图片上传、转发、置顶或单独审核流程。
- Life Post 是用户生成内容,正文按作者输入展示,不进入 `entity_translations` - Life Post 是用户生成内容,正文按作者输入展示,不进入 `entity_translations`
@@ -385,6 +394,7 @@ Life Post 可配置:
API 暴露边界: API 暴露边界:
- Life Post 作者信息只返回 `id``displayName` - Life Post 作者信息只返回 `id``displayName`
- Life Post 标签只返回 `id` 和按当前语言解析后的 `name`
- Life Comment 作者信息只返回 `id``displayName` - Life Comment 作者信息只返回 `id``displayName`
- Life Reaction 对外只返回按类型汇总的数量和当前用户自己的 Reaction不返回其他用户的 Reaction 明细。 - Life Reaction 对外只返回按类型汇总的数量和当前用户自己的 Reaction不返回其他用户的 Reaction 明细。
- Life Post 列表 API 返回分页结果:`items``nextCursor``hasMore``cursor` 是不透明分页令牌。 - Life Post 列表 API 返回分页结果:`items``nextCursor``hasMore``cursor` 是不透明分页令牌。
@@ -429,7 +439,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 正文搜索。 - `GET /api/life-posts`:支持 `cursor` / `limit` 分页读取;支持 `search` 按 Life Post 正文搜索;支持 `tagId` 按 Life 标签筛选
认证 API 认证 API

View File

@@ -38,7 +38,8 @@ CREATE TABLE IF NOT EXISTS entity_translations (
'items', 'items',
'maps', 'maps',
'habitats', 'habitats',
'daily-checklist-items' 'daily-checklist-items',
'life-tags'
) )
), ),
entity_id integer NOT NULL, entity_id integer NOT NULL,
@@ -65,7 +66,8 @@ ALTER TABLE entity_translations ADD CONSTRAINT entity_translations_entity_type_c
'items', 'items',
'maps', 'maps',
'habitats', 'habitats',
'daily-checklist-items' 'daily-checklist-items',
'life-tags'
) )
); );
ALTER TABLE entity_translations DROP CONSTRAINT IF EXISTS entity_translations_field_name_check; ALTER TABLE entity_translations DROP CONSTRAINT IF EXISTS entity_translations_field_name_check;
@@ -119,6 +121,16 @@ CREATE TABLE IF NOT EXISTS daily_checklist_items (
CREATE INDEX IF NOT EXISTS daily_checklist_items_sort_order_idx CREATE INDEX IF NOT EXISTS daily_checklist_items_sort_order_idx
ON daily_checklist_items(sort_order, id); ON daily_checklist_items(sort_order, id);
CREATE TABLE IF NOT EXISTS life_tags (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name text NOT NULL UNIQUE,
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
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),
@@ -134,6 +146,15 @@ ALTER TABLE life_posts DROP COLUMN IF EXISTS link_title;
CREATE INDEX IF NOT EXISTS life_posts_created_at_idx CREATE INDEX IF NOT EXISTS life_posts_created_at_idx
ON life_posts(created_at DESC, id DESC); ON life_posts(created_at DESC, id DESC);
CREATE TABLE IF NOT EXISTS life_post_tags (
post_id integer NOT NULL REFERENCES life_posts(id) ON DELETE CASCADE,
tag_id integer NOT NULL REFERENCES life_tags(id) ON DELETE CASCADE,
PRIMARY KEY (post_id, tag_id)
);
CREATE INDEX IF NOT EXISTS life_post_tags_tag_idx
ON life_post_tags(tag_id, post_id);
CREATE TABLE IF NOT EXISTS life_post_comments ( CREATE TABLE IF NOT EXISTS life_post_comments (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
post_id integer NOT NULL REFERENCES life_posts(id) ON DELETE CASCADE, post_id integer NOT NULL REFERENCES life_posts(id) ON DELETE CASCADE,
@@ -401,6 +422,12 @@ ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS special_attack integer NOT NULL DEF
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS special_defense integer NOT NULL DEFAULT 0 CHECK (special_defense >= 0); ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS special_defense integer NOT NULL DEFAULT 0 CHECK (special_defense >= 0);
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS speed integer NOT NULL DEFAULT 0 CHECK (speed >= 0); ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS speed integer NOT NULL DEFAULT 0 CHECK (speed >= 0);
ALTER TABLE life_tags ADD COLUMN IF NOT EXISTS created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
ALTER TABLE life_tags ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
ALTER TABLE life_tags ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now();
ALTER TABLE life_tags ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
ALTER TABLE life_tags ADD COLUMN IF NOT EXISTS sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0);
ALTER TABLE item_categories ADD COLUMN IF NOT EXISTS created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL; ALTER TABLE item_categories ADD COLUMN IF NOT EXISTS created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
ALTER TABLE item_categories ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL; ALTER TABLE item_categories ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
ALTER TABLE item_categories ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now(); ALTER TABLE item_categories ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now();
@@ -493,6 +520,16 @@ SET sort_order = ordered.next_sort_order
FROM ordered FROM ordered
WHERE target.id = ordered.id; WHERE target.id = ordered.id;
WITH ordered AS (
SELECT id, (row_number() OVER (ORDER BY created_at, id) * 10)::integer AS next_sort_order
FROM life_tags
WHERE sort_order = 0
)
UPDATE life_tags target
SET sort_order = ordered.next_sort_order
FROM ordered
WHERE target.id = ordered.id;
WITH ordered AS ( WITH ordered AS (
SELECT id, (row_number() OVER (ORDER BY created_at, id) * 10)::integer AS next_sort_order SELECT id, (row_number() OVER (ORDER BY created_at, id) * 10)::integer AS next_sort_order
FROM item_categories FROM item_categories
@@ -568,6 +605,7 @@ 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);
CREATE INDEX IF NOT EXISTS pokemon_types_sort_order_idx ON pokemon_types(sort_order, id); CREATE INDEX IF NOT EXISTS pokemon_types_sort_order_idx ON pokemon_types(sort_order, id);
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 INDEX IF NOT EXISTS life_tags_sort_order_idx ON life_tags(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_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);

View File

@@ -23,7 +23,8 @@ type EntityType =
| 'items' | 'items'
| 'maps' | 'maps'
| 'habitats' | 'habitats'
| 'daily-checklist-items'; | 'daily-checklist-items'
| 'life-tags';
type ConfigType = type ConfigType =
| 'pokemon-types' | 'pokemon-types'
@@ -33,7 +34,8 @@ type ConfigType =
| 'item-categories' | 'item-categories'
| 'item-usages' | 'item-usages'
| 'acquisition-methods' | 'acquisition-methods'
| 'maps'; | 'maps'
| 'life-tags';
type ConfigDefinition = { type ConfigDefinition = {
table: string; table: string;
@@ -107,6 +109,7 @@ type DailyChecklistPayload = {
type LifePostPayload = { type LifePostPayload = {
body: string; body: string;
tagIds: number[];
}; };
type LifeCommentPayload = { type LifeCommentPayload = {
@@ -139,6 +142,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 }>;
}; };
type LifePost = Omit<LifePostRow, 'createdAtCursor'> & { type LifePost = Omit<LifePostRow, 'createdAtCursor'> & {
@@ -248,7 +252,8 @@ const configDefinitions: Record<ConfigType, ConfigDefinition> = {
'item-categories': { table: 'item_categories', entityType: 'item-categories' }, 'item-categories': { table: 'item_categories', entityType: 'item-categories' },
'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' }
}; };
const sortableContentDefinitions: Record<SortableContentType, SortableContentDefinition> = { const sortableContentDefinitions: Record<SortableContentType, SortableContentDefinition> = {
@@ -1068,7 +1073,8 @@ export async function getOptions(locale = defaultLocale) {
itemCategories, itemCategories,
itemUsages, itemUsages,
acquisitionMethods, acquisitionMethods,
maps maps,
lifeTags
] = await Promise.all([ ] = await Promise.all([
optionSelect('pokemon_types', 'pokemon-types', locale), optionSelect('pokemon_types', 'pokemon-types', locale),
skillOptions(locale), skillOptions(locale),
@@ -1077,7 +1083,8 @@ export async function getOptions(locale = defaultLocale) {
optionSelect('item_categories', 'item-categories', locale), optionSelect('item_categories', 'item-categories', locale),
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)
]); ]);
return { return {
@@ -1089,7 +1096,8 @@ export async function getOptions(locale = defaultLocale) {
itemUsages, itemUsages,
acquisitionMethods, acquisitionMethods,
itemTags: favoriteThings, itemTags: favoriteThings,
maps maps,
lifeTags
}; };
} }
@@ -1231,7 +1239,10 @@ function cleanLifePostPayload(payload: Record<string, unknown>): LifePostPayload
throw validationError('Post is too long'); throw validationError('Post is too long');
} }
return { body }; return {
body,
tagIds: cleanIds(payload.tagIds)
};
} }
function cleanLifeCommentPayload(payload: Record<string, unknown>): LifeCommentPayload { function cleanLifeCommentPayload(payload: Record<string, unknown>): LifeCommentPayload {
@@ -1264,7 +1275,9 @@ function cleanLifeReactionType(value: unknown): LifeReactionType {
return value; return value;
} }
function lifePostProjection(): string { function lifePostProjection(locale = defaultLocale): string {
const tagName = localizedName('life-tags', 'lt', locale);
return ` return `
SELECT SELECT
lp.id, lp.id,
@@ -1279,7 +1292,13 @@ function lifePostProjection(): string {
CASE CASE
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((
SELECT json_agg(json_build_object('id', lt.id, 'name', ${tagName}) ORDER BY ${orderByEntity('lt')})
FROM life_post_tags lpt
JOIN life_tags lt ON lt.id = lpt.tag_id
WHERE lpt.post_id = lp.id
), '[]'::json) AS tags
FROM life_posts lp FROM life_posts lp
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
@@ -1337,6 +1356,7 @@ function hydrateLifePost(
updatedAt: post.updatedAt, updatedAt: post.updatedAt,
author: post.author, author: post.author,
updatedBy: post.updatedBy, updatedBy: post.updatedBy,
tags: post.tags,
comments: commentsByPost.get(post.id) ?? [], comments: commentsByPost.get(post.id) ?? [],
reactionCounts: countsByPost.get(post.id) ?? emptyLifeReactionCounts(), reactionCounts: countsByPost.get(post.id) ?? emptyLifeReactionCounts(),
myReaction: myReactionsByPost.get(post.id) ?? null myReaction: myReactionsByPost.get(post.id) ?? null
@@ -1479,10 +1499,15 @@ async function getLifeCommentById(id: number): Promise<LifeComment | null> {
return row ? { ...row, replies: [] } : null; return row ? { ...row, replies: [] } : null;
} }
export async function listLifePosts(paramsQuery: QueryParams = {}, userId: number | null = null): Promise<LifePostsPage> { export async function listLifePosts(
paramsQuery: QueryParams = {},
userId: number | null = null,
locale = defaultLocale
): Promise<LifePostsPage> {
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 params: unknown[] = []; const params: unknown[] = [];
const conditions: string[] = []; const conditions: string[] = [];
@@ -1491,6 +1516,17 @@ export async function listLifePosts(paramsQuery: QueryParams = {}, userId: numbe
conditions.push(`lp.body ILIKE $${params.length}`); conditions.push(`lp.body ILIKE $${params.length}`);
} }
if (tagIdValue) {
const tagId = requirePositiveInteger(tagIdValue, 'Tag is invalid');
params.push(tagId);
conditions.push(`EXISTS (
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) {
params.push(cursor.createdAt, cursor.id); params.push(cursor.createdAt, cursor.id);
conditions.push(`(lp.created_at, lp.id) < ($${params.length - 1}::timestamptz, $${params.length}::integer)`); conditions.push(`(lp.created_at, lp.id) < ($${params.length - 1}::timestamptz, $${params.length}::integer)`);
@@ -1500,7 +1536,7 @@ export async function listLifePosts(paramsQuery: QueryParams = {}, userId: numbe
params.push(limit + 1); params.push(limit + 1);
const rows = await query<LifePostRow>( const rows = await query<LifePostRow>(
` `
${lifePostProjection()} ${lifePostProjection(locale)}
${whereClause} ${whereClause}
ORDER BY lp.created_at DESC, lp.id DESC ORDER BY lp.created_at DESC, lp.id DESC
LIMIT $${params.length} LIMIT $${params.length}
@@ -1521,10 +1557,10 @@ export async function listLifePosts(paramsQuery: QueryParams = {}, userId: numbe
}; };
} }
async function getLifePostById(id: number, userId: number | null = null): Promise<LifePost | null> { async function getLifePostById(id: number, userId: number | null = null, locale = defaultLocale): Promise<LifePost | null> {
const post = await queryOne<LifePostRow>( const post = await queryOne<LifePostRow>(
` `
${lifePostProjection()} ${lifePostProjection(locale)}
WHERE lp.id = $1 WHERE lp.id = $1
`, `,
[id] [id]
@@ -1539,10 +1575,25 @@ async function getLifePostById(id: number, userId: number | null = null): Promis
return hydrateLifePost(post, commentsByPost, countsByPost, myReactionsByPost); return hydrateLifePost(post, commentsByPost, countsByPost, myReactionsByPost);
} }
export async function createLifePost(payload: Record<string, unknown>, userId: number) { async function replaceLifePostTags(client: DbClient, postId: number, tagIds: number[]): Promise<void> {
await client.query('DELETE FROM life_post_tags WHERE post_id = $1', [postId]);
for (const tagId of tagIds) {
await client.query(
`
INSERT INTO life_post_tags (post_id, tag_id)
VALUES ($1, $2)
`,
[postId, tagId]
);
}
}
export async function createLifePost(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
const cleanPayload = cleanLifePostPayload(payload); const cleanPayload = cleanLifePostPayload(payload);
const result = await queryOne<{ id: number }>( const id = await withTransaction(async (client) => {
const result = await client.query<{ id: number }>(
` `
INSERT INTO life_posts (body, created_by_user_id, updated_by_user_id) INSERT INTO life_posts (body, created_by_user_id, updated_by_user_id)
VALUES ($1, $2, $2) VALUES ($1, $2, $2)
@@ -1551,13 +1602,19 @@ export async function createLifePost(payload: Record<string, unknown>, userId: n
[cleanPayload.body, userId] [cleanPayload.body, userId]
); );
return getLifePostById(result?.id ?? 0, userId); const createdId = result.rows[0].id;
await replaceLifePostTags(client, createdId, cleanPayload.tagIds);
return createdId;
});
return getLifePostById(id, userId, locale);
} }
export async function updateLifePost(id: number, payload: Record<string, unknown>, userId: number) { export async function updateLifePost(id: number, payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
const cleanPayload = cleanLifePostPayload(payload); const cleanPayload = cleanLifePostPayload(payload);
const result = await queryOne<{ id: number }>( const updatedId = await withTransaction(async (client) => {
const result = await client.query<{ id: number }>(
` `
UPDATE life_posts UPDATE life_posts
SET body = $1, updated_by_user_id = $2, updated_at = now() SET body = $1, updated_by_user_id = $2, updated_at = now()
@@ -1568,7 +1625,16 @@ export async function updateLifePost(id: number, payload: Record<string, unknown
[cleanPayload.body, userId, id] [cleanPayload.body, userId, id]
); );
return result ? getLifePostById(result.id, userId) : null; const resultId = result.rows[0]?.id ?? null;
if (resultId === null) {
return null;
}
await replaceLifePostTags(client, resultId, cleanPayload.tagIds);
return resultId;
});
return updatedId ? getLifePostById(updatedId, userId, locale) : null;
} }
export async function deleteLifePost(id: number, userId: number) { export async function deleteLifePost(id: number, userId: number) {
@@ -1585,7 +1651,12 @@ export async function deleteLifePost(id: number, userId: number) {
return Boolean(result); return Boolean(result);
} }
export async function setLifePostReaction(postId: number, payload: Record<string, unknown>, userId: number) { export async function setLifePostReaction(
postId: number,
payload: Record<string, unknown>,
userId: number,
locale = defaultLocale
) {
const reactionType = cleanLifeReactionType(payload.reactionType); const reactionType = cleanLifeReactionType(payload.reactionType);
const result = await queryOne<{ postId: number }>( const result = await queryOne<{ postId: number }>(
@@ -1600,10 +1671,10 @@ export async function setLifePostReaction(postId: number, payload: Record<string
[postId, userId, reactionType] [postId, userId, reactionType]
); );
return result ? getLifePostById(result.postId, userId) : null; return result ? getLifePostById(result.postId, userId, locale) : null;
} }
export async function deleteLifePostReaction(postId: number, userId: number) { export async function deleteLifePostReaction(postId: number, userId: number, locale = defaultLocale) {
await queryOne<{ postId: number }>( await queryOne<{ postId: number }>(
` `
DELETE FROM life_post_reactions DELETE FROM life_post_reactions
@@ -1614,7 +1685,7 @@ export async function deleteLifePostReaction(postId: number, userId: number) {
[postId, userId] [postId, userId]
); );
return getLifePostById(postId, userId); return getLifePostById(postId, userId, locale);
} }
export async function createLifeComment(postId: number, payload: Record<string, unknown>, userId: number) { export async function createLifeComment(postId: number, payload: Record<string, unknown>, userId: number) {

View File

@@ -195,12 +195,14 @@ app.get('/api/daily-checklist', async (request) => listDailyChecklistItems(reque
app.get('/api/life-posts', async (request) => { app.get('/api/life-posts', async (request) => {
const user = await optionalUser(request); const user = await optionalUser(request);
return listLifePosts(request.query as Record<string, string | string[] | undefined>, user?.id ?? null); return listLifePosts(request.query as Record<string, string | string[] | undefined>, user?.id ?? null, requestLocale(request));
}); });
app.post('/api/life-posts', async (request, reply) => { app.post('/api/life-posts', async (request, reply) => {
const user = await requireVerifiedUser(request, reply); const user = await requireVerifiedUser(request, reply);
return user ? reply.code(201).send(await createLifePost(request.body as Record<string, unknown>, user.id)) : undefined; return user
? reply.code(201).send(await createLifePost(request.body as Record<string, unknown>, user.id, requestLocale(request)))
: undefined;
}); });
app.post('/api/life-posts/:postId/comments', async (request, reply) => { app.post('/api/life-posts/:postId/comments', async (request, reply) => {
@@ -234,7 +236,7 @@ app.put('/api/life-posts/:id', async (request, reply) => {
return; return;
} }
const { id } = request.params as { id: string }; const { id } = request.params as { id: string };
const post = await updateLifePost(Number(id), request.body as Record<string, unknown>, user.id); const post = await updateLifePost(Number(id), request.body as Record<string, unknown>, user.id, requestLocale(request));
return post ? post : reply.code(404).send({ message: 'Not found' }); return post ? post : reply.code(404).send({ message: 'Not found' });
}); });
@@ -244,7 +246,7 @@ app.put('/api/life-posts/:id/reaction', async (request, reply) => {
return; return;
} }
const { id } = request.params as { id: string }; const { id } = request.params as { id: string };
const post = await setLifePostReaction(Number(id), request.body as Record<string, unknown>, user.id); const post = await setLifePostReaction(Number(id), request.body as Record<string, unknown>, user.id, requestLocale(request));
return post ? post : reply.code(404).send({ message: 'Not found' }); return post ? post : reply.code(404).send({ message: 'Not found' });
}); });
@@ -254,7 +256,7 @@ app.delete('/api/life-posts/:id/reaction', async (request, reply) => {
return; return;
} }
const { id } = request.params as { id: string }; const { id } = request.params as { id: string };
const post = await deleteLifePostReaction(Number(id), user.id); const post = await deleteLifePostReaction(Number(id), user.id, requestLocale(request));
return post ? post : reply.code(404).send({ message: 'Not found' }); return post ? post : reply.code(404).send({ message: 'Not found' });
}); });

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from '@iconify/vue'; import { Icon } from '@iconify/vue';
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'; import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch, type CSSProperties } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { iconCheck, iconChevronDown, iconClose } from '../icons'; import { iconCheck, iconChevronDown, iconClose } from '../icons';
@@ -17,6 +17,7 @@ type OptionRow = {
}; };
type CandidateRow = { type: 'option'; id: string; value: string; label: string } | { type: 'create'; id: string }; type CandidateRow = { type: 'option'; id: string; value: string; label: string } | { type: 'create'; id: string };
type DropdownStrategy = 'absolute' | 'fixed';
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
@@ -31,12 +32,14 @@ const props = withDefaults(
allowCreate?: boolean; allowCreate?: boolean;
creating?: boolean; creating?: boolean;
createLabel?: string; createLabel?: string;
dropdownStrategy?: DropdownStrategy;
}>(), }>(),
{ {
multiple: true, multiple: true,
max: 0, max: 0,
allowCreate: false, allowCreate: false,
creating: false creating: false,
dropdownStrategy: 'absolute'
} }
); );
@@ -47,10 +50,14 @@ const emit = defineEmits<{
const { t } = useI18n(); const { t } = useI18n();
const root = ref<HTMLElement | null>(null); const root = ref<HTMLElement | null>(null);
const trigger = ref<HTMLButtonElement | null>(null);
const searchInput = ref<HTMLInputElement | null>(null); const searchInput = ref<HTMLInputElement | null>(null);
const isOpen = ref(false); const isOpen = ref(false);
const search = ref(''); const search = ref('');
const activeIndex = ref(-1); const activeIndex = ref(-1);
const dropdownStyle = ref<CSSProperties>({});
const dropdownPlacement = ref<'top' | 'bottom'>('bottom');
let positionFrame = 0;
const optionRows = computed(() => const optionRows = computed(() =>
props.options.map((option, index) => ({ props.options.map((option, index) => ({
@@ -104,6 +111,7 @@ const candidateRows = computed<CandidateRow[]>(() => {
}); });
const activeCandidate = computed(() => candidateRows.value[activeIndex.value]); const activeCandidate = computed(() => candidateRows.value[activeIndex.value]);
const activeDescendant = computed(() => activeCandidate.value?.id); const activeDescendant = computed(() => activeCandidate.value?.id);
const usesFixedDropdown = computed(() => props.dropdownStrategy === 'fixed');
function setDefaultActiveIndex() { function setDefaultActiveIndex() {
const keyword = createName.value.toLowerCase(); const keyword = createName.value.toLowerCase();
@@ -130,6 +138,8 @@ function clampActiveIndex() {
async function openDropdown() { async function openDropdown() {
isOpen.value = true; isOpen.value = true;
await nextTick(); await nextTick();
updateDropdownPosition();
addPositionListeners();
setDefaultActiveIndex(); setDefaultActiveIndex();
searchInput.value?.focus(); searchInput.value?.focus();
} }
@@ -138,6 +148,8 @@ function closeDropdown() {
isOpen.value = false; isOpen.value = false;
search.value = ''; search.value = '';
activeIndex.value = -1; activeIndex.value = -1;
dropdownStyle.value = {};
removePositionListeners();
} }
function toggleDropdown() { function toggleDropdown() {
@@ -168,11 +180,13 @@ function selectOption(value: string) {
updateValue([...modelValues.value, value]); updateValue([...modelValues.value, value]);
search.value = ''; search.value = '';
setDefaultActiveIndex(); setDefaultActiveIndex();
scheduleDropdownPositionUpdate();
} }
} }
function remove(value: string) { function remove(value: string) {
updateValue(modelValues.value.filter((item) => item !== value)); updateValue(modelValues.value.filter((item) => item !== value));
scheduleDropdownPositionUpdate();
} }
function createOption() { function createOption() {
@@ -225,22 +239,107 @@ function onDocumentPointerDown(event: PointerEvent) {
} }
} }
function scheduleDropdownPositionUpdate() {
if (!usesFixedDropdown.value || !isOpen.value || positionFrame) {
return;
}
positionFrame = window.requestAnimationFrame(() => {
positionFrame = 0;
updateDropdownPosition();
});
}
function updateDropdownPosition() {
if (!usesFixedDropdown.value || !isOpen.value || !trigger.value) {
dropdownStyle.value = {};
return;
}
const viewportPadding = 12;
const dropdownGap = 6;
const dropdownChromeHeight = 72;
const triggerRect = trigger.value.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const width = Math.min(triggerRect.width, viewportWidth - viewportPadding * 2);
const left = Math.min(Math.max(triggerRect.left, viewportPadding), viewportWidth - width - viewportPadding);
const spaceBelow = viewportHeight - triggerRect.bottom - viewportPadding - dropdownGap;
const spaceAbove = triggerRect.top - viewportPadding - dropdownGap;
const placeAbove = spaceBelow < 220 && spaceAbove > spaceBelow;
const availableSpace = Math.max(144, placeAbove ? spaceAbove : spaceBelow);
const optionsMaxHeight = Math.max(96, Math.min(240, availableSpace - dropdownChromeHeight));
const nextStyle = {
left: `${left}px`,
width: `${width}px`,
'--tags-select-options-max-height': `${optionsMaxHeight}px`
} as CSSProperties;
if (placeAbove) {
dropdownPlacement.value = 'top';
dropdownStyle.value = {
...nextStyle,
bottom: `${viewportHeight - triggerRect.top + dropdownGap}px`
};
return;
}
dropdownPlacement.value = 'bottom';
dropdownStyle.value = {
...nextStyle,
top: `${triggerRect.bottom + dropdownGap}px`
};
}
function addPositionListeners() {
if (!usesFixedDropdown.value) {
return;
}
window.addEventListener('resize', scheduleDropdownPositionUpdate);
window.addEventListener('scroll', scheduleDropdownPositionUpdate, true);
}
function removePositionListeners() {
window.removeEventListener('resize', scheduleDropdownPositionUpdate);
window.removeEventListener('scroll', scheduleDropdownPositionUpdate, true);
if (positionFrame) {
window.cancelAnimationFrame(positionFrame);
positionFrame = 0;
}
}
onMounted(() => { onMounted(() => {
document.addEventListener('pointerdown', onDocumentPointerDown); document.addEventListener('pointerdown', onDocumentPointerDown);
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
document.removeEventListener('pointerdown', onDocumentPointerDown); document.removeEventListener('pointerdown', onDocumentPointerDown);
removePositionListeners();
}); });
watch(search, setDefaultActiveIndex); watch(search, setDefaultActiveIndex);
watch(candidateRows, clampActiveIndex); watch(candidateRows, clampActiveIndex);
watch(
() => props.dropdownStrategy,
() => {
if (!isOpen.value) return;
removePositionListeners();
void nextTick(() => {
updateDropdownPosition();
addPositionListeners();
});
}
);
</script> </script>
<template> <template>
<div ref="root" class="tags-select" :class="{ 'tags-select--single': !multiple }" @keydown="onRootKeydown"> <div ref="root" class="tags-select" :class="{ 'tags-select--single': !multiple }" @keydown="onRootKeydown">
<button <button
:id="id" :id="id"
ref="trigger"
type="button" type="button"
class="tags-select__trigger" class="tags-select__trigger"
:class="{ open: isOpen }" :class="{ open: isOpen }"
@@ -271,7 +370,15 @@ watch(candidateRows, clampActiveIndex);
<Icon :icon="iconChevronDown" class="tags-select__arrow" aria-hidden="true" /> <Icon :icon="iconChevronDown" class="tags-select__arrow" aria-hidden="true" />
</button> </button>
<div v-if="isOpen" class="tags-select__dropdown"> <div
v-if="isOpen"
class="tags-select__dropdown"
:class="{
'tags-select__dropdown--fixed': usesFixedDropdown,
'tags-select__dropdown--top': usesFixedDropdown && dropdownPlacement === 'top'
}"
:style="dropdownStyle"
>
<input <input
ref="searchInput" ref="searchInput"
v-model="search" v-model="search"

View File

@@ -230,6 +230,10 @@ const messages = {
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',
allTags: 'All',
tagPlaceholder: 'Select tags',
searchTags: 'Search tags',
search: 'Search Life', search: 'Search Life',
searchPlaceholder: 'Search post content...', searchPlaceholder: 'Search post content...',
searchEmpty: 'No posts match your search', searchEmpty: 'No posts match your search',
@@ -319,7 +323,8 @@ const messages = {
itemCategories: 'Item categories', itemCategories: 'Item categories',
itemUsages: 'Item usages', itemUsages: 'Item usages',
acquisitionMethods: 'Acquisition methods', acquisitionMethods: 'Acquisition methods',
maps: 'Maps' maps: 'Maps',
lifeTags: 'Life tags'
}, },
appearance: { appearance: {
time: 'Time', time: 'Time',
@@ -577,6 +582,10 @@ const messages = {
bodyLabel: '动态内容', bodyLabel: '动态内容',
bodyPlaceholder: '分享一段想法、心得或发现……', bodyPlaceholder: '分享一段想法、心得或发现……',
newPost: 'New Post', newPost: 'New Post',
tags: '标签',
allTags: '全部',
tagPlaceholder: '选择标签',
searchTags: '搜索标签',
search: '搜索动态', search: '搜索动态',
searchPlaceholder: '搜索动态内容……', searchPlaceholder: '搜索动态内容……',
searchEmpty: '没有匹配的动态', searchEmpty: '没有匹配的动态',
@@ -666,7 +675,8 @@ const messages = {
itemCategories: '物品分类', itemCategories: '物品分类',
itemUsages: '物品用途', itemUsages: '物品用途',
acquisitionMethods: '入手方式', acquisitionMethods: '入手方式',
maps: '地图' maps: '地图',
lifeTags: 'Life 标签'
}, },
appearance: { appearance: {
time: '时段', time: '时段',

View File

@@ -183,6 +183,7 @@ export interface LifePost {
updatedAt: string; updatedAt: string;
author: UserSummary | null; author: UserSummary | null;
updatedBy: UserSummary | null; updatedBy: UserSummary | null;
tags: NamedEntity[];
comments: LifeComment[]; comments: LifeComment[];
reactionCounts: LifeReactionCounts; reactionCounts: LifeReactionCounts;
myReaction: LifeReactionType | null; myReaction: LifeReactionType | null;
@@ -198,6 +199,7 @@ export interface LifePostsParams {
cursor?: string | null; cursor?: string | null;
limit?: number; limit?: number;
search?: string; search?: string;
tagId?: string | number;
} }
export interface LifeComment { export interface LifeComment {
@@ -228,6 +230,7 @@ export interface Options {
acquisitionMethods: NamedEntity[]; acquisitionMethods: NamedEntity[];
itemTags: NamedEntity[]; itemTags: NamedEntity[];
maps: NamedEntity[]; maps: NamedEntity[];
lifeTags: NamedEntity[];
} }
export interface AuthUser { export interface AuthUser {
@@ -259,7 +262,8 @@ export type ConfigType =
| 'item-categories' | 'item-categories'
| 'item-usages' | 'item-usages'
| 'acquisition-methods' | 'acquisition-methods'
| 'maps'; | 'maps'
| 'life-tags';
export interface PokemonPayload { export interface PokemonPayload {
id: number; id: number;
@@ -316,6 +320,7 @@ export interface DailyChecklistPayload {
export interface LifePostPayload { export interface LifePostPayload {
body: string; body: string;
tagIds?: number[];
} }
export interface LifeCommentPayload { export interface LifeCommentPayload {
@@ -466,7 +471,12 @@ export const api = {
dailyChecklist: () => getJson<DailyChecklistItem[]>('/api/daily-checklist'), dailyChecklist: () => getJson<DailyChecklistItem[]>('/api/daily-checklist'),
lifePosts: (params: LifePostsParams = {}) => lifePosts: (params: LifePostsParams = {}) =>
getJson<LifePostsPage>( getJson<LifePostsPage>(
`/api/life-posts${buildQuery({ cursor: params.cursor ?? undefined, limit: params.limit, search: params.search?.trim() })}` `/api/life-posts${buildQuery({
cursor: params.cursor ?? undefined,
limit: params.limit,
search: params.search?.trim(),
tagId: params.tagId
})}`
), ),
createLifePost: (payload: LifePostPayload) => sendJson<LifePost>('/api/life-posts', 'POST', payload), createLifePost: (payload: LifePostPayload) => sendJson<LifePost>('/api/life-posts', 'POST', payload),
updateLifePost: (id: string | number, payload: LifePostPayload) => updateLifePost: (id: string | number, payload: LifePostPayload) =>

View File

@@ -798,9 +798,16 @@ button:disabled,
box-shadow: var(--shadow-raised); box-shadow: var(--shadow-raised);
} }
.tags-select__dropdown--fixed {
position: fixed;
top: auto;
left: auto;
z-index: 80;
}
.tags-select__options { .tags-select__options {
display: grid; display: grid;
max-height: 240px; max-height: var(--tags-select-options-max-height, 240px);
overflow: auto; overflow: auto;
} }
@@ -1200,6 +1207,10 @@ button:disabled,
min-height: 44px; min-height: 44px;
} }
.life-tag-tabs {
max-width: 100%;
}
.life-composer, .life-composer,
.life-post { .life-post {
display: grid; display: grid;
@@ -1341,6 +1352,30 @@ button:disabled,
white-space: pre-wrap; white-space: pre-wrap;
} }
.life-post__tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.life-post__tag {
min-height: 30px;
display: inline-flex;
align-items: center;
padding: 4px 9px;
border: 1px solid color-mix(in srgb, var(--pokemon-blue) 38%, var(--line));
border-radius: var(--radius-small);
background: color-mix(in srgb, var(--pokemon-blue) 9%, var(--surface));
color: var(--pokemon-blue-deep);
font-size: 13px;
font-weight: 850;
line-height: 1.2;
}
[data-theme="night"] .life-post__tag {
color: var(--pokemon-yellow);
}
.life-post__engagement { .life-post__engagement {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;

View File

@@ -73,7 +73,8 @@ const configTypes = computed<Array<{ key: ConfigType; label: string; supportsIte
{ key: 'item-categories', label: t('config.itemCategories') }, { key: 'item-categories', label: t('config.itemCategories') },
{ 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') }
]); ]);
const activeTab = ref<AdminTab>('config'); const activeTab = ref<AdminTab>('config');

View File

@@ -7,6 +7,8 @@ import Modal from '../components/Modal.vue';
import PageHeader from '../components/PageHeader.vue'; import PageHeader from '../components/PageHeader.vue';
import Skeleton from '../components/Skeleton.vue'; import Skeleton from '../components/Skeleton.vue';
import StatusMessage from '../components/StatusMessage.vue'; import StatusMessage from '../components/StatusMessage.vue';
import TagsSelect from '../components/TagsSelect.vue';
import Tabs, { type TabOption } from '../components/Tabs.vue';
import { import {
iconAdd, iconAdd,
iconCancel, iconCancel,
@@ -30,11 +32,13 @@ import {
type AuthUser, type AuthUser,
type LifeComment, type LifeComment,
type LifePost, type LifePost,
type LifeReactionType type LifeReactionType,
type NamedEntity
} from '../services/api'; } from '../services/api';
const { locale, t } = useI18n(); const { locale, t } = useI18n();
const posts = ref<LifePost[]>([]); const posts = ref<LifePost[]>([]);
const lifeTags = ref<NamedEntity[]>([]);
const currentUser = ref<AuthUser | null>(null); const currentUser = ref<AuthUser | null>(null);
const loading = ref(true); const loading = ref(true);
const loadingMore = ref(false); const loadingMore = ref(false);
@@ -42,7 +46,9 @@ 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 body = ref(''); const body = ref('');
const selectedTagIds = ref<string[]>([]);
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('');
@@ -67,6 +73,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 reactionOptions = [ const reactionOptions = [
{ type: 'like', icon: iconReactionLike, labelKey: 'pages.life.reactionLike' }, { type: 'like', icon: iconReactionLike, labelKey: 'pages.life.reactionLike' },
@@ -79,6 +86,14 @@ const canPost = computed(() => currentUser.value?.emailVerified === true);
const charactersLeft = computed(() => Math.max(0, 2000 - body.value.length)); const charactersLeft = computed(() => Math.max(0, 2000 - 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 tagId = Number(activeTagId.value);
return activeTagId.value === allTagValue || !Number.isInteger(tagId) || tagId <= 0 ? undefined : tagId;
});
const tagTabs = computed<TabOption[]>(() => [
{ value: allTagValue, label: t('pages.life.allTags') },
...lifeTags.value.map((tag) => ({ value: String(tag.id), label: tag.name }))
]);
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');
@@ -105,6 +120,19 @@ async function loadCurrentUser() {
} }
} }
async function loadLifeTags() {
try {
const options = await api.options();
lifeTags.value = options.lifeTags;
if (activeTagId.value !== allTagValue && !lifeTags.value.some((tag) => String(tag.id) === activeTagId.value)) {
activeTagId.value = allTagValue;
}
} catch (error) {
loadError.value = error instanceof Error && error.message ? error.message : t('errors.loadFailed');
}
}
async function loadPosts() { async function loadPosts() {
const requestId = ++postsRequestId; const requestId = ++postsRequestId;
loading.value = true; loading.value = true;
@@ -115,7 +143,7 @@ async function loadPosts() {
loadMorePaused.value = false; loadMorePaused.value = false;
try { try {
const page = await api.lifePosts({ limit: lifePostPageSize, search: searchQuery.value }); const page = await api.lifePosts({ limit: lifePostPageSize, search: searchQuery.value, tagId: selectedFeedTagId.value });
if (requestId !== postsRequestId) { if (requestId !== postsRequestId) {
return; return;
} }
@@ -149,7 +177,7 @@ async function loadMorePosts() {
loadError.value = ''; loadError.value = '';
try { try {
const page = await api.lifePosts({ cursor, limit: lifePostPageSize, search: searchQuery.value }); const page = await api.lifePosts({ cursor, limit: lifePostPageSize, search: searchQuery.value, tagId: selectedFeedTagId.value });
if (requestId !== postsRequestId) { if (requestId !== postsRequestId) {
return; return;
} }
@@ -172,13 +200,15 @@ async function loadMorePosts() {
function resetForm() { function resetForm() {
body.value = ''; body.value = '';
selectedTagIds.value = [];
editingPostId.value = null; editingPostId.value = null;
formError.value = ''; formError.value = '';
} }
function payload() { function payload() {
return { return {
body: body.value.trim() body: body.value.trim(),
tagIds: selectedTagIds.value.map((tagId) => Number(tagId)).filter((tagId) => Number.isInteger(tagId) && tagId > 0)
}; };
} }
@@ -192,9 +222,12 @@ function submitSearch() {
void loadPosts(); void loadPosts();
} }
function matchesCurrentSearch(post: LifePost) { function matchesCurrentFilters(post: LifePost) {
const keyword = searchQuery.value.toLowerCase(); const keyword = searchQuery.value.toLowerCase();
return keyword === '' || post.body.toLowerCase().includes(keyword); const tagId = selectedFeedTagId.value;
const matchesSearch = keyword === '' || post.body.toLowerCase().includes(keyword);
const matchesTag = tagId === undefined || post.tags.some((tag) => tag.id === tagId);
return matchesSearch && matchesTag;
} }
function openCreatePostModal() { function openCreatePostModal() {
@@ -228,7 +261,7 @@ async function submitPost() {
replacePost(updated); replacePost(updated);
} else { } else {
const created = await api.createLifePost(payload()); const created = await api.createLifePost(payload());
if (matchesCurrentSearch(created)) { if (matchesCurrentFilters(created)) {
posts.value = [created, ...posts.value]; posts.value = [created, ...posts.value];
} }
} }
@@ -294,7 +327,7 @@ function reactionCountLabel(post: LifePost, type: LifeReactionType) {
} }
function replacePost(updatedPost: LifePost) { function replacePost(updatedPost: LifePost) {
if (!matchesCurrentSearch(updatedPost)) { if (!matchesCurrentFilters(updatedPost)) {
posts.value = posts.value.filter((post) => post.id !== updatedPost.id); posts.value = posts.value.filter((post) => post.id !== updatedPost.id);
return; return;
} }
@@ -410,6 +443,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));
formError.value = ''; formError.value = '';
postModalOpen.value = true; postModalOpen.value = true;
void nextTick(() => bodyInput.value?.focus()); void nextTick(() => bodyInput.value?.focus());
@@ -573,9 +607,17 @@ function observeLoadMore() {
} }
watch([loadMoreSentinel, hasMorePosts, loading, loadingMore, loadMorePaused], observeLoadMore, { flush: 'post' }); watch([loadMoreSentinel, hasMorePosts, loading, loadingMore, loadMorePaused], observeLoadMore, { flush: 'post' });
watch(activeTagId, () => {
void loadPosts();
});
watch(locale, () => {
void loadLifeTags();
void loadPosts();
});
onMounted(() => { onMounted(() => {
void loadCurrentUser(); void loadCurrentUser();
void loadLifeTags();
void loadPosts(); void loadPosts();
removeAuthListener = onAuthTokenChange(() => { removeAuthListener = onAuthTokenChange(() => {
void loadCurrentUser(); void loadCurrentUser();
@@ -615,6 +657,8 @@ onUnmounted(() => {
</div> </div>
</FilterPanel> </FilterPanel>
<Tabs id="life-tag-filter" v-model="activeTagId" class="life-tag-tabs" :tabs="tagTabs" :label="t('pages.life.tags')" />
<StatusMessage v-if="loadError" variant="danger" :duration="0">{{ loadError }}</StatusMessage> <StatusMessage v-if="loadError" variant="danger" :duration="0">{{ loadError }}</StatusMessage>
<Modal <Modal
@@ -622,6 +666,7 @@ onUnmounted(() => {
:title="postModalTitle" :title="postModalTitle"
:subtitle="t('pages.life.composerPrompt')" :subtitle="t('pages.life.composerPrompt')"
:close-label="t('common.close')" :close-label="t('common.close')"
size="wide"
@close="closePostModal" @close="closePostModal"
> >
<div v-if="!authReady" class="life-composer__auth-skeleton" aria-hidden="true"> <div v-if="!authReady" class="life-composer__auth-skeleton" aria-hidden="true">
@@ -643,6 +688,18 @@ onUnmounted(() => {
<span class="life-form__counter">{{ t('pages.life.charactersLeft', { count: charactersLeft }) }}</span> <span class="life-form__counter">{{ t('pages.life.charactersLeft', { count: charactersLeft }) }}</span>
</div> </div>
<div class="field">
<label for="life-post-tags">{{ t('pages.life.tags') }}</label>
<TagsSelect
id="life-post-tags"
v-model="selectedTagIds"
:options="lifeTags"
:placeholder="t('pages.life.tagPlaceholder')"
:search-placeholder="t('pages.life.searchTags')"
dropdown-strategy="fixed"
/>
</div>
<p v-if="formError" class="life-form__error" role="alert">{{ formError }}</p> <p v-if="formError" class="life-form__error" role="alert">{{ formError }}</p>
<div class="life-form__actions"> <div class="life-form__actions">
@@ -707,6 +764,10 @@ 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')">
<span v-for="tag in post.tags" :key="tag.id" class="life-post__tag">{{ tag.name }}</span>
</div>
<div class="life-post__engagement"> <div class="life-post__engagement">
<div class="life-post__engagement-actions"> <div class="life-post__engagement-actions">
<div class="life-reactions"> <div class="life-reactions">