feat(profile): add public user profiles with activity tabs and stats
Add API routes for user stats, posts, reactions, and comments Implement profile view with Feeds, Contributions, Reactions tabs Link to user profiles from edit history, discussions, and life posts Add database indexes to optimize user-centric queries
This commit is contained in:
13
DESIGN.md
13
DESIGN.md
@@ -116,8 +116,12 @@
|
||||
- 当前用户:`id`、`email`、`displayName`、`emailVerified`
|
||||
- 编辑署名:`id`、`displayName`
|
||||
- User Profile:
|
||||
- 登录用户可通过 `/profile` 查看自己的邮箱、邮箱验证状态和显示名。
|
||||
- 当前版本只允许用户更新自己的 `displayName`,不支持头像、公开个人主页、邮箱修改或直接密码修改。
|
||||
- 登录用户可通过 `/profile` 查看自己的账号资料、邮箱验证状态、Referral 信息和公开主页内容。
|
||||
- 任意用户可通过 `/profile/:id` 访问其他用户的公开 Profile。
|
||||
- 公开 Profile 展示用户公开摘要、Life Feeds、Wiki 贡献统计、Like / Reaction 过的 Life Post 和评论过的内容。
|
||||
- Profile 使用 Tabs 组织:Feeds、Contributions、Reactions、Comments;仅自己的 `/profile` 额外展示 Account。
|
||||
- 公开用户摘要只包含 `id`、`displayName` 和公开展示需要的加入时间;不公开邮箱、角色、权限、Referral Code、邀请链接、session、token/hash 或内部审计 payload。
|
||||
- 当前版本只允许用户在自己的 `/profile` Account Tab 更新 `displayName`,不支持头像、邮箱修改或直接密码修改。
|
||||
- 更新显示名后,API 仍只返回当前用户必要字段,不返回 session、token/hash、内部审计或调试数据。
|
||||
- 显示名用于编辑署名、讨论和 Life 内容作者展示。
|
||||
|
||||
@@ -215,6 +219,7 @@
|
||||
- `created_at`
|
||||
- 详情页展示最后编辑者、最后编辑时间和编辑历史面板。
|
||||
- 编辑历史中的用户信息只展示必要署名,不暴露邮箱、token、hash 或内部元数据。
|
||||
- 编辑署名、编辑历史署名、Life 作者和讨论作者可链接到对应公开 Profile。
|
||||
|
||||
## Wiki 图片上传
|
||||
|
||||
@@ -664,6 +669,10 @@ API 暴露边界:
|
||||
- `GET /api/recipes`
|
||||
- `GET /api/recipes/:id`
|
||||
- `GET /api/life-posts`:支持 `cursor` / `limit` 分页读取;支持 `search` 按 Life Post 正文搜索;支持 `tagId` 按 Life 标签筛选。
|
||||
- `GET /api/users/:id/profile`:读取公开用户 Profile 摘要、Wiki 贡献统计和公开社区统计。
|
||||
- `GET /api/users/:id/life-posts`:分页读取该用户发布过且未删除的 Life Post。
|
||||
- `GET /api/users/:id/reactions`:分页读取该用户设置过 Reaction 且目标未删除的 Life Post。
|
||||
- `GET /api/users/:id/comments`:分页读取该用户未删除的 Life 评论和实体讨论评论。
|
||||
- `GET /api/discussions/:entityType/:entityId/comments`:读取实体讨论;`entityType` 支持 `pokemon`、`items`、`recipes`、`habitats`。
|
||||
|
||||
认证 API:
|
||||
|
||||
@@ -490,6 +490,10 @@ CREATE INDEX IF NOT EXISTS life_posts_active_created_at_idx
|
||||
ON life_posts(created_at DESC, id DESC)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS life_posts_user_created_at_idx
|
||||
ON life_posts(created_by_user_id, created_at DESC, id DESC)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
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,
|
||||
@@ -517,6 +521,9 @@ CREATE INDEX IF NOT EXISTS life_post_comments_post_idx
|
||||
CREATE INDEX IF NOT EXISTS life_post_comments_parent_idx
|
||||
ON life_post_comments(parent_comment_id, created_at, id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS life_post_comments_user_idx
|
||||
ON life_post_comments(created_by_user_id, created_at DESC, id DESC);
|
||||
|
||||
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,
|
||||
@@ -529,6 +536,9 @@ CREATE TABLE IF NOT EXISTS life_post_reactions (
|
||||
CREATE INDEX IF NOT EXISTS life_post_reactions_post_idx
|
||||
ON life_post_reactions(post_id, reaction_type);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS life_post_reactions_user_idx
|
||||
ON life_post_reactions(user_id, updated_at DESC, post_id DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS skills (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
name text NOT NULL UNIQUE,
|
||||
|
||||
@@ -243,12 +243,87 @@ type LifePostCursor = {
|
||||
id: number;
|
||||
};
|
||||
|
||||
type LifePostFilters = {
|
||||
authorId?: number;
|
||||
};
|
||||
|
||||
type LifePostsPage = {
|
||||
items: LifePost[];
|
||||
nextCursor: string | null;
|
||||
hasMore: boolean;
|
||||
};
|
||||
|
||||
type PublicProfileUser = {
|
||||
id: number;
|
||||
displayName: string;
|
||||
joinedAt: Date;
|
||||
};
|
||||
|
||||
type PublicProfileStats = {
|
||||
wikiEdits: number;
|
||||
wikiCreates: number;
|
||||
wikiUpdates: number;
|
||||
wikiDeletes: number;
|
||||
imageUploads: number;
|
||||
lifePosts: number;
|
||||
lifeComments: number;
|
||||
lifeReactions: number;
|
||||
discussionComments: number;
|
||||
};
|
||||
|
||||
type PublicProfileContribution = {
|
||||
contentType: string;
|
||||
total: number;
|
||||
creates: number;
|
||||
updates: number;
|
||||
deletes: number;
|
||||
lastContributedAt: Date | null;
|
||||
};
|
||||
|
||||
type PublicUserProfile = {
|
||||
user: PublicProfileUser;
|
||||
stats: PublicProfileStats;
|
||||
contributions: PublicProfileContribution[];
|
||||
};
|
||||
|
||||
type UserReactionActivity = {
|
||||
postId: number;
|
||||
reactionType: LifeReactionType;
|
||||
reactedAt: Date;
|
||||
post: LifePost;
|
||||
};
|
||||
|
||||
type UserReactionActivityPage = {
|
||||
items: UserReactionActivity[];
|
||||
nextCursor: string | null;
|
||||
hasMore: boolean;
|
||||
};
|
||||
|
||||
type UserCommentActivitySource = 'life' | 'discussion';
|
||||
|
||||
type UserCommentActivity = {
|
||||
id: number;
|
||||
source: UserCommentActivitySource;
|
||||
body: string;
|
||||
createdAt: Date;
|
||||
target: {
|
||||
type: 'life-post' | DiscussionEntityType;
|
||||
id: number;
|
||||
title: string;
|
||||
excerpt: string;
|
||||
};
|
||||
};
|
||||
|
||||
type UserCommentActivityPage = {
|
||||
items: UserCommentActivity[];
|
||||
nextCursor: string | null;
|
||||
hasMore: boolean;
|
||||
};
|
||||
|
||||
type UserCommentActivityCursor = LifePostCursor & {
|
||||
source: UserCommentActivitySource;
|
||||
};
|
||||
|
||||
type HabitatPayload = {
|
||||
name: string;
|
||||
translations: TranslationInput;
|
||||
@@ -2198,6 +2273,45 @@ function encodeLifePostCursor(post: LifePostRow): string {
|
||||
return Buffer.from(JSON.stringify({ createdAt: post.createdAtCursor, id: post.id }), 'utf8').toString('base64url');
|
||||
}
|
||||
|
||||
function encodeProfileCursor(cursor: LifePostCursor): string {
|
||||
return Buffer.from(JSON.stringify(cursor), 'utf8').toString('base64url');
|
||||
}
|
||||
|
||||
function decodeUserCommentActivityCursor(value: QueryValue): UserCommentActivityCursor | null {
|
||||
const rawCursor = asString(value);
|
||||
if (!rawCursor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const cursor = JSON.parse(Buffer.from(rawCursor, 'base64url').toString('utf8')) as Partial<UserCommentActivityCursor>;
|
||||
const createdAt = typeof cursor.createdAt === 'string' ? cursor.createdAt : '';
|
||||
const id = Number(cursor.id);
|
||||
const source = cursor.source;
|
||||
|
||||
if (
|
||||
!createdAt ||
|
||||
Number.isNaN(new Date(createdAt).getTime()) ||
|
||||
!Number.isInteger(id) ||
|
||||
id <= 0 ||
|
||||
(source !== 'life' && source !== 'discussion')
|
||||
) {
|
||||
throw validationError('server.validation.cursorInvalid');
|
||||
}
|
||||
|
||||
return { createdAt, id, source };
|
||||
} catch (error) {
|
||||
if (error instanceof Error && 'statusCode' in error) {
|
||||
throw error;
|
||||
}
|
||||
throw validationError('server.validation.cursorInvalid');
|
||||
}
|
||||
}
|
||||
|
||||
function encodeUserCommentActivityCursor(cursor: UserCommentActivityCursor): string {
|
||||
return Buffer.from(JSON.stringify(cursor), 'utf8').toString('base64url');
|
||||
}
|
||||
|
||||
function hydrateLifePost(
|
||||
post: LifePostRow,
|
||||
commentsByPost: Map<number, LifeComment[]>,
|
||||
@@ -2354,10 +2468,11 @@ async function getLifeCommentById(id: number): Promise<LifeComment | null> {
|
||||
return row ? { ...row, replies: [] } : null;
|
||||
}
|
||||
|
||||
export async function listLifePosts(
|
||||
async function listLifePostsWithFilters(
|
||||
paramsQuery: QueryParams = {},
|
||||
userId: number | null = null,
|
||||
locale = defaultLocale
|
||||
locale = defaultLocale,
|
||||
filters: LifePostFilters = {}
|
||||
): Promise<LifePostsPage> {
|
||||
const cursor = decodeLifePostCursor(paramsQuery.cursor);
|
||||
const limit = cleanLifePostLimit(paramsQuery.limit);
|
||||
@@ -2366,6 +2481,11 @@ export async function listLifePosts(
|
||||
const params: unknown[] = [];
|
||||
const conditions: string[] = ['lp.deleted_at IS NULL'];
|
||||
|
||||
if (filters.authorId !== undefined) {
|
||||
params.push(filters.authorId);
|
||||
conditions.push(`lp.created_by_user_id = $${params.length}`);
|
||||
}
|
||||
|
||||
if (search) {
|
||||
params.push(`%${search}%`);
|
||||
conditions.push(`lp.body ILIKE $${params.length}`);
|
||||
@@ -2412,6 +2532,357 @@ export async function listLifePosts(
|
||||
};
|
||||
}
|
||||
|
||||
export async function listLifePosts(
|
||||
paramsQuery: QueryParams = {},
|
||||
userId: number | null = null,
|
||||
locale = defaultLocale
|
||||
): Promise<LifePostsPage> {
|
||||
return listLifePostsWithFilters(paramsQuery, userId, locale);
|
||||
}
|
||||
|
||||
async function getPublicProfileUser(userIdValue: number): Promise<PublicProfileUser | null> {
|
||||
const userId = requirePositiveInteger(userIdValue, 'server.validation.recordInvalid');
|
||||
return queryOne<PublicProfileUser>(
|
||||
`
|
||||
SELECT
|
||||
id,
|
||||
display_name AS "displayName",
|
||||
created_at AS "joinedAt"
|
||||
FROM users
|
||||
WHERE id = $1
|
||||
`,
|
||||
[userId]
|
||||
);
|
||||
}
|
||||
|
||||
function publicContributionType(entityType: string): string {
|
||||
return entityType === 'daily-checklist-items' ? 'daily-checklist' : entityType;
|
||||
}
|
||||
|
||||
export async function getPublicUserProfile(userIdValue: number): Promise<PublicUserProfile | null> {
|
||||
const user = await getPublicProfileUser(userIdValue);
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const stats = await queryOne<PublicProfileStats>(
|
||||
`
|
||||
SELECT
|
||||
COALESCE((SELECT COUNT(*)::integer FROM wiki_edit_logs WHERE user_id = $1), 0) AS "wikiEdits",
|
||||
COALESCE((SELECT COUNT(*)::integer FROM wiki_edit_logs WHERE user_id = $1 AND action = 'create'), 0) AS "wikiCreates",
|
||||
COALESCE((SELECT COUNT(*)::integer FROM wiki_edit_logs WHERE user_id = $1 AND action = 'update'), 0) AS "wikiUpdates",
|
||||
COALESCE((SELECT COUNT(*)::integer FROM wiki_edit_logs WHERE user_id = $1 AND action = 'delete'), 0) AS "wikiDeletes",
|
||||
COALESCE((SELECT COUNT(*)::integer FROM entity_image_uploads WHERE created_by_user_id = $1), 0) AS "imageUploads",
|
||||
COALESCE((SELECT COUNT(*)::integer FROM life_posts WHERE created_by_user_id = $1 AND deleted_at IS NULL), 0) AS "lifePosts",
|
||||
COALESCE((
|
||||
SELECT COUNT(*)::integer
|
||||
FROM life_post_comments lc
|
||||
JOIN life_posts lp ON lp.id = lc.post_id
|
||||
WHERE lc.created_by_user_id = $1
|
||||
AND lc.deleted_at IS NULL
|
||||
AND lp.deleted_at IS NULL
|
||||
), 0) AS "lifeComments",
|
||||
COALESCE((
|
||||
SELECT COUNT(*)::integer
|
||||
FROM life_post_reactions lpr
|
||||
JOIN life_posts lp ON lp.id = lpr.post_id
|
||||
WHERE lpr.user_id = $1
|
||||
AND lp.deleted_at IS NULL
|
||||
), 0) AS "lifeReactions",
|
||||
COALESCE((
|
||||
SELECT COUNT(*)::integer
|
||||
FROM entity_discussion_comments
|
||||
WHERE created_by_user_id = $1
|
||||
AND deleted_at IS NULL
|
||||
), 0) AS "discussionComments"
|
||||
`,
|
||||
[user.id]
|
||||
);
|
||||
|
||||
const contributions = await query<PublicProfileContribution>(
|
||||
`
|
||||
SELECT
|
||||
entity_type AS "contentType",
|
||||
COUNT(*)::integer AS total,
|
||||
COUNT(*) FILTER (WHERE action = 'create')::integer AS creates,
|
||||
COUNT(*) FILTER (WHERE action = 'update')::integer AS updates,
|
||||
COUNT(*) FILTER (WHERE action = 'delete')::integer AS deletes,
|
||||
MAX(created_at) AS "lastContributedAt"
|
||||
FROM wiki_edit_logs
|
||||
WHERE user_id = $1
|
||||
GROUP BY entity_type
|
||||
ORDER BY total DESC, "lastContributedAt" DESC, entity_type
|
||||
`,
|
||||
[user.id]
|
||||
);
|
||||
|
||||
return {
|
||||
user,
|
||||
stats: stats ?? {
|
||||
wikiEdits: 0,
|
||||
wikiCreates: 0,
|
||||
wikiUpdates: 0,
|
||||
wikiDeletes: 0,
|
||||
imageUploads: 0,
|
||||
lifePosts: 0,
|
||||
lifeComments: 0,
|
||||
lifeReactions: 0,
|
||||
discussionComments: 0
|
||||
},
|
||||
contributions: contributions.map((item) => ({
|
||||
...item,
|
||||
contentType: publicContributionType(item.contentType)
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
export async function listUserLifePosts(
|
||||
userIdValue: number,
|
||||
paramsQuery: QueryParams = {},
|
||||
viewerUserId: number | null = null,
|
||||
locale = defaultLocale
|
||||
): Promise<LifePostsPage | null> {
|
||||
const user = await getPublicProfileUser(userIdValue);
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return listLifePostsWithFilters(paramsQuery, viewerUserId, locale, { authorId: user.id });
|
||||
}
|
||||
|
||||
async function hydrateLifePostsById(
|
||||
postIds: number[],
|
||||
viewerUserId: number | null,
|
||||
locale: string
|
||||
): Promise<Map<number, LifePost>> {
|
||||
const postById = new Map<number, LifePost>();
|
||||
if (postIds.length === 0) {
|
||||
return postById;
|
||||
}
|
||||
|
||||
const posts = await query<LifePostRow>(
|
||||
`
|
||||
${lifePostProjection(locale)}
|
||||
WHERE lp.id = ANY($1::integer[])
|
||||
AND lp.deleted_at IS NULL
|
||||
`,
|
||||
[postIds]
|
||||
);
|
||||
const commentsByPost = await lifeCommentsForPosts(postIds);
|
||||
const { countsByPost, myReactionsByPost } = await lifeReactionsForPosts(postIds, viewerUserId);
|
||||
|
||||
for (const post of posts) {
|
||||
postById.set(post.id, hydrateLifePost(post, commentsByPost, countsByPost, myReactionsByPost));
|
||||
}
|
||||
|
||||
return postById;
|
||||
}
|
||||
|
||||
export async function listUserReactionActivities(
|
||||
userIdValue: number,
|
||||
paramsQuery: QueryParams = {},
|
||||
viewerUserId: number | null = null,
|
||||
locale = defaultLocale
|
||||
): Promise<UserReactionActivityPage | null> {
|
||||
const user = await getPublicProfileUser(userIdValue);
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cursor = decodeLifePostCursor(paramsQuery.cursor);
|
||||
const limit = cleanLifePostLimit(paramsQuery.limit);
|
||||
const params: unknown[] = [user.id];
|
||||
const conditions = ['lpr.user_id = $1', 'lp.deleted_at IS NULL'];
|
||||
|
||||
if (cursor) {
|
||||
params.push(cursor.createdAt, cursor.id);
|
||||
conditions.push(`(lpr.updated_at, lpr.post_id) < ($${params.length - 1}::timestamptz, $${params.length}::integer)`);
|
||||
}
|
||||
|
||||
params.push(limit + 1);
|
||||
const rows = await query<{
|
||||
postId: number;
|
||||
reactionType: LifeReactionType;
|
||||
reactedAt: Date;
|
||||
reactedAtCursor: string;
|
||||
}>(
|
||||
`
|
||||
SELECT
|
||||
lpr.post_id AS "postId",
|
||||
lpr.reaction_type AS "reactionType",
|
||||
lpr.updated_at AS "reactedAt",
|
||||
lpr.updated_at::text AS "reactedAtCursor"
|
||||
FROM life_post_reactions lpr
|
||||
JOIN life_posts lp ON lp.id = lpr.post_id
|
||||
WHERE ${conditions.join(' AND ')}
|
||||
ORDER BY lpr.updated_at DESC, lpr.post_id DESC
|
||||
LIMIT $${params.length}
|
||||
`,
|
||||
params
|
||||
);
|
||||
const hasMore = rows.length > limit;
|
||||
const activities = hasMore ? rows.slice(0, limit) : rows;
|
||||
const postById = await hydrateLifePostsById(
|
||||
activities.map((activity) => activity.postId),
|
||||
viewerUserId,
|
||||
locale
|
||||
);
|
||||
|
||||
return {
|
||||
items: activities.flatMap((activity) => {
|
||||
const post = postById.get(activity.postId);
|
||||
return post
|
||||
? [
|
||||
{
|
||||
postId: activity.postId,
|
||||
reactionType: activity.reactionType,
|
||||
reactedAt: activity.reactedAt,
|
||||
post
|
||||
}
|
||||
]
|
||||
: [];
|
||||
}),
|
||||
nextCursor:
|
||||
hasMore && activities.length > 0
|
||||
? encodeProfileCursor({
|
||||
createdAt: activities[activities.length - 1].reactedAtCursor,
|
||||
id: activities[activities.length - 1].postId
|
||||
})
|
||||
: null,
|
||||
hasMore
|
||||
};
|
||||
}
|
||||
|
||||
export async function listUserCommentActivities(
|
||||
userIdValue: number,
|
||||
paramsQuery: QueryParams = {},
|
||||
locale = defaultLocale
|
||||
): Promise<UserCommentActivityPage | null> {
|
||||
const user = await getPublicProfileUser(userIdValue);
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cursor = decodeUserCommentActivityCursor(paramsQuery.cursor);
|
||||
const limit = cleanLifePostLimit(paramsQuery.limit);
|
||||
const pokemonName = localizedName('pokemon', 'p', locale);
|
||||
const itemName = localizedName('items', 'i', locale);
|
||||
const recipeItemName = localizedName('items', 'recipe_item', locale);
|
||||
const habitatName = localizedName('habitats', 'h', locale);
|
||||
const params: unknown[] = [user.id];
|
||||
let cursorClause = '';
|
||||
|
||||
if (cursor) {
|
||||
params.push(cursor.createdAt, cursor.source, cursor.id);
|
||||
cursorClause = `WHERE (created_at, source, id) < ($${params.length - 2}::timestamptz, $${params.length - 1}::text, $${params.length}::integer)`;
|
||||
}
|
||||
|
||||
params.push(limit + 1);
|
||||
const rows = await query<{
|
||||
id: number;
|
||||
source: UserCommentActivitySource;
|
||||
body: string;
|
||||
createdAt: Date;
|
||||
createdAtCursor: string;
|
||||
targetType: 'life-post' | DiscussionEntityType;
|
||||
targetId: number;
|
||||
targetTitle: string;
|
||||
targetExcerpt: string;
|
||||
}>(
|
||||
`
|
||||
WITH activity AS (
|
||||
SELECT
|
||||
'life'::text AS source,
|
||||
lc.id,
|
||||
lc.body,
|
||||
lc.created_at,
|
||||
lc.created_at::text AS cursor_at,
|
||||
'life-post'::text AS target_type,
|
||||
lp.id AS target_id,
|
||||
COALESCE(post_user.display_name, '') AS target_title,
|
||||
lp.body AS target_excerpt
|
||||
FROM life_post_comments lc
|
||||
JOIN life_posts lp ON lp.id = lc.post_id
|
||||
LEFT JOIN users post_user ON post_user.id = lp.created_by_user_id
|
||||
WHERE lc.created_by_user_id = $1
|
||||
AND lc.deleted_at IS NULL
|
||||
AND lp.deleted_at IS NULL
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
'discussion'::text AS source,
|
||||
edc.id,
|
||||
edc.body,
|
||||
edc.created_at,
|
||||
edc.created_at::text AS cursor_at,
|
||||
edc.entity_type AS target_type,
|
||||
edc.entity_id AS target_id,
|
||||
COALESCE(
|
||||
CASE edc.entity_type
|
||||
WHEN 'pokemon' THEN ${pokemonName}
|
||||
WHEN 'items' THEN ${itemName}
|
||||
WHEN 'recipes' THEN ${recipeItemName}
|
||||
WHEN 'habitats' THEN ${habitatName}
|
||||
ELSE ''
|
||||
END,
|
||||
''
|
||||
) AS target_title,
|
||||
''::text AS target_excerpt
|
||||
FROM entity_discussion_comments edc
|
||||
LEFT JOIN pokemon p ON edc.entity_type = 'pokemon' AND p.id = edc.entity_id
|
||||
LEFT JOIN items i ON edc.entity_type = 'items' AND i.id = edc.entity_id
|
||||
LEFT JOIN recipes r ON edc.entity_type = 'recipes' AND r.id = edc.entity_id
|
||||
LEFT JOIN items recipe_item ON recipe_item.id = r.item_id
|
||||
LEFT JOIN habitats h ON edc.entity_type = 'habitats' AND h.id = edc.entity_id
|
||||
WHERE edc.created_by_user_id = $1
|
||||
AND edc.deleted_at IS NULL
|
||||
)
|
||||
SELECT
|
||||
source,
|
||||
id,
|
||||
body,
|
||||
created_at AS "createdAt",
|
||||
cursor_at AS "createdAtCursor",
|
||||
target_type AS "targetType",
|
||||
target_id AS "targetId",
|
||||
target_title AS "targetTitle",
|
||||
target_excerpt AS "targetExcerpt"
|
||||
FROM activity
|
||||
${cursorClause}
|
||||
ORDER BY created_at DESC, source DESC, id DESC
|
||||
LIMIT $${params.length}
|
||||
`,
|
||||
params
|
||||
);
|
||||
const hasMore = rows.length > limit;
|
||||
const activities = hasMore ? rows.slice(0, limit) : rows;
|
||||
|
||||
return {
|
||||
items: activities.map((activity) => ({
|
||||
id: activity.id,
|
||||
source: activity.source,
|
||||
body: activity.body,
|
||||
createdAt: activity.createdAt,
|
||||
target: {
|
||||
type: activity.targetType,
|
||||
id: activity.targetId,
|
||||
title: activity.targetTitle,
|
||||
excerpt: activity.targetExcerpt
|
||||
}
|
||||
})),
|
||||
nextCursor:
|
||||
hasMore && activities.length > 0
|
||||
? encodeUserCommentActivityCursor({
|
||||
createdAt: activities[activities.length - 1].createdAtCursor,
|
||||
id: activities[activities.length - 1].id,
|
||||
source: activities[activities.length - 1].source
|
||||
})
|
||||
: null,
|
||||
hasMore
|
||||
};
|
||||
}
|
||||
|
||||
async function getLifePostById(id: number, userId: number | null = null, locale = defaultLocale): Promise<LifePost | null> {
|
||||
const post = await queryOne<LifePostRow>(
|
||||
`
|
||||
|
||||
@@ -61,6 +61,7 @@ import {
|
||||
getItem,
|
||||
getOptions,
|
||||
getPokemon,
|
||||
getPublicUserProfile,
|
||||
getRecipe,
|
||||
isConfigType,
|
||||
listEntityDiscussionComments,
|
||||
@@ -73,6 +74,9 @@ import {
|
||||
listPokemon,
|
||||
listPokemonFetchOptions,
|
||||
listRecipes,
|
||||
listUserCommentActivities,
|
||||
listUserLifePosts,
|
||||
listUserReactionActivities,
|
||||
reorderConfig,
|
||||
reorderDailyChecklistItems,
|
||||
reorderHabitats,
|
||||
@@ -406,6 +410,46 @@ app.get('/api/options', async (request) => getOptions(requestLocale(request)));
|
||||
|
||||
app.get('/api/daily-checklist', async (request) => listDailyChecklistItems(requestLocale(request)));
|
||||
|
||||
app.get('/api/users/:id/profile', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const profile = await getPublicUserProfile(Number(id));
|
||||
return profile ? { profile } : notFound(reply, request);
|
||||
});
|
||||
|
||||
app.get('/api/users/:id/life-posts', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const user = await optionalUser(request);
|
||||
const posts = await listUserLifePosts(
|
||||
Number(id),
|
||||
request.query as Record<string, string | string[] | undefined>,
|
||||
user?.id ?? null,
|
||||
requestLocale(request)
|
||||
);
|
||||
return posts ? posts : notFound(reply, request);
|
||||
});
|
||||
|
||||
app.get('/api/users/:id/reactions', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const user = await optionalUser(request);
|
||||
const reactions = await listUserReactionActivities(
|
||||
Number(id),
|
||||
request.query as Record<string, string | string[] | undefined>,
|
||||
user?.id ?? null,
|
||||
requestLocale(request)
|
||||
);
|
||||
return reactions ? reactions : notFound(reply, request);
|
||||
});
|
||||
|
||||
app.get('/api/users/:id/comments', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const comments = await listUserCommentActivities(
|
||||
Number(id),
|
||||
request.query as Record<string, string | string[] | undefined>,
|
||||
requestLocale(request)
|
||||
);
|
||||
return comments ? comments : notFound(reply, request);
|
||||
});
|
||||
|
||||
app.get('/api/life-posts', async (request) => {
|
||||
const user = await optionalUser(request);
|
||||
return listLifePosts(request.query as Record<string, string | string[] | undefined>, user?.id ?? null, requestLocale(request));
|
||||
|
||||
@@ -120,14 +120,20 @@ function formatDateTime(value: string): string {
|
||||
<div>
|
||||
<dt>{{ t('history.createdBy') }}</dt>
|
||||
<dd>
|
||||
<strong>{{ displayName(entity.createdBy) }}</strong>
|
||||
<RouterLink v-if="entity.createdBy" class="user-profile-link" :to="`/profile/${entity.createdBy.id}`">
|
||||
{{ entity.createdBy.displayName }}
|
||||
</RouterLink>
|
||||
<strong v-else>{{ displayName(entity.createdBy) }}</strong>
|
||||
<time :datetime="entity.createdAt">{{ formatDateTime(entity.createdAt) }}</time>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ t('history.lastEdited') }}</dt>
|
||||
<dd>
|
||||
<strong>{{ displayName(entity.updatedBy) }}</strong>
|
||||
<RouterLink v-if="entity.updatedBy" class="user-profile-link" :to="`/profile/${entity.updatedBy.id}`">
|
||||
{{ entity.updatedBy.displayName }}
|
||||
</RouterLink>
|
||||
<strong v-else>{{ displayName(entity.updatedBy) }}</strong>
|
||||
<time :datetime="entity.updatedAt">{{ formatDateTime(entity.updatedAt) }}</time>
|
||||
</dd>
|
||||
</div>
|
||||
@@ -160,7 +166,12 @@ function formatDateTime(value: string): string {
|
||||
<dl class="edit-history-detail-meta">
|
||||
<div>
|
||||
<dt>{{ t('history.author') }}</dt>
|
||||
<dd>{{ displayName(entry.user) }}</dd>
|
||||
<dd>
|
||||
<RouterLink v-if="entry.user" class="user-profile-link" :to="`/profile/${entry.user.id}`">
|
||||
{{ entry.user.displayName }}
|
||||
</RouterLink>
|
||||
<span v-else>{{ displayName(entry.user) }}</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ t('history.time') }}</dt>
|
||||
|
||||
@@ -18,6 +18,11 @@ function formatDateTime(value: string): string {
|
||||
|
||||
<template>
|
||||
<p class="edit-meta">
|
||||
{{ t('history.lastEdited') }}: {{ entity.updatedBy?.displayName ?? t('common.system') }} / {{ formatDateTime(entity.updatedAt) }}
|
||||
{{ t('history.lastEdited') }}:
|
||||
<RouterLink v-if="entity.updatedBy" class="user-profile-link" :to="`/profile/${entity.updatedBy.id}`">
|
||||
{{ entity.updatedBy.displayName }}
|
||||
</RouterLink>
|
||||
<span v-else>{{ t('common.system') }}</span>
|
||||
/ {{ formatDateTime(entity.updatedAt) }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
@@ -318,7 +318,10 @@ onUnmounted(() => {
|
||||
<div class="entity-discussion-comment__avatar" aria-hidden="true">{{ commentInitial(comment) }}</div>
|
||||
<div class="entity-discussion-comment__content">
|
||||
<div class="entity-discussion-comment__meta">
|
||||
<strong>{{ commentAuthorName(comment) }}</strong>
|
||||
<RouterLink v-if="!comment.deleted && comment.author" class="user-profile-link" :to="`/profile/${comment.author.id}`">
|
||||
{{ comment.author.displayName }}
|
||||
</RouterLink>
|
||||
<strong v-else>{{ commentAuthorName(comment) }}</strong>
|
||||
<time :datetime="comment.createdAt">{{ formatDateTime(comment.createdAt) }}</time>
|
||||
</div>
|
||||
<p v-if="!comment.deleted" class="entity-discussion-comment__body">{{ comment.body }}</p>
|
||||
@@ -390,7 +393,10 @@ onUnmounted(() => {
|
||||
<div class="entity-discussion-comment__avatar" aria-hidden="true">{{ commentInitial(reply) }}</div>
|
||||
<div class="entity-discussion-comment__content">
|
||||
<div class="entity-discussion-comment__meta">
|
||||
<strong>{{ commentAuthorName(reply) }}</strong>
|
||||
<RouterLink v-if="!reply.deleted && reply.author" class="user-profile-link" :to="`/profile/${reply.author.id}`">
|
||||
{{ reply.author.displayName }}
|
||||
</RouterLink>
|
||||
<strong v-else>{{ commentAuthorName(reply) }}</strong>
|
||||
<time :datetime="reply.createdAt">{{ formatDateTime(reply.createdAt) }}</time>
|
||||
</div>
|
||||
<p v-if="!reply.deleted" class="entity-discussion-comment__body">{{ reply.body }}</p>
|
||||
|
||||
@@ -49,6 +49,7 @@ export const router = createRouter({
|
||||
{ path: '/life', component: LifeView },
|
||||
{ path: '/admin', component: AdminView, meta: { requiredPermission: 'admin.access' } },
|
||||
{ path: '/profile', component: UserProfileView, meta: { requiresAuth: true } },
|
||||
{ path: '/profile/:id', component: UserProfileView },
|
||||
{ path: '/login', component: LoginView },
|
||||
{ path: '/forgot-password', component: ForgotPasswordView },
|
||||
{ path: '/reset-password', component: ResetPasswordView },
|
||||
|
||||
@@ -324,6 +324,55 @@ export interface ReferralSummary {
|
||||
verifiedReferralCount: number;
|
||||
}
|
||||
|
||||
export interface PublicProfileUser extends UserSummary {
|
||||
joinedAt: string;
|
||||
}
|
||||
|
||||
export interface PublicProfileStats {
|
||||
wikiEdits: number;
|
||||
wikiCreates: number;
|
||||
wikiUpdates: number;
|
||||
wikiDeletes: number;
|
||||
imageUploads: number;
|
||||
lifePosts: number;
|
||||
lifeComments: number;
|
||||
lifeReactions: number;
|
||||
discussionComments: number;
|
||||
}
|
||||
|
||||
export interface PublicProfileContribution {
|
||||
contentType: string;
|
||||
total: number;
|
||||
creates: number;
|
||||
updates: number;
|
||||
deletes: number;
|
||||
lastContributedAt: string | null;
|
||||
}
|
||||
|
||||
export interface PublicUserProfile {
|
||||
user: PublicProfileUser;
|
||||
stats: PublicProfileStats;
|
||||
contributions: PublicProfileContribution[];
|
||||
}
|
||||
|
||||
export interface ProfileActivityParams {
|
||||
cursor?: string | null;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface UserReactionActivity {
|
||||
postId: number;
|
||||
reactionType: LifeReactionType;
|
||||
reactedAt: string;
|
||||
post: LifePost;
|
||||
}
|
||||
|
||||
export interface UserReactionActivityPage {
|
||||
items: UserReactionActivity[];
|
||||
nextCursor: string | null;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
export interface RoleSummary {
|
||||
id: number;
|
||||
key: string;
|
||||
@@ -508,6 +557,25 @@ export interface EntityDiscussionComment {
|
||||
replies: EntityDiscussionComment[];
|
||||
}
|
||||
|
||||
export interface UserCommentActivity {
|
||||
id: number;
|
||||
source: 'life' | 'discussion';
|
||||
body: string;
|
||||
createdAt: string;
|
||||
target: {
|
||||
type: 'life-post' | DiscussionEntityType;
|
||||
id: number;
|
||||
title: string;
|
||||
excerpt: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface UserCommentActivityPage {
|
||||
items: UserCommentActivity[];
|
||||
nextCursor: string | null;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
export interface EntityDiscussionCommentPayload {
|
||||
body: string;
|
||||
}
|
||||
@@ -694,6 +762,28 @@ export const api = {
|
||||
updateMe: (payload: UserProfilePayload) => sendJson<{ user: AuthUser }>('/api/auth/me', 'PATCH', payload),
|
||||
referral: () => getJson<{ referral: ReferralSummary }>('/api/auth/referral'),
|
||||
logout: () => postEmpty('/api/auth/logout'),
|
||||
publicProfile: (id: string | number) => getJson<{ profile: PublicUserProfile }>(`/api/users/${id}/profile`),
|
||||
userLifePosts: (id: string | number, params: ProfileActivityParams = {}) =>
|
||||
getJson<LifePostsPage>(
|
||||
`/api/users/${id}/life-posts${buildQuery({
|
||||
cursor: params.cursor ?? undefined,
|
||||
limit: params.limit
|
||||
})}`
|
||||
),
|
||||
userReactions: (id: string | number, params: ProfileActivityParams = {}) =>
|
||||
getJson<UserReactionActivityPage>(
|
||||
`/api/users/${id}/reactions${buildQuery({
|
||||
cursor: params.cursor ?? undefined,
|
||||
limit: params.limit
|
||||
})}`
|
||||
),
|
||||
userComments: (id: string | number, params: ProfileActivityParams = {}) =>
|
||||
getJson<UserCommentActivityPage>(
|
||||
`/api/users/${id}/comments${buildQuery({
|
||||
cursor: params.cursor ?? undefined,
|
||||
limit: params.limit
|
||||
})}`
|
||||
),
|
||||
adminUsers: () => getJson<AdminUser[]>('/api/admin/users'),
|
||||
updateAdminUserRoles: (id: string | number, roleIds: number[]) =>
|
||||
sendJson<AdminUser[]>(`/api/admin/users/${id}/roles`, 'PUT', { roleIds }),
|
||||
|
||||
@@ -1631,6 +1631,11 @@ button:disabled,
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.edit-meta .user-profile-link {
|
||||
color: var(--ink-soft);
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.checklist-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
@@ -1869,6 +1874,14 @@ button:disabled,
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.life-post__byline .user-profile-link {
|
||||
overflow: hidden;
|
||||
color: var(--ink);
|
||||
font-weight: 950;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.life-post__byline span {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
@@ -2277,6 +2290,11 @@ button:disabled,
|
||||
font-weight: 950;
|
||||
}
|
||||
|
||||
.life-comment__meta .user-profile-link {
|
||||
color: var(--ink);
|
||||
font-weight: 950;
|
||||
}
|
||||
|
||||
.life-comment.is-deleted .life-comment__meta strong {
|
||||
color: var(--muted);
|
||||
font-style: italic;
|
||||
@@ -3090,6 +3108,11 @@ button:disabled,
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.edit-history-summary .user-profile-link {
|
||||
color: var(--ink);
|
||||
font-weight: 950;
|
||||
}
|
||||
|
||||
.edit-history-summary time,
|
||||
.edit-timeline time {
|
||||
color: var(--muted);
|
||||
@@ -3275,6 +3298,11 @@ button:disabled,
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.edit-history-detail-meta .user-profile-link {
|
||||
color: var(--ink-soft);
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.entity-discussion-panel {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
@@ -3423,6 +3451,12 @@ button:disabled,
|
||||
font-weight: 950;
|
||||
}
|
||||
|
||||
.entity-discussion-comment__meta .user-profile-link {
|
||||
color: var(--ink);
|
||||
font-size: 14px;
|
||||
font-weight: 950;
|
||||
}
|
||||
|
||||
.entity-discussion-comment.is-deleted .entity-discussion-comment__meta strong {
|
||||
color: var(--muted);
|
||||
}
|
||||
@@ -4461,6 +4495,270 @@ button:disabled,
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.profile-public-layout,
|
||||
.profile-tab-panel,
|
||||
.profile-activity-list {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.profile-layout--loading {
|
||||
grid-template-columns: minmax(260px, 0.5fr) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.profile-card--wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.profile-card--soft {
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.profile-hero {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.profile-hero .profile-identity {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.profile-stat-strip,
|
||||
.profile-stat-grid {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.profile-stat-strip {
|
||||
grid-column: 1 / -1;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.profile-stat-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(132px, 1fr));
|
||||
}
|
||||
|
||||
.profile-stat-strip div,
|
||||
.profile-stat-grid div {
|
||||
min-width: 0;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-card);
|
||||
background: var(--surface-soft);
|
||||
}
|
||||
|
||||
.profile-stat-strip dt,
|
||||
.profile-stat-grid dt {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.profile-stat-strip dd,
|
||||
.profile-stat-grid dd {
|
||||
margin: 4px 0 0;
|
||||
color: var(--pokemon-blue-deep);
|
||||
font-family: var(--font-display);
|
||||
font-size: 30px;
|
||||
font-weight: 950;
|
||||
line-height: 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.profile-section-grid,
|
||||
.profile-account-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.profile-feed-card__metrics {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--line);
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.profile-feed-card__metrics span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.profile-feed-card__metrics .ui-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--pokemon-blue);
|
||||
}
|
||||
|
||||
.profile-load-more {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.profile-empty {
|
||||
min-height: 220px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: 12px;
|
||||
padding: 26px;
|
||||
border: 1px dashed var(--line);
|
||||
border-radius: var(--radius-card);
|
||||
background: var(--surface-soft);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.profile-empty--compact {
|
||||
min-height: 150px;
|
||||
}
|
||||
|
||||
.profile-empty h2 {
|
||||
margin: 0;
|
||||
color: var(--ink-soft);
|
||||
font-family: var(--font-display);
|
||||
font-size: 22px;
|
||||
font-weight: 950;
|
||||
}
|
||||
|
||||
.profile-empty__icon {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
color: var(--pokemon-blue);
|
||||
}
|
||||
|
||||
.profile-contribution-list,
|
||||
.profile-contribution-row,
|
||||
.profile-activity-card,
|
||||
.profile-post-preview {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.profile-contribution-row,
|
||||
.profile-activity-card {
|
||||
min-width: 0;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-card);
|
||||
background: var(--surface-soft);
|
||||
}
|
||||
|
||||
.profile-contribution-row > div,
|
||||
.profile-activity-card__header,
|
||||
.profile-post-preview__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.profile-contribution-row strong,
|
||||
.profile-post-preview strong,
|
||||
.profile-post-preview .user-profile-link {
|
||||
color: var(--ink);
|
||||
font-weight: 950;
|
||||
}
|
||||
|
||||
.profile-contribution-row span,
|
||||
.profile-activity-card time,
|
||||
.profile-post-preview span {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.profile-contribution-row dl {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.profile-contribution-row dl div {
|
||||
min-width: 0;
|
||||
padding: 8px;
|
||||
border-radius: var(--radius-small);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.profile-contribution-row dt {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.profile-contribution-row dd {
|
||||
margin: 3px 0 0;
|
||||
color: var(--ink);
|
||||
font-size: 18px;
|
||||
font-weight: 950;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.profile-activity-card__header span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
color: var(--ink-soft);
|
||||
font-weight: 950;
|
||||
}
|
||||
|
||||
.profile-activity-card__header .ui-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: var(--pokemon-blue);
|
||||
}
|
||||
|
||||
.profile-post-preview {
|
||||
padding: 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-card);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.profile-post-preview p,
|
||||
.profile-comment-body,
|
||||
.profile-comment-excerpt {
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
line-height: 1.6;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.profile-comment-target {
|
||||
justify-self: start;
|
||||
color: var(--pokemon-blue-deep);
|
||||
font-weight: 950;
|
||||
}
|
||||
|
||||
.profile-comment-excerpt {
|
||||
padding: 10px 12px;
|
||||
border-left: 3px solid var(--pokemon-yellow);
|
||||
background: var(--surface);
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.user-profile-link,
|
||||
.profile-comment-target {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.user-profile-link:hover,
|
||||
.profile-comment-target:hover {
|
||||
color: var(--pokemon-blue-deep);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.admin-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 280px) minmax(0, 1fr);
|
||||
@@ -4877,11 +5175,19 @@ button:disabled,
|
||||
.pokemon-profile-row,
|
||||
.pokemon-related-grid,
|
||||
.profile-layout,
|
||||
.profile-layout--loading,
|
||||
.profile-section-grid,
|
||||
.profile-account-grid,
|
||||
.system-wording-layout,
|
||||
.admin-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.profile-hero,
|
||||
.profile-stat-strip {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.profile-card--referral {
|
||||
grid-column: auto;
|
||||
}
|
||||
@@ -5028,6 +5334,10 @@ button:disabled,
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.profile-contribution-row dl {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.life-toolbar__actions,
|
||||
.life-toolbar .ui-button {
|
||||
width: 100%;
|
||||
|
||||
@@ -821,7 +821,10 @@ onUnmounted(() => {
|
||||
<header class="life-post__header">
|
||||
<div class="life-post__avatar" aria-hidden="true">{{ authorInitial(post) }}</div>
|
||||
<div class="life-post__byline">
|
||||
<strong>{{ post.author?.displayName ?? t('pages.life.byUnknown') }}</strong>
|
||||
<RouterLink v-if="post.author" class="user-profile-link" :to="`/profile/${post.author.id}`">
|
||||
{{ post.author.displayName }}
|
||||
</RouterLink>
|
||||
<strong v-else>{{ t('pages.life.byUnknown') }}</strong>
|
||||
<span>
|
||||
<time :datetime="post.createdAt">{{ formatPostTime(post.createdAt) }}</time>
|
||||
<template v-if="post.updatedAt !== post.createdAt"> - {{ t('pages.life.edited') }}</template>
|
||||
@@ -1008,7 +1011,10 @@ onUnmounted(() => {
|
||||
<div class="life-comment__avatar" aria-hidden="true">{{ commentInitial(comment) }}</div>
|
||||
<div class="life-comment__content">
|
||||
<div class="life-comment__meta">
|
||||
<strong>{{ commentAuthorName(comment) }}</strong>
|
||||
<RouterLink v-if="!comment.deleted && comment.author" class="user-profile-link" :to="`/profile/${comment.author.id}`">
|
||||
{{ comment.author.displayName }}
|
||||
</RouterLink>
|
||||
<strong v-else>{{ commentAuthorName(comment) }}</strong>
|
||||
<time :datetime="comment.createdAt">{{ formatPostTime(comment.createdAt) }}</time>
|
||||
</div>
|
||||
<p v-if="!comment.deleted" class="life-comment__body">{{ comment.body }}</p>
|
||||
@@ -1080,7 +1086,10 @@ onUnmounted(() => {
|
||||
<div class="life-comment__avatar" aria-hidden="true">{{ commentInitial(reply) }}</div>
|
||||
<div class="life-comment__content">
|
||||
<div class="life-comment__meta">
|
||||
<strong>{{ commentAuthorName(reply) }}</strong>
|
||||
<RouterLink v-if="!reply.deleted && reply.author" class="user-profile-link" :to="`/profile/${reply.author.id}`">
|
||||
{{ reply.author.displayName }}
|
||||
</RouterLink>
|
||||
<strong v-else>{{ commentAuthorName(reply) }}</strong>
|
||||
<time :datetime="reply.createdAt">{{ formatPostTime(reply.createdAt) }}</time>
|
||||
</div>
|
||||
<p v-if="!reply.deleted" class="life-comment__body">{{ reply.body }}</p>
|
||||
|
||||
@@ -1,61 +1,243 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import Skeleton from '../components/Skeleton.vue';
|
||||
import StatusBadge from '../components/StatusBadge.vue';
|
||||
import StatusMessage from '../components/StatusMessage.vue';
|
||||
import { iconCopy, iconProfile, iconReferral, iconSave } from '../icons';
|
||||
import { api, notifyAuthChange, type AuthUser, type ReferralSummary } from '../services/api';
|
||||
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
||||
import {
|
||||
iconComment,
|
||||
iconCopy,
|
||||
iconLife,
|
||||
iconProfile,
|
||||
iconReactionFun,
|
||||
iconReactionHelpful,
|
||||
iconReactionLike,
|
||||
iconReactionThanks,
|
||||
iconReferral,
|
||||
iconSave
|
||||
} from '../icons';
|
||||
import {
|
||||
api,
|
||||
getAuthToken,
|
||||
notifyAuthChange,
|
||||
setAuthToken,
|
||||
type AuthUser,
|
||||
type DiscussionEntityType,
|
||||
type LifePost,
|
||||
type LifeReactionType,
|
||||
type PublicUserProfile,
|
||||
type ReferralSummary,
|
||||
type UserCommentActivity,
|
||||
type UserReactionActivity
|
||||
} from '../services/api';
|
||||
|
||||
const { t } = useI18n();
|
||||
const user = ref<AuthUser | null>(null);
|
||||
type ProfileTab = 'feeds' | 'contributions' | 'reactions' | 'comments' | 'account';
|
||||
|
||||
const { locale, t } = useI18n();
|
||||
const route = useRoute();
|
||||
const currentUser = ref<AuthUser | null>(null);
|
||||
const profile = ref<PublicUserProfile | null>(null);
|
||||
const referral = ref<ReferralSummary | null>(null);
|
||||
const displayName = ref('');
|
||||
const activeTab = ref<ProfileTab>('feeds');
|
||||
const loading = ref(true);
|
||||
const busy = ref(false);
|
||||
const message = ref('');
|
||||
const errorMessage = ref('');
|
||||
const referralMessage = ref('');
|
||||
const referralErrorMessage = ref('');
|
||||
const feeds = ref<LifePost[]>([]);
|
||||
const feedsCursor = ref<string | null>(null);
|
||||
const feedsHasMore = ref(false);
|
||||
const feedsLoading = ref(false);
|
||||
const feedsError = ref('');
|
||||
const reactions = ref<UserReactionActivity[]>([]);
|
||||
const reactionsCursor = ref<string | null>(null);
|
||||
const reactionsHasMore = ref(false);
|
||||
const reactionsLoading = ref(false);
|
||||
const reactionsError = ref('');
|
||||
const comments = ref<UserCommentActivity[]>([]);
|
||||
const commentsCursor = ref<string | null>(null);
|
||||
const commentsHasMore = ref(false);
|
||||
const commentsLoading = ref(false);
|
||||
const commentsError = ref('');
|
||||
const activityLimit = 10;
|
||||
let profileRequestId = 0;
|
||||
|
||||
const routeProfileId = computed(() => {
|
||||
const value = route.params.id;
|
||||
return Array.isArray(value) ? value[0] : value;
|
||||
});
|
||||
const isAccountRoute = computed(() => !routeProfileId.value);
|
||||
const canShowAccount = computed(() => {
|
||||
return isAccountRoute.value && currentUser.value !== null && profile.value?.user.id === currentUser.value.id;
|
||||
});
|
||||
const trimmedDisplayName = computed(() => displayName.value.trim());
|
||||
const hasChanges = computed(() => {
|
||||
const currentUser = user.value;
|
||||
if (!currentUser) return false;
|
||||
return trimmedDisplayName.value !== currentUser.displayName;
|
||||
const user = currentUser.value;
|
||||
if (!user || !canShowAccount.value) return false;
|
||||
return trimmedDisplayName.value !== user.displayName;
|
||||
});
|
||||
const profileInitial = computed(() => {
|
||||
const name = user.value?.displayName.trim() || user.value?.email.trim() || '';
|
||||
return name.charAt(0).toUpperCase();
|
||||
const name = profile.value?.user.displayName.trim() || currentUser.value?.displayName.trim() || '';
|
||||
return name.charAt(0).toUpperCase() || '#';
|
||||
});
|
||||
const pageTitle = computed(() => profile.value?.user.displayName ?? t('pages.profile.title'));
|
||||
const pageSubtitle = computed(() => (isAccountRoute.value ? t('pages.profile.subtitle') : t('pages.profile.publicSubtitle')));
|
||||
const tabs = computed<TabOption[]>(() => {
|
||||
const baseTabs: TabOption[] = [
|
||||
{ value: 'feeds', label: t('pages.profile.tabFeeds') },
|
||||
{ value: 'contributions', label: t('pages.profile.tabContributions') },
|
||||
{ value: 'reactions', label: t('pages.profile.tabReactions') },
|
||||
{ value: 'comments', label: t('pages.profile.tabComments') }
|
||||
];
|
||||
|
||||
return canShowAccount.value ? [...baseTabs, { value: 'account', label: t('pages.profile.tabAccount') }] : baseTabs;
|
||||
});
|
||||
const headlineStats = computed(() => {
|
||||
const stats = profile.value?.stats;
|
||||
return [
|
||||
{ label: t('pages.profile.lifePosts'), value: stats?.lifePosts ?? 0 },
|
||||
{ label: t('pages.profile.wikiEdits'), value: stats?.wikiEdits ?? 0 },
|
||||
{ label: t('pages.profile.lifeReactions'), value: stats?.lifeReactions ?? 0 },
|
||||
{ label: t('pages.profile.commentsMade'), value: (stats?.lifeComments ?? 0) + (stats?.discussionComments ?? 0) }
|
||||
];
|
||||
});
|
||||
const wikiStats = computed(() => {
|
||||
const stats = profile.value?.stats;
|
||||
return [
|
||||
{ label: t('pages.profile.wikiEdits'), value: stats?.wikiEdits ?? 0 },
|
||||
{ label: t('pages.profile.wikiCreates'), value: stats?.wikiCreates ?? 0 },
|
||||
{ label: t('pages.profile.wikiUpdates'), value: stats?.wikiUpdates ?? 0 },
|
||||
{ label: t('pages.profile.wikiDeletes'), value: stats?.wikiDeletes ?? 0 },
|
||||
{ label: t('pages.profile.imageUploads'), value: stats?.imageUploads ?? 0 }
|
||||
];
|
||||
});
|
||||
const communityStats = computed(() => {
|
||||
const stats = profile.value?.stats;
|
||||
return [
|
||||
{ label: t('pages.profile.lifePosts'), value: stats?.lifePosts ?? 0 },
|
||||
{ label: t('pages.profile.lifeComments'), value: stats?.lifeComments ?? 0 },
|
||||
{ label: t('pages.profile.lifeReactions'), value: stats?.lifeReactions ?? 0 },
|
||||
{ label: t('pages.profile.discussionComments'), value: stats?.discussionComments ?? 0 }
|
||||
];
|
||||
});
|
||||
|
||||
watch(
|
||||
tabs,
|
||||
(nextTabs) => {
|
||||
if (!nextTabs.some((tab) => tab.value === activeTab.value)) {
|
||||
activeTab.value = 'feeds';
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
() => activeTab.value,
|
||||
() => {
|
||||
void loadActiveTab();
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
() => {
|
||||
void loadProfile();
|
||||
}
|
||||
);
|
||||
|
||||
function resetActivity() {
|
||||
feeds.value = [];
|
||||
feedsCursor.value = null;
|
||||
feedsHasMore.value = false;
|
||||
feedsError.value = '';
|
||||
reactions.value = [];
|
||||
reactionsCursor.value = null;
|
||||
reactionsHasMore.value = false;
|
||||
reactionsError.value = '';
|
||||
comments.value = [];
|
||||
commentsCursor.value = null;
|
||||
commentsHasMore.value = false;
|
||||
commentsError.value = '';
|
||||
}
|
||||
|
||||
async function loadOptionalCurrentUser() {
|
||||
if (!getAuthToken()) {
|
||||
currentUser.value = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await api.me();
|
||||
currentUser.value = response.user;
|
||||
return response.user;
|
||||
} catch {
|
||||
currentUser.value = null;
|
||||
setAuthToken(null);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProfile() {
|
||||
const nextRequestId = ++profileRequestId;
|
||||
loading.value = true;
|
||||
message.value = '';
|
||||
errorMessage.value = '';
|
||||
referralMessage.value = '';
|
||||
referralErrorMessage.value = '';
|
||||
referral.value = null;
|
||||
profile.value = null;
|
||||
resetActivity();
|
||||
|
||||
try {
|
||||
let targetId = routeProfileId.value ?? '';
|
||||
|
||||
if (isAccountRoute.value) {
|
||||
const response = await api.me();
|
||||
user.value = response.user;
|
||||
currentUser.value = response.user;
|
||||
targetId = String(response.user.id);
|
||||
displayName.value = response.user.displayName;
|
||||
|
||||
try {
|
||||
const referralResponse = await api.referral();
|
||||
if (nextRequestId === profileRequestId) {
|
||||
referral.value = referralResponse.referral;
|
||||
}
|
||||
} catch {
|
||||
if (nextRequestId === profileRequestId) {
|
||||
referralErrorMessage.value = t('pages.profile.referralLoadFailed');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await loadOptionalCurrentUser();
|
||||
}
|
||||
|
||||
const response = await api.publicProfile(targetId);
|
||||
if (nextRequestId !== profileRequestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
profile.value = response.profile;
|
||||
if (canShowAccount.value) {
|
||||
displayName.value = currentUser.value?.displayName ?? response.profile.user.displayName;
|
||||
}
|
||||
void loadActiveTab(true);
|
||||
} catch (error) {
|
||||
if (nextRequestId === profileRequestId) {
|
||||
profile.value = null;
|
||||
errorMessage.value = error instanceof Error && error.message ? error.message : t('errors.loadFailed');
|
||||
}
|
||||
} finally {
|
||||
if (nextRequestId === profileRequestId) {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function saveProfile() {
|
||||
message.value = '';
|
||||
@@ -69,8 +251,17 @@ async function saveProfile() {
|
||||
busy.value = true;
|
||||
try {
|
||||
const response = await api.updateMe({ displayName: trimmedDisplayName.value });
|
||||
user.value = response.user;
|
||||
currentUser.value = response.user;
|
||||
displayName.value = response.user.displayName;
|
||||
if (profile.value) {
|
||||
profile.value = {
|
||||
...profile.value,
|
||||
user: {
|
||||
...profile.value.user,
|
||||
displayName: response.user.displayName
|
||||
}
|
||||
};
|
||||
}
|
||||
message.value = t('pages.profile.saved');
|
||||
notifyAuthChange();
|
||||
} catch (error) {
|
||||
@@ -114,6 +305,182 @@ async function copyReferralLink() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadActiveTab(force = false) {
|
||||
if (!profile.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeTab.value === 'feeds' && (force || feeds.value.length === 0)) {
|
||||
await loadFeeds(true);
|
||||
} else if (activeTab.value === 'reactions' && (force || reactions.value.length === 0)) {
|
||||
await loadReactions(true);
|
||||
} else if (activeTab.value === 'comments' && (force || comments.value.length === 0)) {
|
||||
await loadComments(true);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFeeds(reset = false) {
|
||||
if (!profile.value || feedsLoading.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
feedsLoading.value = true;
|
||||
feedsError.value = '';
|
||||
try {
|
||||
const page = await api.userLifePosts(profile.value.user.id, {
|
||||
cursor: reset ? null : feedsCursor.value,
|
||||
limit: activityLimit
|
||||
});
|
||||
feeds.value = reset ? page.items : [...feeds.value, ...page.items];
|
||||
feedsCursor.value = page.nextCursor;
|
||||
feedsHasMore.value = page.hasMore;
|
||||
} catch (error) {
|
||||
feedsError.value = error instanceof Error && error.message ? error.message : t('errors.loadFailed');
|
||||
} finally {
|
||||
feedsLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadReactions(reset = false) {
|
||||
if (!profile.value || reactionsLoading.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
reactionsLoading.value = true;
|
||||
reactionsError.value = '';
|
||||
try {
|
||||
const page = await api.userReactions(profile.value.user.id, {
|
||||
cursor: reset ? null : reactionsCursor.value,
|
||||
limit: activityLimit
|
||||
});
|
||||
reactions.value = reset ? page.items : [...reactions.value, ...page.items];
|
||||
reactionsCursor.value = page.nextCursor;
|
||||
reactionsHasMore.value = page.hasMore;
|
||||
} catch (error) {
|
||||
reactionsError.value = error instanceof Error && error.message ? error.message : t('errors.loadFailed');
|
||||
} finally {
|
||||
reactionsLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadComments(reset = false) {
|
||||
if (!profile.value || commentsLoading.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
commentsLoading.value = true;
|
||||
commentsError.value = '';
|
||||
try {
|
||||
const page = await api.userComments(profile.value.user.id, {
|
||||
cursor: reset ? null : commentsCursor.value,
|
||||
limit: activityLimit
|
||||
});
|
||||
comments.value = reset ? page.items : [...comments.value, ...page.items];
|
||||
commentsCursor.value = page.nextCursor;
|
||||
commentsHasMore.value = page.hasMore;
|
||||
} catch (error) {
|
||||
commentsError.value = error instanceof Error && error.message ? error.message : t('errors.loadFailed');
|
||||
} finally {
|
||||
commentsLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(value: string): string {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat(locale.value, {
|
||||
dateStyle: 'medium'
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function formatDateTime(value: string): string {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat(locale.value, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short'
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function authorInitial(post: LifePost): string {
|
||||
const name = post.author?.displayName.trim() || t('pages.life.byUnknown');
|
||||
return name.slice(0, 1).toUpperCase();
|
||||
}
|
||||
|
||||
function commentTotal(post: LifePost): number {
|
||||
return post.comments.reduce((total, comment) => total + 1 + comment.replies.length, 0);
|
||||
}
|
||||
|
||||
function reactionTotal(post: LifePost): number {
|
||||
return Object.values(post.reactionCounts).reduce((total, count) => total + count, 0);
|
||||
}
|
||||
|
||||
function postExcerpt(post: LifePost): string {
|
||||
return post.body.length > 180 ? `${post.body.slice(0, 180).trim()}...` : post.body;
|
||||
}
|
||||
|
||||
function reactionIcon(type: LifeReactionType) {
|
||||
return {
|
||||
like: iconReactionLike,
|
||||
helpful: iconReactionHelpful,
|
||||
fun: iconReactionFun,
|
||||
thanks: iconReactionThanks
|
||||
}[type];
|
||||
}
|
||||
|
||||
function reactionLabel(type: LifeReactionType): string {
|
||||
return t(`pages.life.reaction${type.charAt(0).toUpperCase()}${type.slice(1)}`);
|
||||
}
|
||||
|
||||
function contentTypeLabel(contentType: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
pokemon: t('nav.pokemon'),
|
||||
items: t('nav.items'),
|
||||
recipes: t('nav.recipes'),
|
||||
habitats: t('nav.habitats'),
|
||||
'daily-checklist': t('nav.checklist'),
|
||||
'pokemon-types': t('config.pokemonTypes'),
|
||||
skills: t('config.skills'),
|
||||
environments: t('config.environments'),
|
||||
'favorite-things': t('config.favoriteThings'),
|
||||
'item-categories': t('config.itemCategories'),
|
||||
'item-usages': t('config.itemUsages'),
|
||||
'acquisition-methods': t('config.acquisitionMethods'),
|
||||
maps: t('config.maps'),
|
||||
'life-tags': t('config.lifeTags')
|
||||
};
|
||||
return labels[contentType] ?? t('pages.profile.otherContributions');
|
||||
}
|
||||
|
||||
function discussionTargetRoute(type: DiscussionEntityType, id: number): string {
|
||||
return {
|
||||
pokemon: `/pokemon/${id}`,
|
||||
items: `/items/${id}`,
|
||||
recipes: `/recipes/${id}`,
|
||||
habitats: `/habitats/${id}`
|
||||
}[type];
|
||||
}
|
||||
|
||||
function commentTargetRoute(comment: UserCommentActivity): string {
|
||||
return comment.target.type === 'life-post' ? '/life' : discussionTargetRoute(comment.target.type, comment.target.id);
|
||||
}
|
||||
|
||||
function commentTargetTitle(comment: UserCommentActivity): string {
|
||||
if (comment.target.type === 'life-post') {
|
||||
return comment.target.title
|
||||
? t('pages.profile.lifePostBy', { name: comment.target.title })
|
||||
: t('pages.profile.lifePostTarget');
|
||||
}
|
||||
|
||||
return comment.target.title || contentTypeLabel(comment.target.type);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadProfile();
|
||||
});
|
||||
@@ -121,11 +488,11 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<section class="profile-page">
|
||||
<PageHeader :title="t('pages.profile.title')" :subtitle="t('pages.profile.subtitle')">
|
||||
<template #kicker>{{ t('auth.accountAccess') }}</template>
|
||||
<PageHeader :title="pageTitle" :subtitle="pageSubtitle">
|
||||
<template #kicker>{{ isAccountRoute ? t('auth.accountAccess') : t('pages.profile.publicKicker') }}</template>
|
||||
</PageHeader>
|
||||
|
||||
<div v-if="loading" class="profile-layout" aria-busy="true" :aria-label="t('pages.profile.loading')">
|
||||
<div v-if="loading" class="profile-layout profile-layout--loading" aria-busy="true" :aria-label="t('pages.profile.loading')">
|
||||
<section class="profile-card profile-card--identity" aria-hidden="true">
|
||||
<div class="profile-identity">
|
||||
<Skeleton variant="box" width="58px" height="58px" />
|
||||
@@ -135,45 +502,263 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="profile-card" aria-hidden="true">
|
||||
<Skeleton width="180px" height="28px" />
|
||||
<div class="auth-form">
|
||||
<div class="field">
|
||||
<Skeleton width="110px" />
|
||||
<Skeleton variant="box" height="44px" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<Skeleton width="70px" />
|
||||
<Skeleton variant="box" height="44px" />
|
||||
</div>
|
||||
<Skeleton variant="box" width="120px" height="42px" />
|
||||
</div>
|
||||
</section>
|
||||
<section class="profile-card profile-card--referral" aria-hidden="true">
|
||||
<Skeleton width="180px" height="28px" />
|
||||
<div class="auth-form">
|
||||
<Skeleton variant="box" height="58px" />
|
||||
<Skeleton variant="box" height="44px" />
|
||||
<Skeleton variant="box" width="120px" height="42px" />
|
||||
<section class="profile-card profile-card--wide" aria-hidden="true">
|
||||
<Skeleton width="210px" height="28px" />
|
||||
<div class="profile-stat-grid">
|
||||
<Skeleton v-for="index in 4" :key="index" variant="box" height="74px" />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div v-else-if="user" class="profile-layout">
|
||||
<section class="profile-card profile-card--identity" :aria-label="t('pages.profile.accountSummary')">
|
||||
<StatusMessage v-else-if="errorMessage" variant="danger" :duration="0">{{ errorMessage }}</StatusMessage>
|
||||
|
||||
<div v-else-if="profile" class="profile-public-layout">
|
||||
<section class="profile-card profile-hero" :aria-label="t('pages.profile.accountSummary')">
|
||||
<div class="profile-identity">
|
||||
<div class="profile-avatar" aria-hidden="true">{{ profileInitial }}</div>
|
||||
<div class="profile-identity__copy">
|
||||
<h2>{{ user.displayName }}</h2>
|
||||
<p>{{ user.email }}</p>
|
||||
<h2>{{ profile.user.displayName }}</h2>
|
||||
<p>{{ t('pages.profile.joinedAt', { date: formatDate(profile.user.joinedAt) }) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StatusBadge
|
||||
:label="user.emailVerified ? t('pages.profile.emailVerified') : t('pages.profile.emailUnverified')"
|
||||
:tone="user.emailVerified ? 'success' : 'warning'"
|
||||
v-if="canShowAccount && currentUser"
|
||||
:label="currentUser.emailVerified ? t('pages.profile.emailVerified') : t('pages.profile.emailUnverified')"
|
||||
:tone="currentUser.emailVerified ? 'success' : 'warning'"
|
||||
/>
|
||||
|
||||
<dl class="profile-stat-strip">
|
||||
<div v-for="item in headlineStats" :key="item.label">
|
||||
<dt>{{ item.label }}</dt>
|
||||
<dd>{{ item.value }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<Tabs id="profile-tabs" v-model="activeTab" :tabs="tabs" :label="t('pages.profile.tabsLabel')" />
|
||||
|
||||
<section v-if="activeTab === 'feeds'" class="profile-tab-panel" :aria-label="t('pages.profile.tabFeeds')">
|
||||
<StatusMessage v-if="feedsError" variant="danger" :duration="0">{{ feedsError }}</StatusMessage>
|
||||
|
||||
<div v-if="feedsLoading && !feeds.length" class="life-feed__list" aria-hidden="true">
|
||||
<article v-for="index in 3" :key="index" class="life-post life-post--skeleton">
|
||||
<div class="life-post__header">
|
||||
<Skeleton variant="box" width="46px" height="46px" />
|
||||
<div class="life-post__byline">
|
||||
<Skeleton width="140px" />
|
||||
<Skeleton width="180px" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton width="90%" />
|
||||
<Skeleton width="68%" />
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div v-else-if="feeds.length" class="life-feed__list">
|
||||
<article v-for="post in feeds" :key="post.id" class="life-post profile-feed-card">
|
||||
<header class="life-post__header">
|
||||
<div class="life-post__avatar" aria-hidden="true">{{ authorInitial(post) }}</div>
|
||||
<div class="life-post__byline">
|
||||
<RouterLink v-if="post.author" class="user-profile-link" :to="`/profile/${post.author.id}`">
|
||||
{{ post.author.displayName }}
|
||||
</RouterLink>
|
||||
<strong v-else>{{ t('pages.life.byUnknown') }}</strong>
|
||||
<span>
|
||||
<time :datetime="post.createdAt">{{ formatDateTime(post.createdAt) }}</time>
|
||||
<template v-if="post.updatedAt !== post.createdAt"> - {{ t('pages.life.edited') }}</template>
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<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="profile-feed-card__metrics">
|
||||
<span>
|
||||
<Icon :icon="iconReactionLike" class="ui-icon" aria-hidden="true" />
|
||||
{{ t('pages.life.reactionsCount', { count: reactionTotal(post) }) }}
|
||||
</span>
|
||||
<span>
|
||||
<Icon :icon="iconComment" class="ui-icon" aria-hidden="true" />
|
||||
{{ t('pages.life.commentsCount', { count: commentTotal(post) }) }}
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div v-if="feedsHasMore" class="profile-load-more">
|
||||
<button class="ui-button ui-button--blue" type="button" :disabled="feedsLoading" @click="loadFeeds(false)">
|
||||
{{ feedsLoading ? t('common.loading') : t('pages.profile.loadMore') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="profile-empty">
|
||||
<Icon :icon="iconLife" class="profile-empty__icon" aria-hidden="true" />
|
||||
<h2>{{ t('pages.profile.feedsEmpty') }}</h2>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-else-if="activeTab === 'contributions'" class="profile-tab-panel" :aria-label="t('pages.profile.tabContributions')">
|
||||
<div class="profile-section-grid">
|
||||
<section class="profile-card profile-card--soft" :aria-label="t('pages.profile.wikiContributionStats')">
|
||||
<div class="profile-card__header">
|
||||
<Icon :icon="iconProfile" class="profile-card__icon" aria-hidden="true" />
|
||||
<h2>{{ t('pages.profile.wikiContributionStats') }}</h2>
|
||||
</div>
|
||||
<dl class="profile-stat-grid">
|
||||
<div v-for="item in wikiStats" :key="item.label">
|
||||
<dt>{{ item.label }}</dt>
|
||||
<dd>{{ item.value }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="profile-card profile-card--soft" :aria-label="t('pages.profile.communityStats')">
|
||||
<div class="profile-card__header">
|
||||
<Icon :icon="iconLife" class="profile-card__icon" aria-hidden="true" />
|
||||
<h2>{{ t('pages.profile.communityStats') }}</h2>
|
||||
</div>
|
||||
<dl class="profile-stat-grid">
|
||||
<div v-for="item in communityStats" :key="item.label">
|
||||
<dt>{{ item.label }}</dt>
|
||||
<dd>{{ item.value }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="profile-card profile-card--wide" :aria-label="t('pages.profile.contributionBreakdown')">
|
||||
<div class="profile-card__header">
|
||||
<Icon :icon="iconProfile" class="profile-card__icon" aria-hidden="true" />
|
||||
<h2>{{ t('pages.profile.contributionBreakdown') }}</h2>
|
||||
</div>
|
||||
|
||||
<div v-if="profile.contributions.length" class="profile-contribution-list">
|
||||
<article v-for="item in profile.contributions" :key="item.contentType" class="profile-contribution-row">
|
||||
<div>
|
||||
<strong>{{ contentTypeLabel(item.contentType) }}</strong>
|
||||
<span v-if="item.lastContributedAt">{{ formatDateTime(item.lastContributedAt) }}</span>
|
||||
</div>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>{{ t('pages.profile.total') }}</dt>
|
||||
<dd>{{ item.total }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ t('pages.profile.wikiCreates') }}</dt>
|
||||
<dd>{{ item.creates }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ t('pages.profile.wikiUpdates') }}</dt>
|
||||
<dd>{{ item.updates }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ t('pages.profile.wikiDeletes') }}</dt>
|
||||
<dd>{{ item.deletes }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div v-else class="profile-empty profile-empty--compact">
|
||||
<Icon :icon="iconProfile" class="profile-empty__icon" aria-hidden="true" />
|
||||
<h2>{{ t('pages.profile.contributionsEmpty') }}</h2>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section v-else-if="activeTab === 'reactions'" class="profile-tab-panel" :aria-label="t('pages.profile.tabReactions')">
|
||||
<StatusMessage v-if="reactionsError" variant="danger" :duration="0">{{ reactionsError }}</StatusMessage>
|
||||
|
||||
<div v-if="reactionsLoading && !reactions.length" class="profile-activity-list" aria-hidden="true">
|
||||
<article v-for="index in 3" :key="index" class="profile-activity-card">
|
||||
<Skeleton width="180px" />
|
||||
<Skeleton width="90%" />
|
||||
<Skeleton width="64%" />
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div v-else-if="reactions.length" class="profile-activity-list">
|
||||
<article v-for="activity in reactions" :key="`${activity.postId}-${activity.reactedAt}`" class="profile-activity-card">
|
||||
<header class="profile-activity-card__header">
|
||||
<span>
|
||||
<Icon :icon="reactionIcon(activity.reactionType)" class="ui-icon" aria-hidden="true" />
|
||||
{{ reactionLabel(activity.reactionType) }}
|
||||
</span>
|
||||
<time :datetime="activity.reactedAt">{{ formatDateTime(activity.reactedAt) }}</time>
|
||||
</header>
|
||||
|
||||
<div class="profile-post-preview">
|
||||
<div class="profile-post-preview__meta">
|
||||
<RouterLink v-if="activity.post.author" class="user-profile-link" :to="`/profile/${activity.post.author.id}`">
|
||||
{{ activity.post.author.displayName }}
|
||||
</RouterLink>
|
||||
<strong v-else>{{ t('pages.life.byUnknown') }}</strong>
|
||||
<span>{{ formatDateTime(activity.post.createdAt) }}</span>
|
||||
</div>
|
||||
<p>{{ postExcerpt(activity.post) }}</p>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div v-if="reactionsHasMore" class="profile-load-more">
|
||||
<button class="ui-button ui-button--blue" type="button" :disabled="reactionsLoading" @click="loadReactions(false)">
|
||||
{{ reactionsLoading ? t('common.loading') : t('pages.profile.loadMore') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="profile-empty">
|
||||
<Icon :icon="iconReactionLike" class="profile-empty__icon" aria-hidden="true" />
|
||||
<h2>{{ t('pages.profile.reactionsEmpty') }}</h2>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-else-if="activeTab === 'comments'" class="profile-tab-panel" :aria-label="t('pages.profile.tabComments')">
|
||||
<StatusMessage v-if="commentsError" variant="danger" :duration="0">{{ commentsError }}</StatusMessage>
|
||||
|
||||
<div v-if="commentsLoading && !comments.length" class="profile-activity-list" aria-hidden="true">
|
||||
<article v-for="index in 3" :key="index" class="profile-activity-card">
|
||||
<Skeleton width="180px" />
|
||||
<Skeleton width="94%" />
|
||||
<Skeleton width="60%" />
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div v-else-if="comments.length" class="profile-activity-list">
|
||||
<article v-for="comment in comments" :key="`${comment.source}-${comment.id}`" class="profile-activity-card">
|
||||
<header class="profile-activity-card__header">
|
||||
<span>
|
||||
<Icon :icon="iconComment" class="ui-icon" aria-hidden="true" />
|
||||
{{ comment.source === 'life' ? t('pages.profile.lifeComment') : t('pages.profile.discussionComment') }}
|
||||
</span>
|
||||
<time :datetime="comment.createdAt">{{ formatDateTime(comment.createdAt) }}</time>
|
||||
</header>
|
||||
|
||||
<p class="profile-comment-body">{{ comment.body }}</p>
|
||||
<RouterLink class="profile-comment-target" :to="commentTargetRoute(comment)">
|
||||
{{ commentTargetTitle(comment) }}
|
||||
</RouterLink>
|
||||
<p v-if="comment.target.excerpt" class="profile-comment-excerpt">{{ comment.target.excerpt }}</p>
|
||||
</article>
|
||||
|
||||
<div v-if="commentsHasMore" class="profile-load-more">
|
||||
<button class="ui-button ui-button--blue" type="button" :disabled="commentsLoading" @click="loadComments(false)">
|
||||
{{ commentsLoading ? t('common.loading') : t('pages.profile.loadMore') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="profile-empty">
|
||||
<Icon :icon="iconComment" class="profile-empty__icon" aria-hidden="true" />
|
||||
<h2>{{ t('pages.profile.commentsEmpty') }}</h2>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-else-if="activeTab === 'account' && canShowAccount && currentUser" class="profile-tab-panel profile-account-grid">
|
||||
<section class="profile-card" :aria-label="t('pages.profile.profileDetails')">
|
||||
<div class="profile-card__header">
|
||||
<Icon :icon="iconProfile" class="profile-card__icon" aria-hidden="true" />
|
||||
@@ -196,7 +781,7 @@ onMounted(() => {
|
||||
|
||||
<div class="field">
|
||||
<label for="profile-email">{{ t('auth.email') }}</label>
|
||||
<input id="profile-email" class="profile-readonly-input" :value="user.email" readonly type="email" />
|
||||
<input id="profile-email" class="profile-readonly-input" :value="currentUser.email" readonly type="email" />
|
||||
</div>
|
||||
|
||||
<StatusMessage v-if="message" variant="success">{{ message }}</StatusMessage>
|
||||
@@ -244,8 +829,7 @@ onMounted(() => {
|
||||
|
||||
<StatusMessage v-else-if="referralErrorMessage" variant="danger" :duration="0">{{ referralErrorMessage }}</StatusMessage>
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<StatusMessage v-else-if="errorMessage" variant="danger" :duration="0">{{ errorMessage }}</StatusMessage>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -135,7 +135,40 @@ export const systemWordingMessages = {
|
||||
copyReferralLink: 'Copy link',
|
||||
referralCopied: 'Referral link copied',
|
||||
referralCopyFailed: 'Referral link copy failed',
|
||||
referralLoadFailed: 'Referral details failed to load'
|
||||
referralLoadFailed: 'Referral details failed to load',
|
||||
publicSubtitle: 'Review this member\'s Life posts, reactions, comments, and Wiki contributions.',
|
||||
publicKicker: 'Member Profile',
|
||||
tabsLabel: 'Profile sections',
|
||||
tabFeeds: 'Feeds',
|
||||
tabContributions: 'Contributions',
|
||||
tabReactions: 'Reactions',
|
||||
tabComments: 'Comments',
|
||||
tabAccount: 'Account',
|
||||
joinedAt: 'Joined {date}',
|
||||
lifePosts: 'Life posts',
|
||||
lifeComments: 'Life comments',
|
||||
lifeReactions: 'Reactions',
|
||||
discussionComments: 'Wiki comments',
|
||||
commentsMade: 'Comments',
|
||||
wikiEdits: 'Wiki edits',
|
||||
wikiCreates: 'Creates',
|
||||
wikiUpdates: 'Updates',
|
||||
wikiDeletes: 'Deletes',
|
||||
imageUploads: 'Images',
|
||||
wikiContributionStats: 'Wiki contribution stats',
|
||||
communityStats: 'Community stats',
|
||||
contributionBreakdown: 'Contribution breakdown',
|
||||
total: 'Total',
|
||||
otherContributions: 'Other',
|
||||
feedsEmpty: 'No Life posts yet',
|
||||
contributionsEmpty: 'No Wiki contributions yet',
|
||||
reactionsEmpty: 'No reactions yet',
|
||||
commentsEmpty: 'No comments yet',
|
||||
loadMore: 'Load more',
|
||||
lifeComment: 'Life comment',
|
||||
discussionComment: 'Wiki discussion',
|
||||
lifePostTarget: 'Life post',
|
||||
lifePostBy: 'Life post by {name}'
|
||||
},
|
||||
pokemon: {
|
||||
title: 'Pokemon',
|
||||
@@ -857,7 +890,40 @@ export const systemWordingMessages = {
|
||||
copyReferralLink: '复制链接',
|
||||
referralCopied: '邀请链接已复制',
|
||||
referralCopyFailed: '邀请链接复制失败',
|
||||
referralLoadFailed: '邀请信息加载失败'
|
||||
referralLoadFailed: '邀请信息加载失败',
|
||||
publicSubtitle: '查看该成员的 Life 动态、互动、评论和 Wiki 贡献。',
|
||||
publicKicker: '成员主页',
|
||||
tabsLabel: '个人主页分区',
|
||||
tabFeeds: 'Feeds',
|
||||
tabContributions: '贡献',
|
||||
tabReactions: '互动',
|
||||
tabComments: '评论',
|
||||
tabAccount: '账号',
|
||||
joinedAt: '加入于 {date}',
|
||||
lifePosts: 'Life 动态',
|
||||
lifeComments: 'Life 评论',
|
||||
lifeReactions: '互动',
|
||||
discussionComments: 'Wiki 评论',
|
||||
commentsMade: '评论',
|
||||
wikiEdits: 'Wiki 编辑',
|
||||
wikiCreates: '新增',
|
||||
wikiUpdates: '更新',
|
||||
wikiDeletes: '删除',
|
||||
imageUploads: '图片',
|
||||
wikiContributionStats: 'Wiki 贡献统计',
|
||||
communityStats: '社区统计',
|
||||
contributionBreakdown: '贡献分布',
|
||||
total: '总计',
|
||||
otherContributions: '其他',
|
||||
feedsEmpty: '暂无 Life 动态',
|
||||
contributionsEmpty: '暂无 Wiki 贡献',
|
||||
reactionsEmpty: '暂无互动',
|
||||
commentsEmpty: '暂无评论',
|
||||
loadMore: '加载更多',
|
||||
lifeComment: 'Life 评论',
|
||||
discussionComment: 'Wiki 讨论',
|
||||
lifePostTarget: 'Life 动态',
|
||||
lifePostBy: '{name} 的 Life 动态'
|
||||
},
|
||||
pokemon: {
|
||||
title: 'Pokemon',
|
||||
|
||||
Reference in New Issue
Block a user