diff --git a/DESIGN.md b/DESIGN.md index 5035e0a..68d2c5e 100644 --- a/DESIGN.md +++ b/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: diff --git a/backend/db/schema.sql b/backend/db/schema.sql index c3859aa..821fc90 100644 --- a/backend/db/schema.sql +++ b/backend/db/schema.sql @@ -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, diff --git a/backend/src/queries.ts b/backend/src/queries.ts index 7f07f79..b70a0ae 100644 --- a/backend/src/queries.ts +++ b/backend/src/queries.ts @@ -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; + 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, @@ -2354,10 +2468,11 @@ async function getLifeCommentById(id: number): Promise { 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 { 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 { + return listLifePostsWithFilters(paramsQuery, userId, locale); +} + +async function getPublicProfileUser(userIdValue: number): Promise { + const userId = requirePositiveInteger(userIdValue, 'server.validation.recordInvalid'); + return queryOne( + ` + 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 { + const user = await getPublicProfileUser(userIdValue); + if (!user) { + return null; + } + + const stats = await queryOne( + ` + 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( + ` + 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 { + 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> { + const postById = new Map(); + if (postIds.length === 0) { + return postById; + } + + const posts = await query( + ` + ${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 { + 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 { + 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 { const post = await queryOne( ` diff --git a/backend/src/server.ts b/backend/src/server.ts index 909c1de..28c9f05 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -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, + 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, + 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, + 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, user?.id ?? null, requestLocale(request)); diff --git a/frontend/src/components/EditHistoryPanel.vue b/frontend/src/components/EditHistoryPanel.vue index 3238175..539aa4a 100644 --- a/frontend/src/components/EditHistoryPanel.vue +++ b/frontend/src/components/EditHistoryPanel.vue @@ -120,14 +120,20 @@ function formatDateTime(value: string): string {
{{ t('history.createdBy') }}
- {{ displayName(entity.createdBy) }} + + {{ displayName(entity.createdBy) }}
{{ t('history.lastEdited') }}
- {{ displayName(entity.updatedBy) }} + + {{ displayName(entity.updatedBy) }}
@@ -160,7 +166,12 @@ function formatDateTime(value: string): string {
{{ t('history.author') }}
-
{{ displayName(entry.user) }}
+
+ + {{ displayName(entry.user) }} +
{{ t('history.time') }}
diff --git a/frontend/src/components/EditMeta.vue b/frontend/src/components/EditMeta.vue index 009e249..346cb7a 100644 --- a/frontend/src/components/EditMeta.vue +++ b/frontend/src/components/EditMeta.vue @@ -18,6 +18,11 @@ function formatDateTime(value: string): string { diff --git a/frontend/src/components/EntityDiscussionPanel.vue b/frontend/src/components/EntityDiscussionPanel.vue index 7a6b723..fdbf1c1 100644 --- a/frontend/src/components/EntityDiscussionPanel.vue +++ b/frontend/src/components/EntityDiscussionPanel.vue @@ -318,7 +318,10 @@ onUnmounted(() => {
- {{ commentAuthorName(comment) }} + + {{ commentAuthorName(comment) }}

{{ comment.body }}

@@ -390,7 +393,10 @@ onUnmounted(() => {
- {{ commentAuthorName(reply) }} + + {{ commentAuthorName(reply) }}

{{ reply.body }}

diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 10dd083..c638af5 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -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 }, diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 05fe20b..50cb4fc 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -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( + `/api/users/${id}/life-posts${buildQuery({ + cursor: params.cursor ?? undefined, + limit: params.limit + })}` + ), + userReactions: (id: string | number, params: ProfileActivityParams = {}) => + getJson( + `/api/users/${id}/reactions${buildQuery({ + cursor: params.cursor ?? undefined, + limit: params.limit + })}` + ), + userComments: (id: string | number, params: ProfileActivityParams = {}) => + getJson( + `/api/users/${id}/comments${buildQuery({ + cursor: params.cursor ?? undefined, + limit: params.limit + })}` + ), adminUsers: () => getJson('/api/admin/users'), updateAdminUserRoles: (id: string | number, roleIds: number[]) => sendJson(`/api/admin/users/${id}/roles`, 'PUT', { roleIds }), diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css index 7f8ee1c..8963e86 100644 --- a/frontend/src/styles/main.css +++ b/frontend/src/styles/main.css @@ -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%; diff --git a/frontend/src/views/LifeView.vue b/frontend/src/views/LifeView.vue index 88570cf..aed404d 100644 --- a/frontend/src/views/LifeView.vue +++ b/frontend/src/views/LifeView.vue @@ -821,7 +821,10 @@ onUnmounted(() => {