feat(life): add reactions to life posts
Support 'like', 'helpful', 'fun', and 'thanks' reactions. Add API endpoints and database schema for post reactions. Update UI with reaction picker and summary counts.
This commit is contained in:
@@ -364,6 +364,7 @@ Life Post 可配置:
|
|||||||
- 创建者、最后编辑者、创建时间、最后编辑时间
|
- 创建者、最后编辑者、创建时间、最后编辑时间
|
||||||
- 评论
|
- 评论
|
||||||
- 评论回复:仅支持回复顶层评论,不做无限嵌套
|
- 评论回复:仅支持回复顶层评论,不做无限嵌套
|
||||||
|
- Reactions:`like`、`helpful`、`fun`、`thanks`
|
||||||
|
|
||||||
前台行为:
|
前台行为:
|
||||||
|
|
||||||
@@ -374,13 +375,16 @@ Life Post 可配置:
|
|||||||
- 已注册并完成邮箱验证的用户可以评论 Life Post,并回复顶层评论。
|
- 已注册并完成邮箱验证的用户可以评论 Life Post,并回复顶层评论。
|
||||||
- 评论作者可以删除自己的评论;删除评论后正文不再展示,已有回复保留在原位置。
|
- 评论作者可以删除自己的评论;删除评论后正文不再展示,已有回复保留在原位置。
|
||||||
- 每条 Life Post 默认只展示评论入口与评论数量;评论列表、回复和评论输入默认折叠,用户点击后展开。
|
- 每条 Life Post 默认只展示评论入口与评论数量;评论列表、回复和评论输入默认折叠,用户点击后展开。
|
||||||
- 当前没有点赞、图片上传、转发、分页、置顶或单独审核流程。
|
- 已注册并完成邮箱验证的用户可以对每条 Life Post 选择一个 Reaction;普通点击默认设置 `like`,再次点击 `like` 会取消,当前为其他 Reaction 时普通点击会替换为 `like`。
|
||||||
|
- Life Reaction 的其他类型通过右键 / context menu 打开 Popup 选择;再次选择当前 Reaction 会取消,选择其他 Reaction 会替换原 Reaction。
|
||||||
|
- 当前没有图片上传、转发、分页、置顶或单独审核流程。
|
||||||
- Life Post 是用户生成内容,正文按作者输入展示,不进入 `entity_translations`。
|
- Life Post 是用户生成内容,正文按作者输入展示,不进入 `entity_translations`。
|
||||||
|
|
||||||
API 暴露边界:
|
API 暴露边界:
|
||||||
|
|
||||||
- Life Post 作者信息只返回 `id` 和 `displayName`。
|
- Life Post 作者信息只返回 `id` 和 `displayName`。
|
||||||
- Life Comment 作者信息只返回 `id` 和 `displayName`。
|
- Life Comment 作者信息只返回 `id` 和 `displayName`。
|
||||||
|
- Life Reaction 对外只返回按类型汇总的数量和当前用户自己的 Reaction,不返回其他用户的 Reaction 明细。
|
||||||
- API 不返回邮箱、token/hash、内部调试字段或不必要的审计 payload。
|
- API 不返回邮箱、token/hash、内部调试字段或不必要的审计 payload。
|
||||||
- 非作者不能编辑或删除其他用户的 Life Post。
|
- 非作者不能编辑或删除其他用户的 Life Post。
|
||||||
- 非作者不能删除其他用户的 Life Comment。
|
- 非作者不能删除其他用户的 Life Comment。
|
||||||
@@ -443,6 +447,9 @@ API 暴露边界:
|
|||||||
- `POST /api/life-posts/:postId/comments`
|
- `POST /api/life-posts/:postId/comments`
|
||||||
- `POST /api/life-posts/:postId/comments/:commentId/replies`
|
- `POST /api/life-posts/:postId/comments/:commentId/replies`
|
||||||
- `DELETE /api/life-comments/:id`
|
- `DELETE /api/life-comments/:id`
|
||||||
|
- Life Reaction 的设置、替换和取消。
|
||||||
|
- `PUT /api/life-posts/:id/reaction`
|
||||||
|
- `DELETE /api/life-posts/:id/reaction`
|
||||||
- 每日 CheckList 的创建、更新、删除、排序。
|
- 每日 CheckList 的创建、更新、删除、排序。
|
||||||
- 全局配置项的创建、更新、删除、排序。
|
- 全局配置项的创建、更新、删除、排序。
|
||||||
- 语言的创建、更新、删除、排序。
|
- 语言的创建、更新、删除、排序。
|
||||||
|
|||||||
@@ -152,6 +152,18 @@ CREATE INDEX IF NOT EXISTS life_post_comments_post_idx
|
|||||||
CREATE INDEX IF NOT EXISTS life_post_comments_parent_idx
|
CREATE INDEX IF NOT EXISTS life_post_comments_parent_idx
|
||||||
ON life_post_comments(parent_comment_id, created_at, id);
|
ON life_post_comments(parent_comment_id, created_at, id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS life_post_reactions (
|
||||||
|
post_id integer NOT NULL REFERENCES life_posts(id) ON DELETE CASCADE,
|
||||||
|
user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
reaction_type text NOT NULL CHECK (reaction_type IN ('like', 'helpful', 'fun', 'thanks')),
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (post_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS life_post_reactions_post_idx
|
||||||
|
ON life_post_reactions(post_id, reaction_type);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS skills (
|
CREATE TABLE IF NOT EXISTS skills (
|
||||||
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,
|
||||||
|
|||||||
@@ -112,6 +112,9 @@ type LifeCommentPayload = {
|
|||||||
body: string;
|
body: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type LifeReactionType = 'like' | 'helpful' | 'fun' | 'thanks';
|
||||||
|
type LifeReactionCounts = Record<LifeReactionType, number>;
|
||||||
|
|
||||||
type LifeCommentRow = {
|
type LifeCommentRow = {
|
||||||
id: number;
|
id: number;
|
||||||
postId: number;
|
postId: number;
|
||||||
@@ -138,6 +141,8 @@ type LifePostRow = {
|
|||||||
|
|
||||||
type LifePost = LifePostRow & {
|
type LifePost = LifePostRow & {
|
||||||
comments: LifeComment[];
|
comments: LifeComment[];
|
||||||
|
reactionCounts: LifeReactionCounts;
|
||||||
|
myReaction: LifeReactionType | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type HabitatPayload = {
|
type HabitatPayload = {
|
||||||
@@ -210,6 +215,7 @@ const timeOfDays = ['早晨', '中午', '傍晚', '晚上'];
|
|||||||
const weathers = ['晴天', '阴天', '雨天'];
|
const weathers = ['晴天', '阴天', '雨天'];
|
||||||
const defaultLocale = 'en';
|
const defaultLocale = 'en';
|
||||||
const localePattern = /^[a-z]{2}(-[A-Z]{2})?$/;
|
const localePattern = /^[a-z]{2}(-[A-Z]{2})?$/;
|
||||||
|
const lifeReactionTypes = ['like', 'helpful', 'fun', 'thanks'] as const;
|
||||||
const pokemonStatLabels: Array<{ key: keyof PokemonStats; label: string }> = [
|
const pokemonStatLabels: Array<{ key: keyof PokemonStats; label: string }> = [
|
||||||
{ key: 'hp', label: 'HP' },
|
{ key: 'hp', label: 'HP' },
|
||||||
{ key: 'attack', label: 'Attack' },
|
{ key: 'attack', label: 'Attack' },
|
||||||
@@ -1222,6 +1228,27 @@ function cleanLifeCommentPayload(payload: Record<string, unknown>): LifeCommentP
|
|||||||
return { body };
|
return { body };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function emptyLifeReactionCounts(): LifeReactionCounts {
|
||||||
|
return {
|
||||||
|
like: 0,
|
||||||
|
helpful: 0,
|
||||||
|
fun: 0,
|
||||||
|
thanks: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLifeReactionType(value: unknown): value is LifeReactionType {
|
||||||
|
return typeof value === 'string' && lifeReactionTypes.includes(value as LifeReactionType);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanLifeReactionType(value: unknown): LifeReactionType {
|
||||||
|
if (!isLifeReactionType(value)) {
|
||||||
|
throw validationError('Reaction is invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
function lifePostProjection(): string {
|
function lifePostProjection(): string {
|
||||||
return `
|
return `
|
||||||
SELECT
|
SELECT
|
||||||
@@ -1309,6 +1336,65 @@ async function lifeCommentsForPosts(postIds: number[]): Promise<Map<number, Life
|
|||||||
return commentsByPost;
|
return commentsByPost;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function lifeReactionsForPosts(
|
||||||
|
postIds: number[],
|
||||||
|
userId: number | null
|
||||||
|
): Promise<{
|
||||||
|
countsByPost: Map<number, LifeReactionCounts>;
|
||||||
|
myReactionsByPost: Map<number, LifeReactionType>;
|
||||||
|
}> {
|
||||||
|
const countsByPost = new Map<number, LifeReactionCounts>();
|
||||||
|
const myReactionsByPost = new Map<number, LifeReactionType>();
|
||||||
|
|
||||||
|
for (const postId of postIds) {
|
||||||
|
countsByPost.set(postId, emptyLifeReactionCounts());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (postIds.length === 0) {
|
||||||
|
return { countsByPost, myReactionsByPost };
|
||||||
|
}
|
||||||
|
|
||||||
|
const countRows = await query<{ postId: number; reactionType: LifeReactionType; count: number }>(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
post_id AS "postId",
|
||||||
|
reaction_type AS "reactionType",
|
||||||
|
COUNT(*)::integer AS count
|
||||||
|
FROM life_post_reactions
|
||||||
|
WHERE post_id = ANY($1::integer[])
|
||||||
|
GROUP BY post_id, reaction_type
|
||||||
|
`,
|
||||||
|
[postIds]
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const row of countRows) {
|
||||||
|
const counts = countsByPost.get(row.postId);
|
||||||
|
if (counts && isLifeReactionType(row.reactionType)) {
|
||||||
|
counts[row.reactionType] = row.count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userId !== null) {
|
||||||
|
const myRows = await query<{ postId: number; reactionType: LifeReactionType }>(
|
||||||
|
`
|
||||||
|
SELECT post_id AS "postId", reaction_type AS "reactionType"
|
||||||
|
FROM life_post_reactions
|
||||||
|
WHERE post_id = ANY($1::integer[])
|
||||||
|
AND user_id = $2
|
||||||
|
`,
|
||||||
|
[postIds, userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const row of myRows) {
|
||||||
|
if (isLifeReactionType(row.reactionType)) {
|
||||||
|
myReactionsByPost.set(row.postId, row.reactionType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { countsByPost, myReactionsByPost };
|
||||||
|
}
|
||||||
|
|
||||||
async function getLifeCommentById(id: number): Promise<LifeComment | null> {
|
async function getLifeCommentById(id: number): Promise<LifeComment | null> {
|
||||||
const row = await queryOne<LifeCommentRow>(
|
const row = await queryOne<LifeCommentRow>(
|
||||||
`
|
`
|
||||||
@@ -1320,21 +1406,25 @@ async function getLifeCommentById(id: number): Promise<LifeComment | null> {
|
|||||||
return row ? { ...row, replies: [] } : null;
|
return row ? { ...row, replies: [] } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listLifePosts(): Promise<LifePost[]> {
|
export async function listLifePosts(userId: number | null = null): Promise<LifePost[]> {
|
||||||
const posts = await query<LifePostRow>(`
|
const posts = await query<LifePostRow>(`
|
||||||
${lifePostProjection()}
|
${lifePostProjection()}
|
||||||
ORDER BY lp.created_at DESC, lp.id DESC
|
ORDER BY lp.created_at DESC, lp.id DESC
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const commentsByPost = await lifeCommentsForPosts(posts.map((post) => post.id));
|
const postIds = posts.map((post) => post.id);
|
||||||
|
const commentsByPost = await lifeCommentsForPosts(postIds);
|
||||||
|
const { countsByPost, myReactionsByPost } = await lifeReactionsForPosts(postIds, userId);
|
||||||
|
|
||||||
return posts.map((post) => ({
|
return posts.map((post) => ({
|
||||||
...post,
|
...post,
|
||||||
comments: commentsByPost.get(post.id) ?? []
|
comments: commentsByPost.get(post.id) ?? [],
|
||||||
|
reactionCounts: countsByPost.get(post.id) ?? emptyLifeReactionCounts(),
|
||||||
|
myReaction: myReactionsByPost.get(post.id) ?? null
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getLifePostById(id: number): Promise<LifePost | null> {
|
async function getLifePostById(id: number, userId: number | null = null): Promise<LifePost | null> {
|
||||||
const post = await queryOne<LifePostRow>(
|
const post = await queryOne<LifePostRow>(
|
||||||
`
|
`
|
||||||
${lifePostProjection()}
|
${lifePostProjection()}
|
||||||
@@ -1348,9 +1438,12 @@ async function getLifePostById(id: number): Promise<LifePost | null> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const commentsByPost = await lifeCommentsForPosts([post.id]);
|
const commentsByPost = await lifeCommentsForPosts([post.id]);
|
||||||
|
const { countsByPost, myReactionsByPost } = await lifeReactionsForPosts([post.id], userId);
|
||||||
return {
|
return {
|
||||||
...post,
|
...post,
|
||||||
comments: commentsByPost.get(post.id) ?? []
|
comments: commentsByPost.get(post.id) ?? [],
|
||||||
|
reactionCounts: countsByPost.get(post.id) ?? emptyLifeReactionCounts(),
|
||||||
|
myReaction: myReactionsByPost.get(post.id) ?? null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1366,7 +1459,7 @@ export async function createLifePost(payload: Record<string, unknown>, userId: n
|
|||||||
[cleanPayload.body, userId]
|
[cleanPayload.body, userId]
|
||||||
);
|
);
|
||||||
|
|
||||||
return getLifePostById(result?.id ?? 0);
|
return getLifePostById(result?.id ?? 0, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateLifePost(id: number, payload: Record<string, unknown>, userId: number) {
|
export async function updateLifePost(id: number, payload: Record<string, unknown>, userId: number) {
|
||||||
@@ -1383,7 +1476,7 @@ export async function updateLifePost(id: number, payload: Record<string, unknown
|
|||||||
[cleanPayload.body, userId, id]
|
[cleanPayload.body, userId, id]
|
||||||
);
|
);
|
||||||
|
|
||||||
return result ? getLifePostById(result.id) : null;
|
return result ? getLifePostById(result.id, userId) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteLifePost(id: number, userId: number) {
|
export async function deleteLifePost(id: number, userId: number) {
|
||||||
@@ -1400,6 +1493,38 @@ 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) {
|
||||||
|
const reactionType = cleanLifeReactionType(payload.reactionType);
|
||||||
|
|
||||||
|
const result = await queryOne<{ postId: number }>(
|
||||||
|
`
|
||||||
|
INSERT INTO life_post_reactions (post_id, user_id, reaction_type)
|
||||||
|
SELECT $1, $2, $3
|
||||||
|
WHERE EXISTS (SELECT 1 FROM life_posts WHERE id = $1)
|
||||||
|
ON CONFLICT (post_id, user_id)
|
||||||
|
DO UPDATE SET reaction_type = EXCLUDED.reaction_type, updated_at = now()
|
||||||
|
RETURNING post_id AS "postId"
|
||||||
|
`,
|
||||||
|
[postId, userId, reactionType]
|
||||||
|
);
|
||||||
|
|
||||||
|
return result ? getLifePostById(result.postId, userId) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteLifePostReaction(postId: number, userId: number) {
|
||||||
|
await queryOne<{ postId: number }>(
|
||||||
|
`
|
||||||
|
DELETE FROM life_post_reactions
|
||||||
|
WHERE post_id = $1
|
||||||
|
AND user_id = $2
|
||||||
|
RETURNING post_id AS "postId"
|
||||||
|
`,
|
||||||
|
[postId, userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return getLifePostById(postId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
export async function createLifeComment(postId: number, payload: Record<string, unknown>, userId: number) {
|
export async function createLifeComment(postId: number, payload: Record<string, unknown>, userId: number) {
|
||||||
const cleanPayload = cleanLifeCommentPayload(payload);
|
const cleanPayload = cleanLifeCommentPayload(payload);
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
deleteLanguage,
|
deleteLanguage,
|
||||||
deleteLifeComment,
|
deleteLifeComment,
|
||||||
deleteLifePost,
|
deleteLifePost,
|
||||||
|
deleteLifePostReaction,
|
||||||
deletePokemon,
|
deletePokemon,
|
||||||
deleteRecipe,
|
deleteRecipe,
|
||||||
getHabitat,
|
getHabitat,
|
||||||
@@ -45,6 +46,7 @@ import {
|
|||||||
reorderLanguages,
|
reorderLanguages,
|
||||||
reorderPokemon,
|
reorderPokemon,
|
||||||
reorderRecipes,
|
reorderRecipes,
|
||||||
|
setLifePostReaction,
|
||||||
updateConfig,
|
updateConfig,
|
||||||
updateDailyChecklistItem,
|
updateDailyChecklistItem,
|
||||||
updateHabitat,
|
updateHabitat,
|
||||||
@@ -144,6 +146,19 @@ async function requireVerifiedUser(request: FastifyRequest, reply: FastifyReply)
|
|||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function optionalUser(request: FastifyRequest): Promise<AuthUser | null> {
|
||||||
|
const token = getBearerToken(request.headers.authorization);
|
||||||
|
if (!token) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await getUserBySessionToken(token);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
app.post('/api/auth/register', async (request, reply) =>
|
app.post('/api/auth/register', async (request, reply) =>
|
||||||
reply.code(201).send(await registerUser(request.body as Record<string, unknown>, requestLocale(request)))
|
reply.code(201).send(await registerUser(request.body as Record<string, unknown>, requestLocale(request)))
|
||||||
);
|
);
|
||||||
@@ -178,7 +193,10 @@ app.get('/api/options', async (request) => getOptions(requestLocale(request)));
|
|||||||
|
|
||||||
app.get('/api/daily-checklist', async (request) => listDailyChecklistItems(requestLocale(request)));
|
app.get('/api/daily-checklist', async (request) => listDailyChecklistItems(requestLocale(request)));
|
||||||
|
|
||||||
app.get('/api/life-posts', async () => listLifePosts());
|
app.get('/api/life-posts', async (request) => {
|
||||||
|
const user = await optionalUser(request);
|
||||||
|
return listLifePosts(user?.id ?? null);
|
||||||
|
});
|
||||||
|
|
||||||
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);
|
||||||
@@ -220,6 +238,26 @@ app.put('/api/life-posts/:id', async (request, reply) => {
|
|||||||
return post ? post : reply.code(404).send({ message: 'Not found' });
|
return post ? post : reply.code(404).send({ message: 'Not found' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.put('/api/life-posts/:id/reaction', async (request, reply) => {
|
||||||
|
const user = await requireVerifiedUser(request, reply);
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const post = await setLifePostReaction(Number(id), request.body as Record<string, unknown>, user.id);
|
||||||
|
return post ? post : reply.code(404).send({ message: 'Not found' });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/life-posts/:id/reaction', async (request, reply) => {
|
||||||
|
const user = await requireVerifiedUser(request, reply);
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const post = await deleteLifePostReaction(Number(id), user.id);
|
||||||
|
return post ? post : reply.code(404).send({ message: 'Not found' });
|
||||||
|
});
|
||||||
|
|
||||||
app.delete('/api/life-posts/:id', async (request, reply) => {
|
app.delete('/api/life-posts/:id', async (request, reply) => {
|
||||||
const user = await requireVerifiedUser(request, reply);
|
const user = await requireVerifiedUser(request, reply);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
|||||||
@@ -233,6 +233,16 @@ const messages = {
|
|||||||
commentsCount: '{count} comments',
|
commentsCount: '{count} comments',
|
||||||
comment: 'Comment',
|
comment: 'Comment',
|
||||||
hideComments: 'Hide comments',
|
hideComments: 'Hide comments',
|
||||||
|
react: 'Like',
|
||||||
|
reactions: 'Reactions',
|
||||||
|
reactionsCount: '{count} reactions',
|
||||||
|
reactionCountLabel: '{reaction}: {count}',
|
||||||
|
reactionLike: 'Like',
|
||||||
|
reactionHelpful: 'Helpful',
|
||||||
|
reactionFun: 'Fun',
|
||||||
|
reactionThanks: 'Thanks',
|
||||||
|
removeReaction: 'Remove reaction',
|
||||||
|
reactionFailed: 'Reaction failed',
|
||||||
commentPlaceholder: 'Write a comment...',
|
commentPlaceholder: 'Write a comment...',
|
||||||
commentReplyPlaceholder: 'Write a reply...',
|
commentReplyPlaceholder: 'Write a reply...',
|
||||||
postComment: 'Post comment',
|
postComment: 'Post comment',
|
||||||
@@ -566,6 +576,16 @@ const messages = {
|
|||||||
commentsCount: '{count} 条评论',
|
commentsCount: '{count} 条评论',
|
||||||
comment: '评论',
|
comment: '评论',
|
||||||
hideComments: '收起评论',
|
hideComments: '收起评论',
|
||||||
|
react: '点赞',
|
||||||
|
reactions: '互动',
|
||||||
|
reactionsCount: '{count} 次互动',
|
||||||
|
reactionCountLabel: '{reaction}:{count}',
|
||||||
|
reactionLike: '喜欢',
|
||||||
|
reactionHelpful: '有帮助',
|
||||||
|
reactionFun: '有趣',
|
||||||
|
reactionThanks: '感谢',
|
||||||
|
removeReaction: '取消互动',
|
||||||
|
reactionFailed: '互动失败',
|
||||||
commentPlaceholder: '写下评论……',
|
commentPlaceholder: '写下评论……',
|
||||||
commentReplyPlaceholder: '写下回复……',
|
commentReplyPlaceholder: '写下回复……',
|
||||||
postComment: '发表评论',
|
postComment: '发表评论',
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ export const iconPokemon: AppIcon = 'mdi:pokeball';
|
|||||||
export const iconRecipe: AppIcon = 'mdi:book-open-page-variant-outline';
|
export const iconRecipe: AppIcon = 'mdi:book-open-page-variant-outline';
|
||||||
export const iconRegister: AppIcon = 'mdi:account-plus-outline';
|
export const iconRegister: AppIcon = 'mdi:account-plus-outline';
|
||||||
export const iconReply: AppIcon = 'mdi:reply-outline';
|
export const iconReply: AppIcon = 'mdi:reply-outline';
|
||||||
|
export const iconReactionFun: AppIcon = 'mdi:party-popper';
|
||||||
|
export const iconReactionHelpful: AppIcon = 'mdi:lightbulb-on-outline';
|
||||||
|
export const iconReactionLike: AppIcon = 'mdi:thumb-up-outline';
|
||||||
|
export const iconReactionThanks: AppIcon = 'mdi:hand-heart-outline';
|
||||||
export const iconSave: AppIcon = 'mdi:content-save-outline';
|
export const iconSave: AppIcon = 'mdi:content-save-outline';
|
||||||
export const iconSuccess: AppIcon = 'mdi:check-circle-outline';
|
export const iconSuccess: AppIcon = 'mdi:check-circle-outline';
|
||||||
export const iconTranslate: AppIcon = 'mdi:translate';
|
export const iconTranslate: AppIcon = 'mdi:translate';
|
||||||
|
|||||||
@@ -173,6 +173,9 @@ export interface DailyChecklistItem {
|
|||||||
translations?: TranslationMap;
|
translations?: TranslationMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type LifeReactionType = 'like' | 'helpful' | 'fun' | 'thanks';
|
||||||
|
export type LifeReactionCounts = Record<LifeReactionType, number>;
|
||||||
|
|
||||||
export interface LifePost {
|
export interface LifePost {
|
||||||
id: number;
|
id: number;
|
||||||
body: string;
|
body: string;
|
||||||
@@ -181,6 +184,8 @@ export interface LifePost {
|
|||||||
author: UserSummary | null;
|
author: UserSummary | null;
|
||||||
updatedBy: UserSummary | null;
|
updatedBy: UserSummary | null;
|
||||||
comments: LifeComment[];
|
comments: LifeComment[];
|
||||||
|
reactionCounts: LifeReactionCounts;
|
||||||
|
myReaction: LifeReactionType | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LifeComment {
|
export interface LifeComment {
|
||||||
@@ -417,6 +422,19 @@ async function deleteJson(path: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function deleteAndGetJson<T>(path: string): Promise<T> {
|
||||||
|
const response = await fetch(`${apiBaseUrl}${path}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: requestHeaders()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await getErrorMessage(response));
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
languages: () => getJson<Language[]>('/api/languages'),
|
languages: () => getJson<Language[]>('/api/languages'),
|
||||||
adminLanguages: () => getJson<Language[]>('/api/admin/languages'),
|
adminLanguages: () => getJson<Language[]>('/api/admin/languages'),
|
||||||
@@ -439,6 +457,9 @@ export const api = {
|
|||||||
updateLifePost: (id: string | number, payload: LifePostPayload) =>
|
updateLifePost: (id: string | number, payload: LifePostPayload) =>
|
||||||
sendJson<LifePost>(`/api/life-posts/${id}`, 'PUT', payload),
|
sendJson<LifePost>(`/api/life-posts/${id}`, 'PUT', payload),
|
||||||
deleteLifePost: (id: string | number) => deleteJson(`/api/life-posts/${id}`),
|
deleteLifePost: (id: string | number) => deleteJson(`/api/life-posts/${id}`),
|
||||||
|
setLifeReaction: (id: string | number, reactionType: LifeReactionType) =>
|
||||||
|
sendJson<LifePost>(`/api/life-posts/${id}/reaction`, 'PUT', { reactionType }),
|
||||||
|
deleteLifeReaction: (id: string | number) => deleteAndGetJson<LifePost>(`/api/life-posts/${id}/reaction`),
|
||||||
createLifeComment: (postId: string | number, payload: LifeCommentPayload) =>
|
createLifeComment: (postId: string | number, payload: LifeCommentPayload) =>
|
||||||
sendJson<LifeComment>(`/api/life-posts/${postId}/comments`, 'POST', payload),
|
sendJson<LifeComment>(`/api/life-posts/${postId}/comments`, 'POST', payload),
|
||||||
createLifeCommentReply: (postId: string | number, commentId: string | number, payload: LifeCommentPayload) =>
|
createLifeCommentReply: (postId: string | number, commentId: string | number, payload: LifeCommentPayload) =>
|
||||||
|
|||||||
@@ -1326,6 +1326,19 @@ button:disabled,
|
|||||||
border-top: 1px solid var(--line);
|
border-top: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.life-post__engagement-actions,
|
||||||
|
.life-post__metrics {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.life-post__metrics {
|
||||||
|
justify-content: flex-end;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.life-post__engagement-button,
|
.life-post__engagement-button,
|
||||||
.life-post__comment-count {
|
.life-post__comment-count {
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
@@ -1342,7 +1355,8 @@ button:disabled,
|
|||||||
|
|
||||||
.life-post__engagement-button:hover,
|
.life-post__engagement-button:hover,
|
||||||
.life-post__comment-count:hover,
|
.life-post__comment-count:hover,
|
||||||
.life-post__engagement-button[aria-expanded="true"] {
|
.life-post__engagement-button[aria-expanded="true"],
|
||||||
|
.life-post__engagement-button.is-active {
|
||||||
background: color-mix(in srgb, var(--pokemon-blue) 10%, var(--surface-soft));
|
background: color-mix(in srgb, var(--pokemon-blue) 10%, var(--surface-soft));
|
||||||
color: var(--pokemon-blue-deep);
|
color: var(--pokemon-blue-deep);
|
||||||
}
|
}
|
||||||
@@ -1357,6 +1371,136 @@ button:disabled,
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.life-reactions {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.life-reaction-trigger {
|
||||||
|
position: relative;
|
||||||
|
width: 44px;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.life-reaction-picker {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 10;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
left: 0;
|
||||||
|
width: max-content;
|
||||||
|
max-width: calc(100vw - 48px);
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
border: 2px solid var(--line-strong);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
background: var(--surface);
|
||||||
|
box-shadow: var(--shadow-control);
|
||||||
|
}
|
||||||
|
|
||||||
|
.life-reaction-option {
|
||||||
|
position: relative;
|
||||||
|
width: 44px;
|
||||||
|
min-height: 44px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 7px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
background: var(--surface-soft);
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-weight: 900;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.life-reaction-option:hover,
|
||||||
|
.life-reaction-option.is-active {
|
||||||
|
border-color: color-mix(in srgb, var(--pokemon-blue) 50%, var(--line));
|
||||||
|
background: color-mix(in srgb, var(--pokemon-blue) 12%, var(--surface-soft));
|
||||||
|
color: var(--pokemon-blue-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
.life-reaction-option .ui-icon,
|
||||||
|
.life-reaction-summary .ui-icon {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.life-reaction-summary {
|
||||||
|
min-height: 44px;
|
||||||
|
display: inline-flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.life-reaction-summary__item {
|
||||||
|
position: relative;
|
||||||
|
min-height: 32px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px 7px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
background: var(--surface-soft);
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.life-reaction-tooltip {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 30;
|
||||||
|
bottom: calc(100% + 8px);
|
||||||
|
left: 50%;
|
||||||
|
min-width: max-content;
|
||||||
|
max-width: 220px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
background: var(--ink);
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
|
color: var(--surface);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 850;
|
||||||
|
line-height: 1.25;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
text-align: center;
|
||||||
|
transform: translate(-50%, 4px);
|
||||||
|
transition: opacity 140ms ease, transform 140ms ease, visibility 140ms ease;
|
||||||
|
visibility: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.life-reaction-tooltip::after {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 50%;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-right: 1px solid var(--line-strong);
|
||||||
|
border-bottom: 1px solid var(--line-strong);
|
||||||
|
background: var(--ink);
|
||||||
|
content: '';
|
||||||
|
transform: translate(-50%, -4px) rotate(45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.life-reaction-trigger:hover .life-reaction-tooltip,
|
||||||
|
.life-reaction-trigger:focus-visible .life-reaction-tooltip,
|
||||||
|
.life-reaction-option:hover .life-reaction-tooltip,
|
||||||
|
.life-reaction-option:focus-visible .life-reaction-tooltip,
|
||||||
|
.life-reaction-summary__item:hover .life-reaction-tooltip {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate(-50%, 0);
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|
||||||
.life-comments {
|
.life-comments {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@@ -2696,6 +2840,10 @@ button:disabled,
|
|||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.life-post__metrics {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
.life-comment-replies {
|
.life-comment-replies {
|
||||||
padding-left: 10px;
|
padding-left: 10px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,19 @@ import { useI18n } from 'vue-i18n';
|
|||||||
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 { iconCancel, iconComment, iconDelete, iconEdit, iconLife, iconReply, iconSave } from '../icons';
|
import {
|
||||||
|
iconCancel,
|
||||||
|
iconComment,
|
||||||
|
iconDelete,
|
||||||
|
iconEdit,
|
||||||
|
iconLife,
|
||||||
|
iconReactionFun,
|
||||||
|
iconReactionHelpful,
|
||||||
|
iconReactionLike,
|
||||||
|
iconReactionThanks,
|
||||||
|
iconReply,
|
||||||
|
iconSave
|
||||||
|
} from '../icons';
|
||||||
import {
|
import {
|
||||||
api,
|
api,
|
||||||
getAuthToken,
|
getAuthToken,
|
||||||
@@ -13,7 +25,8 @@ import {
|
|||||||
setAuthToken,
|
setAuthToken,
|
||||||
type AuthUser,
|
type AuthUser,
|
||||||
type LifeComment,
|
type LifeComment,
|
||||||
type LifePost
|
type LifePost,
|
||||||
|
type LifeReactionType
|
||||||
} from '../services/api';
|
} from '../services/api';
|
||||||
|
|
||||||
const { locale, t } = useI18n();
|
const { locale, t } = useI18n();
|
||||||
@@ -32,10 +45,20 @@ const replyTargetId = ref<number | null>(null);
|
|||||||
const expandedComments = ref<Record<number, boolean>>({});
|
const expandedComments = ref<Record<number, boolean>>({});
|
||||||
const commentBusyKey = ref('');
|
const commentBusyKey = ref('');
|
||||||
const commentErrors = ref<Record<string, string>>({});
|
const commentErrors = ref<Record<string, string>>({});
|
||||||
|
const reactionPickerPostId = ref<number | null>(null);
|
||||||
|
const reactionBusyPostId = ref<number | null>(null);
|
||||||
|
const reactionErrors = ref<Record<number, string>>({});
|
||||||
const bodyInput = ref<HTMLTextAreaElement | null>(null);
|
const bodyInput = ref<HTMLTextAreaElement | null>(null);
|
||||||
const skeletonPostCount = 3;
|
const skeletonPostCount = 3;
|
||||||
let removeAuthListener: (() => void) | null = null;
|
let removeAuthListener: (() => void) | null = null;
|
||||||
|
|
||||||
|
const reactionOptions = [
|
||||||
|
{ type: 'like', icon: iconReactionLike, labelKey: 'pages.life.reactionLike' },
|
||||||
|
{ type: 'helpful', icon: iconReactionHelpful, labelKey: 'pages.life.reactionHelpful' },
|
||||||
|
{ type: 'fun', icon: iconReactionFun, labelKey: 'pages.life.reactionFun' },
|
||||||
|
{ type: 'thanks', icon: iconReactionThanks, labelKey: 'pages.life.reactionThanks' }
|
||||||
|
] as const satisfies ReadonlyArray<{ type: LifeReactionType; icon: string; labelKey: string }>;
|
||||||
|
|
||||||
const canPost = computed(() => currentUser.value?.emailVerified === true);
|
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);
|
||||||
@@ -102,7 +125,7 @@ async function submitPost() {
|
|||||||
try {
|
try {
|
||||||
if (editingPostId.value !== null) {
|
if (editingPostId.value !== null) {
|
||||||
const updated = await api.updateLifePost(editingPostId.value, payload());
|
const updated = await api.updateLifePost(editingPostId.value, payload());
|
||||||
posts.value = posts.value.map((post) => (post.id === updated.id ? updated : post));
|
replacePost(updated);
|
||||||
} else {
|
} else {
|
||||||
const created = await api.createLifePost(payload());
|
const created = await api.createLifePost(payload());
|
||||||
posts.value = [created, ...posts.value];
|
posts.value = [created, ...posts.value];
|
||||||
@@ -140,6 +163,37 @@ function commentCount(post: LifePost) {
|
|||||||
return post.comments.reduce((count, comment) => count + 1 + comment.replies.length, 0);
|
return post.comments.reduce((count, comment) => count + 1 + comment.replies.length, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function reactionTotal(post: LifePost) {
|
||||||
|
return reactionOptions.reduce((count, option) => count + (post.reactionCounts[option.type] ?? 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function reactionLabel(type: LifeReactionType) {
|
||||||
|
return t(reactionOptions.find((option) => option.type === type)?.labelKey ?? 'pages.life.react');
|
||||||
|
}
|
||||||
|
|
||||||
|
function reactionIcon(type: LifeReactionType | null) {
|
||||||
|
return reactionOptions.find((option) => option.type === type)?.icon ?? iconReactionLike;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reactionButtonLabel(post: LifePost) {
|
||||||
|
return post.myReaction ? reactionLabel(post.myReaction) : t('pages.life.reactionLike');
|
||||||
|
}
|
||||||
|
|
||||||
|
function reactionOptionLabel(post: LifePost, type: LifeReactionType) {
|
||||||
|
return post.myReaction === type ? t('pages.life.removeReaction') : reactionLabel(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
function reactionCountLabel(post: LifePost, type: LifeReactionType) {
|
||||||
|
return t('pages.life.reactionCountLabel', {
|
||||||
|
reaction: reactionLabel(type),
|
||||||
|
count: post.reactionCounts[type] ?? 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function replacePost(updatedPost: LifePost) {
|
||||||
|
posts.value = posts.value.map((post) => (post.id === updatedPost.id ? updatedPost : post));
|
||||||
|
}
|
||||||
|
|
||||||
function areCommentsExpanded(postId: number) {
|
function areCommentsExpanded(postId: number) {
|
||||||
return expandedComments.value[postId] === true;
|
return expandedComments.value[postId] === true;
|
||||||
}
|
}
|
||||||
@@ -159,6 +213,10 @@ function isCommentBusy(key: string) {
|
|||||||
return commentBusyKey.value === key;
|
return commentBusyKey.value === key;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isReactionBusy(postId: number) {
|
||||||
|
return reactionBusyPostId.value === postId;
|
||||||
|
}
|
||||||
|
|
||||||
function commentAuthorName(comment: LifeComment) {
|
function commentAuthorName(comment: LifeComment) {
|
||||||
return comment.deleted ? t('pages.life.commentDeleted') : comment.author?.displayName ?? t('pages.life.byUnknown');
|
return comment.deleted ? t('pages.life.commentDeleted') : comment.author?.displayName ?? t('pages.life.byUnknown');
|
||||||
}
|
}
|
||||||
@@ -178,6 +236,69 @@ function clearCommentError(key: string) {
|
|||||||
commentErrors.value = nextErrors;
|
commentErrors.value = nextErrors;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setReactionError(postId: number, message: string) {
|
||||||
|
reactionErrors.value = { ...reactionErrors.value, [postId]: message };
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearReactionError(postId: number) {
|
||||||
|
const nextErrors = { ...reactionErrors.value };
|
||||||
|
delete nextErrors[postId];
|
||||||
|
reactionErrors.value = nextErrors;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canUseReactions() {
|
||||||
|
return canPost.value && reactionBusyPostId.value === null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleReactionPicker(postId: number) {
|
||||||
|
if (!canUseReactions()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearReactionError(postId);
|
||||||
|
reactionPickerPostId.value = reactionPickerPostId.value === postId ? null : postId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReactionContextMenu(event: MouseEvent, postId: number) {
|
||||||
|
event.preventDefault();
|
||||||
|
toggleReactionPicker(postId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReactionKeydown(event: KeyboardEvent, postId: number) {
|
||||||
|
if (event.key !== 'ContextMenu' && !(event.shiftKey && event.key === 'F10')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
toggleReactionPicker(postId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleDefaultReaction(post: LifePost) {
|
||||||
|
await toggleReaction(post, 'like');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleReaction(post: LifePost, reactionType: LifeReactionType) {
|
||||||
|
if (!canUseReactions()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
reactionBusyPostId.value = post.id;
|
||||||
|
clearReactionError(post.id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updatedPost =
|
||||||
|
post.myReaction === reactionType
|
||||||
|
? await api.deleteLifeReaction(post.id)
|
||||||
|
: await api.setLifeReaction(post.id, reactionType);
|
||||||
|
replacePost(updatedPost);
|
||||||
|
reactionPickerPostId.value = null;
|
||||||
|
} catch (error) {
|
||||||
|
setReactionError(post.id, error instanceof Error && error.message ? error.message : t('pages.life.reactionFailed'));
|
||||||
|
} finally {
|
||||||
|
reactionBusyPostId.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function startEdit(post: LifePost) {
|
function startEdit(post: LifePost) {
|
||||||
editingPostId.value = post.id;
|
editingPostId.value = post.id;
|
||||||
body.value = post.body;
|
body.value = post.body;
|
||||||
@@ -318,6 +439,7 @@ onMounted(() => {
|
|||||||
void loadPosts();
|
void loadPosts();
|
||||||
removeAuthListener = onAuthTokenChange(() => {
|
removeAuthListener = onAuthTokenChange(() => {
|
||||||
void loadCurrentUser();
|
void loadCurrentUser();
|
||||||
|
void loadPosts();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -424,6 +546,58 @@ onUnmounted(() => {
|
|||||||
<p class="life-post__body">{{ post.body }}</p>
|
<p class="life-post__body">{{ post.body }}</p>
|
||||||
|
|
||||||
<div class="life-post__engagement">
|
<div class="life-post__engagement">
|
||||||
|
<div class="life-post__engagement-actions">
|
||||||
|
<div class="life-reactions">
|
||||||
|
<button
|
||||||
|
class="life-post__engagement-button life-reaction-trigger"
|
||||||
|
:class="{ 'is-active': post.myReaction !== null }"
|
||||||
|
type="button"
|
||||||
|
:aria-controls="`life-reactions-${post.id}`"
|
||||||
|
:aria-expanded="reactionPickerPostId === post.id"
|
||||||
|
:aria-label="reactionButtonLabel(post)"
|
||||||
|
:aria-describedby="`life-reaction-tooltip-${post.id}`"
|
||||||
|
:disabled="!canPost || reactionBusyPostId !== null"
|
||||||
|
@click="toggleDefaultReaction(post)"
|
||||||
|
@contextmenu="handleReactionContextMenu($event, post.id)"
|
||||||
|
@keydown="handleReactionKeydown($event, post.id)"
|
||||||
|
>
|
||||||
|
<Icon :icon="reactionIcon(post.myReaction)" class="ui-icon" aria-hidden="true" />
|
||||||
|
<span :id="`life-reaction-tooltip-${post.id}`" class="life-reaction-tooltip" role="tooltip">
|
||||||
|
{{ reactionButtonLabel(post) }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="reactionPickerPostId === post.id && canPost"
|
||||||
|
:id="`life-reactions-${post.id}`"
|
||||||
|
class="life-reaction-picker"
|
||||||
|
role="group"
|
||||||
|
:aria-label="t('pages.life.reactions')"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="option in reactionOptions"
|
||||||
|
:key="option.type"
|
||||||
|
class="life-reaction-option"
|
||||||
|
:class="{ 'is-active': post.myReaction === option.type }"
|
||||||
|
type="button"
|
||||||
|
:aria-pressed="post.myReaction === option.type"
|
||||||
|
:aria-label="reactionOptionLabel(post, option.type)"
|
||||||
|
:aria-describedby="`life-reaction-option-tooltip-${post.id}-${option.type}`"
|
||||||
|
:disabled="isReactionBusy(post.id)"
|
||||||
|
@click="toggleReaction(post, option.type)"
|
||||||
|
>
|
||||||
|
<Icon :icon="option.icon" class="ui-icon" aria-hidden="true" />
|
||||||
|
<span
|
||||||
|
:id="`life-reaction-option-tooltip-${post.id}-${option.type}`"
|
||||||
|
class="life-reaction-tooltip"
|
||||||
|
role="tooltip"
|
||||||
|
>
|
||||||
|
{{ reactionOptionLabel(post, option.type) }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="life-post__engagement-button"
|
class="life-post__engagement-button"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -434,6 +608,27 @@ onUnmounted(() => {
|
|||||||
<Icon :icon="iconComment" class="ui-icon" aria-hidden="true" />
|
<Icon :icon="iconComment" class="ui-icon" aria-hidden="true" />
|
||||||
{{ areCommentsExpanded(post.id) ? t('pages.life.hideComments') : t('pages.life.comment') }}
|
{{ areCommentsExpanded(post.id) ? t('pages.life.hideComments') : t('pages.life.comment') }}
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="life-post__metrics">
|
||||||
|
<div
|
||||||
|
v-if="reactionTotal(post) > 0"
|
||||||
|
class="life-reaction-summary"
|
||||||
|
:aria-label="t('pages.life.reactionsCount', { count: reactionTotal(post) })"
|
||||||
|
>
|
||||||
|
<template v-for="option in reactionOptions" :key="option.type">
|
||||||
|
<span
|
||||||
|
v-if="post.reactionCounts[option.type] > 0"
|
||||||
|
class="life-reaction-summary__item"
|
||||||
|
:aria-label="reactionCountLabel(post, option.type)"
|
||||||
|
>
|
||||||
|
<Icon :icon="option.icon" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ post.reactionCounts[option.type] }}
|
||||||
|
<span class="life-reaction-tooltip" role="tooltip">{{ reactionCountLabel(post, option.type) }}</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="life-post__comment-count"
|
class="life-post__comment-count"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -444,6 +639,9 @@ onUnmounted(() => {
|
|||||||
{{ t('pages.life.commentsCount', { count: commentCount(post) }) }}
|
{{ t('pages.life.commentsCount', { count: commentCount(post) }) }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="reactionErrors[post.id]" class="life-form__error" role="alert">{{ reactionErrors[post.id] }}</p>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
v-if="areCommentsExpanded(post.id)"
|
v-if="areCommentsExpanded(post.id)"
|
||||||
|
|||||||
Reference in New Issue
Block a user