feat(profile): add password change and activity filters
Implement password change API and UI in the Account tab Add secondary filters for contributions, reactions, and comments Display referral summary in the profile header
This commit is contained in:
@@ -120,8 +120,12 @@
|
|||||||
- 任意用户可通过 `/profile/:id` 访问其他用户的公开 Profile。
|
- 任意用户可通过 `/profile/:id` 访问其他用户的公开 Profile。
|
||||||
- 公开 Profile 展示用户公开摘要、Life Feeds、Wiki 贡献统计、Like / Reaction 过的 Life Post 和评论过的内容。
|
- 公开 Profile 展示用户公开摘要、Life Feeds、Wiki 贡献统计、Like / Reaction 过的 Life Post 和评论过的内容。
|
||||||
- Profile 使用 Tabs 组织:Feeds、Contributions、Reactions、Comments;仅自己的 `/profile` 额外展示 Account。
|
- Profile 使用 Tabs 组织:Feeds、Contributions、Reactions、Comments;仅自己的 `/profile` 额外展示 Account。
|
||||||
|
- Contributions、Reactions、Comments 在对应 Tab 内提供二级分类:Contributions 可按主要内容类型或配置类查看,Reactions 可按 reaction 类型查看,Comments 可按 Life / Wiki discussion 来源查看。
|
||||||
- 公开用户摘要只包含 `id`、`displayName` 和公开展示需要的加入时间;不公开邮箱、角色、权限、Referral Code、邀请链接、session、token/hash 或内部审计 payload。
|
- 公开用户摘要只包含 `id`、`displayName` 和公开展示需要的加入时间;不公开邮箱、角色、权限、Referral Code、邀请链接、session、token/hash 或内部审计 payload。
|
||||||
- 当前版本只允许用户在自己的 `/profile` Account Tab 更新 `displayName`,不支持头像、邮箱修改或直接密码修改。
|
- 当前用户可在自己的 `/profile` Account Tab 更新 `displayName`、查看 Referral 信息、复制 Referral 邀请链接,并修改密码;当前版本不支持头像或邮箱修改。
|
||||||
|
- 当前用户自己的 Profile 顶部摘要区可显示简化 Referral Code 和 Copy Link 入口;完整 Referral 卡片保留 Referral Code、邀请链接复制入口和有效邀请数量;这些字段不在公开 Profile 展示。
|
||||||
|
- 修改密码必须提交当前密码和新密码;成功后更新 password hash、作废未使用的密码重置 token,并保留当前 session、删除该用户其他 session。
|
||||||
|
- 修改密码 API 只返回本地化结果 message,不返回 user、session、token/hash 或内部审计 payload。
|
||||||
- 更新显示名后,API 仍只返回当前用户必要字段,不返回 session、token/hash、内部审计或调试数据。
|
- 更新显示名后,API 仍只返回当前用户必要字段,不返回 session、token/hash、内部审计或调试数据。
|
||||||
- 显示名用于编辑署名、讨论和 Life 内容作者展示。
|
- 显示名用于编辑署名、讨论和 Life 内容作者展示。
|
||||||
|
|
||||||
@@ -178,7 +182,7 @@
|
|||||||
- 全局唯一。
|
- 全局唯一。
|
||||||
- 只包含大写英文字母和数字。
|
- 只包含大写英文字母和数字。
|
||||||
- 现有用户在首次读取 Referral 信息或重新注册未验证账号时自动补齐。
|
- 现有用户在首次读取 Referral 信息或重新注册未验证账号时自动补齐。
|
||||||
- 登录用户可在 `/profile` 查看自己的 Referral Code、邀请链接和有效邀请数量。
|
- 登录用户可在 `/profile` Account Tab 查看自己的 Referral Code、邀请链接复制入口和有效邀请数量。
|
||||||
- 邀请链接使用前端注册页路径:`/register?ref=CODE`。
|
- 邀请链接使用前端注册页路径:`/register?ref=CODE`。
|
||||||
- 注册页支持:
|
- 注册页支持:
|
||||||
- 从 `ref` query 自动填入 Referral Code。
|
- 从 `ref` query 自动填入 Referral Code。
|
||||||
|
|||||||
@@ -53,9 +53,11 @@ type AuthMessageKey =
|
|||||||
| 'emailVerified'
|
| 'emailVerified'
|
||||||
| 'checkPasswordResetEmail'
|
| 'checkPasswordResetEmail'
|
||||||
| 'passwordResetComplete'
|
| 'passwordResetComplete'
|
||||||
|
| 'passwordChanged'
|
||||||
| 'invalidCredentials'
|
| 'invalidCredentials'
|
||||||
| 'verifyEmailFirst'
|
| 'verifyEmailFirst'
|
||||||
| 'invalidResetToken'
|
| 'invalidResetToken'
|
||||||
|
| 'currentPasswordInvalid'
|
||||||
| 'invalidReferralCode'
|
| 'invalidReferralCode'
|
||||||
| 'emailSubject'
|
| 'emailSubject'
|
||||||
| 'emailHtml'
|
| 'emailHtml'
|
||||||
@@ -171,9 +173,11 @@ function authMessage(locale: string, key: AuthMessageKey, params: Record<string,
|
|||||||
emailVerified: 'server.auth.emailVerified',
|
emailVerified: 'server.auth.emailVerified',
|
||||||
checkPasswordResetEmail: 'server.auth.checkPasswordResetEmail',
|
checkPasswordResetEmail: 'server.auth.checkPasswordResetEmail',
|
||||||
passwordResetComplete: 'server.auth.passwordResetComplete',
|
passwordResetComplete: 'server.auth.passwordResetComplete',
|
||||||
|
passwordChanged: 'server.auth.passwordChanged',
|
||||||
invalidCredentials: 'server.auth.invalidCredentials',
|
invalidCredentials: 'server.auth.invalidCredentials',
|
||||||
verifyEmailFirst: 'server.auth.verifyEmailFirst',
|
verifyEmailFirst: 'server.auth.verifyEmailFirst',
|
||||||
invalidResetToken: 'server.auth.invalidResetToken',
|
invalidResetToken: 'server.auth.invalidResetToken',
|
||||||
|
currentPasswordInvalid: 'server.auth.currentPasswordInvalid',
|
||||||
invalidReferralCode: 'server.auth.invalidReferralCode',
|
invalidReferralCode: 'server.auth.invalidReferralCode',
|
||||||
emailSubject: 'email.auth.verificationSubject',
|
emailSubject: 'email.auth.verificationSubject',
|
||||||
emailHtml: 'email.auth.verificationHtml',
|
emailHtml: 'email.auth.verificationHtml',
|
||||||
@@ -1007,6 +1011,40 @@ export async function updateCurrentUser(
|
|||||||
return (await publicUserById(user.id)) ?? toPublicUser(user);
|
return (await publicUserById(user.id)) ?? toPublicUser(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function changeCurrentUserPassword(
|
||||||
|
userId: number,
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
currentSessionToken: string,
|
||||||
|
locale = defaultLocale
|
||||||
|
): Promise<{ message: string }> {
|
||||||
|
const currentPassword = typeof payload.currentPassword === 'string' ? payload.currentPassword : '';
|
||||||
|
const nextPassword = await cleanPassword(payload.password, locale);
|
||||||
|
|
||||||
|
if (!currentPassword) {
|
||||||
|
throw statusError(await authMessage(locale, 'currentPasswordInvalid'), 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await queryOne<LoginUserRow>(
|
||||||
|
'SELECT id, email, display_name, email_verified_at, password_hash FROM users WHERE id = $1',
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!user || !(await verifyPassword(currentPassword, user.password_hash))) {
|
||||||
|
throw statusError(await authMessage(locale, 'currentPasswordInvalid'), 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await hashPassword(nextPassword);
|
||||||
|
const currentSessionHash = hashToken(currentSessionToken);
|
||||||
|
|
||||||
|
await withTransaction(async (client) => {
|
||||||
|
await client.query('UPDATE users SET password_hash = $1, updated_at = now() WHERE id = $2', [passwordHash, user.id]);
|
||||||
|
await client.query('UPDATE password_reset_tokens SET used_at = now() WHERE user_id = $1 AND used_at IS NULL', [user.id]);
|
||||||
|
await client.query('DELETE FROM user_sessions WHERE user_id = $1 AND token_hash <> $2', [user.id, currentSessionHash]);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { message: await authMessage(locale, 'passwordChanged') };
|
||||||
|
}
|
||||||
|
|
||||||
export async function getReferralSummary(userId: number): Promise<ReferralSummary> {
|
export async function getReferralSummary(userId: number): Promise<ReferralSummary> {
|
||||||
return withTransaction(async (client) => {
|
return withTransaction(async (client) => {
|
||||||
const code = await ensureReferralCode(client, userId);
|
const code = await ensureReferralCode(client, userId);
|
||||||
|
|||||||
@@ -2205,6 +2205,28 @@ function cleanLifeReactionType(value: unknown): LifeReactionType {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cleanLifeReactionFilter(value: QueryValue): LifeReactionType | null {
|
||||||
|
const reactionType = asString(value);
|
||||||
|
if (!reactionType) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return cleanLifeReactionType(reactionType);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanUserCommentActivitySourceFilter(value: QueryValue): UserCommentActivitySource | null {
|
||||||
|
const source = asString(value);
|
||||||
|
if (!source) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source !== 'life' && source !== 'discussion') {
|
||||||
|
throw validationError('server.validation.invalidField');
|
||||||
|
}
|
||||||
|
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
function lifePostProjection(locale = defaultLocale): string {
|
function lifePostProjection(locale = defaultLocale): string {
|
||||||
const tagName = localizedName('life-tags', 'lt', locale);
|
const tagName = localizedName('life-tags', 'lt', locale);
|
||||||
|
|
||||||
@@ -2691,9 +2713,15 @@ export async function listUserReactionActivities(
|
|||||||
|
|
||||||
const cursor = decodeLifePostCursor(paramsQuery.cursor);
|
const cursor = decodeLifePostCursor(paramsQuery.cursor);
|
||||||
const limit = cleanLifePostLimit(paramsQuery.limit);
|
const limit = cleanLifePostLimit(paramsQuery.limit);
|
||||||
|
const reactionType = cleanLifeReactionFilter(paramsQuery.reactionType);
|
||||||
const params: unknown[] = [user.id];
|
const params: unknown[] = [user.id];
|
||||||
const conditions = ['lpr.user_id = $1', 'lp.deleted_at IS NULL'];
|
const conditions = ['lpr.user_id = $1', 'lp.deleted_at IS NULL'];
|
||||||
|
|
||||||
|
if (reactionType) {
|
||||||
|
params.push(reactionType);
|
||||||
|
conditions.push(`lpr.reaction_type = $${params.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
if (cursor) {
|
if (cursor) {
|
||||||
params.push(cursor.createdAt, cursor.id);
|
params.push(cursor.createdAt, cursor.id);
|
||||||
conditions.push(`(lpr.updated_at, lpr.post_id) < ($${params.length - 1}::timestamptz, $${params.length}::integer)`);
|
conditions.push(`(lpr.updated_at, lpr.post_id) < ($${params.length - 1}::timestamptz, $${params.length}::integer)`);
|
||||||
@@ -2765,19 +2793,28 @@ export async function listUserCommentActivities(
|
|||||||
|
|
||||||
const cursor = decodeUserCommentActivityCursor(paramsQuery.cursor);
|
const cursor = decodeUserCommentActivityCursor(paramsQuery.cursor);
|
||||||
const limit = cleanLifePostLimit(paramsQuery.limit);
|
const limit = cleanLifePostLimit(paramsQuery.limit);
|
||||||
|
const sourceFilter = cleanUserCommentActivitySourceFilter(paramsQuery.source);
|
||||||
const pokemonName = localizedName('pokemon', 'p', locale);
|
const pokemonName = localizedName('pokemon', 'p', locale);
|
||||||
const itemName = localizedName('items', 'i', locale);
|
const itemName = localizedName('items', 'i', locale);
|
||||||
const recipeItemName = localizedName('items', 'recipe_item', locale);
|
const recipeItemName = localizedName('items', 'recipe_item', locale);
|
||||||
const habitatName = localizedName('habitats', 'h', locale);
|
const habitatName = localizedName('habitats', 'h', locale);
|
||||||
const params: unknown[] = [user.id];
|
const params: unknown[] = [user.id];
|
||||||
let cursorClause = '';
|
const outerConditions: string[] = [];
|
||||||
|
|
||||||
|
if (sourceFilter) {
|
||||||
|
params.push(sourceFilter);
|
||||||
|
outerConditions.push(`source = $${params.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
if (cursor) {
|
if (cursor) {
|
||||||
params.push(cursor.createdAt, cursor.source, cursor.id);
|
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)`;
|
outerConditions.push(
|
||||||
|
`(created_at, source, id) < ($${params.length - 2}::timestamptz, $${params.length - 1}::text, $${params.length}::integer)`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
params.push(limit + 1);
|
params.push(limit + 1);
|
||||||
|
const outerWhere = outerConditions.length ? `WHERE ${outerConditions.join(' AND ')}` : '';
|
||||||
const rows = await query<{
|
const rows = await query<{
|
||||||
id: number;
|
id: number;
|
||||||
source: UserCommentActivitySource;
|
source: UserCommentActivitySource;
|
||||||
@@ -2849,7 +2886,7 @@ export async function listUserCommentActivities(
|
|||||||
target_title AS "targetTitle",
|
target_title AS "targetTitle",
|
||||||
target_excerpt AS "targetExcerpt"
|
target_excerpt AS "targetExcerpt"
|
||||||
FROM activity
|
FROM activity
|
||||||
${cursorClause}
|
${outerWhere}
|
||||||
ORDER BY created_at DESC, source DESC, id DESC
|
ORDER BY created_at DESC, source DESC, id DESC
|
||||||
LIMIT $${params.length}
|
LIMIT $${params.length}
|
||||||
`,
|
`,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import Fastify from 'fastify';
|
|||||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
import { mkdir } from 'node:fs/promises';
|
import { mkdir } from 'node:fs/promises';
|
||||||
import {
|
import {
|
||||||
|
changeCurrentUserPassword,
|
||||||
createPermission,
|
createPermission,
|
||||||
createRole,
|
createRole,
|
||||||
deletePermission,
|
deletePermission,
|
||||||
@@ -301,6 +302,18 @@ app.patch('/api/auth/me', async (request, reply) => {
|
|||||||
return { user: await updateCurrentUser(user.id, payload, requestLocale(request)) };
|
return { user: await updateCurrentUser(user.id, payload, requestLocale(request)) };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.patch('/api/auth/me/password', async (request, reply) => {
|
||||||
|
const token = getBearerToken(request.headers.authorization);
|
||||||
|
const user = token ? await getUserBySessionToken(token) : null;
|
||||||
|
|
||||||
|
if (!user || !token) {
|
||||||
|
return reply.code(401).send({ message: await serverMessage(requestLocale(request), 'loginRequired') });
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = request.body && typeof request.body === 'object' ? (request.body as Record<string, unknown>) : {};
|
||||||
|
return changeCurrentUserPassword(user.id, payload, token, requestLocale(request));
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/api/auth/referral', async (request, reply) => {
|
app.get('/api/auth/referral', async (request, reply) => {
|
||||||
const token = getBearerToken(request.headers.authorization);
|
const token = getBearerToken(request.headers.authorization);
|
||||||
const user = token ? await getUserBySessionToken(token) : null;
|
const user = token ? await getUserBySessionToken(token) : null;
|
||||||
|
|||||||
@@ -355,9 +355,13 @@ export interface PublicUserProfile {
|
|||||||
contributions: PublicProfileContribution[];
|
contributions: PublicProfileContribution[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ProfileCommentSource = 'life' | 'discussion';
|
||||||
|
|
||||||
export interface ProfileActivityParams {
|
export interface ProfileActivityParams {
|
||||||
cursor?: string | null;
|
cursor?: string | null;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
|
reactionType?: LifeReactionType;
|
||||||
|
source?: ProfileCommentSource;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserReactionActivity {
|
export interface UserReactionActivity {
|
||||||
@@ -423,6 +427,11 @@ export interface UserProfilePayload {
|
|||||||
displayName: string;
|
displayName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ChangePasswordPayload {
|
||||||
|
currentPassword: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface LoginPayload {
|
export interface LoginPayload {
|
||||||
email: string;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
@@ -559,7 +568,7 @@ export interface EntityDiscussionComment {
|
|||||||
|
|
||||||
export interface UserCommentActivity {
|
export interface UserCommentActivity {
|
||||||
id: number;
|
id: number;
|
||||||
source: 'life' | 'discussion';
|
source: ProfileCommentSource;
|
||||||
body: string;
|
body: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
target: {
|
target: {
|
||||||
@@ -760,6 +769,8 @@ export const api = {
|
|||||||
sendJson<{ message: string }>('/api/auth/reset-password', 'POST', payload),
|
sendJson<{ message: string }>('/api/auth/reset-password', 'POST', payload),
|
||||||
me: () => getJson<{ user: AuthUser }>('/api/auth/me'),
|
me: () => getJson<{ user: AuthUser }>('/api/auth/me'),
|
||||||
updateMe: (payload: UserProfilePayload) => sendJson<{ user: AuthUser }>('/api/auth/me', 'PATCH', payload),
|
updateMe: (payload: UserProfilePayload) => sendJson<{ user: AuthUser }>('/api/auth/me', 'PATCH', payload),
|
||||||
|
changePassword: (payload: ChangePasswordPayload) =>
|
||||||
|
sendJson<{ message: string }>('/api/auth/me/password', 'PATCH', payload),
|
||||||
referral: () => getJson<{ referral: ReferralSummary }>('/api/auth/referral'),
|
referral: () => getJson<{ referral: ReferralSummary }>('/api/auth/referral'),
|
||||||
logout: () => postEmpty('/api/auth/logout'),
|
logout: () => postEmpty('/api/auth/logout'),
|
||||||
publicProfile: (id: string | number) => getJson<{ profile: PublicUserProfile }>(`/api/users/${id}/profile`),
|
publicProfile: (id: string | number) => getJson<{ profile: PublicUserProfile }>(`/api/users/${id}/profile`),
|
||||||
@@ -774,14 +785,16 @@ export const api = {
|
|||||||
getJson<UserReactionActivityPage>(
|
getJson<UserReactionActivityPage>(
|
||||||
`/api/users/${id}/reactions${buildQuery({
|
`/api/users/${id}/reactions${buildQuery({
|
||||||
cursor: params.cursor ?? undefined,
|
cursor: params.cursor ?? undefined,
|
||||||
limit: params.limit
|
limit: params.limit,
|
||||||
|
reactionType: params.reactionType
|
||||||
})}`
|
})}`
|
||||||
),
|
),
|
||||||
userComments: (id: string | number, params: ProfileActivityParams = {}) =>
|
userComments: (id: string | number, params: ProfileActivityParams = {}) =>
|
||||||
getJson<UserCommentActivityPage>(
|
getJson<UserCommentActivityPage>(
|
||||||
`/api/users/${id}/comments${buildQuery({
|
`/api/users/${id}/comments${buildQuery({
|
||||||
cursor: params.cursor ?? undefined,
|
cursor: params.cursor ?? undefined,
|
||||||
limit: params.limit
|
limit: params.limit,
|
||||||
|
source: params.source
|
||||||
})}`
|
})}`
|
||||||
),
|
),
|
||||||
adminUsers: () => getJson<AdminUser[]>('/api/admin/users'),
|
adminUsers: () => getJson<AdminUser[]>('/api/admin/users'),
|
||||||
|
|||||||
@@ -4376,6 +4376,10 @@ button:disabled,
|
|||||||
grid-column: 2;
|
grid-column: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.profile-card--password {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
.profile-identity {
|
.profile-identity {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto minmax(0, 1fr);
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
@@ -4503,6 +4507,10 @@ button:disabled,
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.profile-secondary-tabs .tab-list {
|
||||||
|
border-bottom-color: color-mix(in srgb, var(--line) 72%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
.profile-layout--loading {
|
.profile-layout--loading {
|
||||||
grid-template-columns: minmax(260px, 0.5fr) minmax(0, 1fr);
|
grid-template-columns: minmax(260px, 0.5fr) minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
@@ -4566,6 +4574,50 @@ button:disabled,
|
|||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.profile-referral-summary {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
background: var(--surface-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-referral-summary > div {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-referral-summary span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-referral-summary strong {
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 900;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-referral-summary .ui-button {
|
||||||
|
min-height: 44px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-referral-summary .status-message {
|
||||||
|
position: static;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
.profile-section-grid,
|
.profile-section-grid,
|
||||||
.profile-account-grid {
|
.profile-account-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -5188,7 +5240,8 @@ button:disabled,
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-card--referral {
|
.profile-card--referral,
|
||||||
|
.profile-card--password {
|
||||||
grid-column: auto;
|
grid-column: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5330,6 +5383,11 @@ button:disabled,
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.profile-referral-summary {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-referral-summary .ui-button,
|
||||||
.profile-referral-link-row .ui-button {
|
.profile-referral-link-row .ui-button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import Tabs, { type TabOption } from '../components/Tabs.vue';
|
|||||||
import {
|
import {
|
||||||
iconComment,
|
iconComment,
|
||||||
iconCopy,
|
iconCopy,
|
||||||
|
iconKey,
|
||||||
iconLife,
|
iconLife,
|
||||||
iconProfile,
|
iconProfile,
|
||||||
iconReactionFun,
|
iconReactionFun,
|
||||||
@@ -29,6 +30,7 @@ import {
|
|||||||
type DiscussionEntityType,
|
type DiscussionEntityType,
|
||||||
type LifePost,
|
type LifePost,
|
||||||
type LifeReactionType,
|
type LifeReactionType,
|
||||||
|
type ProfileCommentSource,
|
||||||
type PublicUserProfile,
|
type PublicUserProfile,
|
||||||
type ReferralSummary,
|
type ReferralSummary,
|
||||||
type UserCommentActivity,
|
type UserCommentActivity,
|
||||||
@@ -36,6 +38,18 @@ import {
|
|||||||
} from '../services/api';
|
} from '../services/api';
|
||||||
|
|
||||||
type ProfileTab = 'feeds' | 'contributions' | 'reactions' | 'comments' | 'account';
|
type ProfileTab = 'feeds' | 'contributions' | 'reactions' | 'comments' | 'account';
|
||||||
|
type PrimaryContributionFilter = 'pokemon' | 'items' | 'recipes' | 'habitats' | 'daily-checklist';
|
||||||
|
type ContributionFilter = 'all' | PrimaryContributionFilter | 'config';
|
||||||
|
type ReactionFilter = 'all' | LifeReactionType;
|
||||||
|
type CommentFilter = 'all' | ProfileCommentSource;
|
||||||
|
|
||||||
|
const primaryContributionFilters: PrimaryContributionFilter[] = [
|
||||||
|
'pokemon',
|
||||||
|
'items',
|
||||||
|
'recipes',
|
||||||
|
'habitats',
|
||||||
|
'daily-checklist'
|
||||||
|
];
|
||||||
|
|
||||||
const { locale, t } = useI18n();
|
const { locale, t } = useI18n();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@@ -43,11 +57,22 @@ const currentUser = ref<AuthUser | null>(null);
|
|||||||
const profile = ref<PublicUserProfile | null>(null);
|
const profile = ref<PublicUserProfile | null>(null);
|
||||||
const referral = ref<ReferralSummary | null>(null);
|
const referral = ref<ReferralSummary | null>(null);
|
||||||
const displayName = ref('');
|
const displayName = ref('');
|
||||||
|
const currentPassword = ref('');
|
||||||
|
const newPassword = ref('');
|
||||||
|
const confirmPassword = ref('');
|
||||||
const activeTab = ref<ProfileTab>('feeds');
|
const activeTab = ref<ProfileTab>('feeds');
|
||||||
|
const contributionFilter = ref<ContributionFilter>('all');
|
||||||
|
const reactionFilter = ref<ReactionFilter>('all');
|
||||||
|
const commentFilter = ref<CommentFilter>('all');
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const busy = ref(false);
|
const busy = ref(false);
|
||||||
|
const passwordBusy = ref(false);
|
||||||
const message = ref('');
|
const message = ref('');
|
||||||
const errorMessage = ref('');
|
const errorMessage = ref('');
|
||||||
|
const passwordMessage = ref('');
|
||||||
|
const passwordErrorMessage = ref('');
|
||||||
|
const referralSummaryMessage = ref('');
|
||||||
|
const referralSummaryErrorMessage = ref('');
|
||||||
const referralMessage = ref('');
|
const referralMessage = ref('');
|
||||||
const referralErrorMessage = ref('');
|
const referralErrorMessage = ref('');
|
||||||
const feeds = ref<LifePost[]>([]);
|
const feeds = ref<LifePost[]>([]);
|
||||||
@@ -98,6 +123,27 @@ const tabs = computed<TabOption[]>(() => {
|
|||||||
|
|
||||||
return canShowAccount.value ? [...baseTabs, { value: 'account', label: t('pages.profile.tabAccount') }] : baseTabs;
|
return canShowAccount.value ? [...baseTabs, { value: 'account', label: t('pages.profile.tabAccount') }] : baseTabs;
|
||||||
});
|
});
|
||||||
|
const contributionFilterTabs = computed<TabOption[]>(() => [
|
||||||
|
{ value: 'all', label: t('common.all') },
|
||||||
|
{ value: 'pokemon', label: t('nav.pokemon') },
|
||||||
|
{ value: 'items', label: t('nav.items') },
|
||||||
|
{ value: 'recipes', label: t('nav.recipes') },
|
||||||
|
{ value: 'habitats', label: t('nav.habitats') },
|
||||||
|
{ value: 'daily-checklist', label: t('nav.checklist') },
|
||||||
|
{ value: 'config', label: t('pages.profile.contributionConfig') }
|
||||||
|
]);
|
||||||
|
const reactionFilterTabs = computed<TabOption[]>(() => [
|
||||||
|
{ value: 'all', label: t('common.all') },
|
||||||
|
{ value: 'like', label: reactionLabel('like') },
|
||||||
|
{ value: 'helpful', label: reactionLabel('helpful') },
|
||||||
|
{ value: 'fun', label: reactionLabel('fun') },
|
||||||
|
{ value: 'thanks', label: reactionLabel('thanks') }
|
||||||
|
]);
|
||||||
|
const commentFilterTabs = computed<TabOption[]>(() => [
|
||||||
|
{ value: 'all', label: t('common.all') },
|
||||||
|
{ value: 'life', label: t('pages.profile.lifeCommentCategory') },
|
||||||
|
{ value: 'discussion', label: t('pages.profile.discussionCommentCategory') }
|
||||||
|
]);
|
||||||
const headlineStats = computed(() => {
|
const headlineStats = computed(() => {
|
||||||
const stats = profile.value?.stats;
|
const stats = profile.value?.stats;
|
||||||
return [
|
return [
|
||||||
@@ -126,6 +172,14 @@ const communityStats = computed(() => {
|
|||||||
{ label: t('pages.profile.discussionComments'), value: stats?.discussionComments ?? 0 }
|
{ label: t('pages.profile.discussionComments'), value: stats?.discussionComments ?? 0 }
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
const filteredContributions = computed(() => {
|
||||||
|
const items = profile.value?.contributions ?? [];
|
||||||
|
if (contributionFilter.value === 'all') {
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
return items.filter((item) => contributionCategory(item.contentType) === contributionFilter.value);
|
||||||
|
});
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
tabs,
|
tabs,
|
||||||
@@ -144,6 +198,26 @@ watch(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => reactionFilter.value,
|
||||||
|
() => {
|
||||||
|
resetReactions();
|
||||||
|
if (activeTab.value === 'reactions') {
|
||||||
|
void loadReactions(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => commentFilter.value,
|
||||||
|
() => {
|
||||||
|
resetComments();
|
||||||
|
if (activeTab.value === 'comments') {
|
||||||
|
void loadComments(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => route.fullPath,
|
() => route.fullPath,
|
||||||
() => {
|
() => {
|
||||||
@@ -151,21 +225,33 @@ watch(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
function resetActivity() {
|
function resetFeeds() {
|
||||||
feeds.value = [];
|
feeds.value = [];
|
||||||
feedsCursor.value = null;
|
feedsCursor.value = null;
|
||||||
feedsHasMore.value = false;
|
feedsHasMore.value = false;
|
||||||
feedsError.value = '';
|
feedsError.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetReactions() {
|
||||||
reactions.value = [];
|
reactions.value = [];
|
||||||
reactionsCursor.value = null;
|
reactionsCursor.value = null;
|
||||||
reactionsHasMore.value = false;
|
reactionsHasMore.value = false;
|
||||||
reactionsError.value = '';
|
reactionsError.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetComments() {
|
||||||
comments.value = [];
|
comments.value = [];
|
||||||
commentsCursor.value = null;
|
commentsCursor.value = null;
|
||||||
commentsHasMore.value = false;
|
commentsHasMore.value = false;
|
||||||
commentsError.value = '';
|
commentsError.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resetActivity() {
|
||||||
|
resetFeeds();
|
||||||
|
resetReactions();
|
||||||
|
resetComments();
|
||||||
|
}
|
||||||
|
|
||||||
async function loadOptionalCurrentUser() {
|
async function loadOptionalCurrentUser() {
|
||||||
if (!getAuthToken()) {
|
if (!getAuthToken()) {
|
||||||
currentUser.value = null;
|
currentUser.value = null;
|
||||||
@@ -188,10 +274,20 @@ async function loadProfile() {
|
|||||||
loading.value = true;
|
loading.value = true;
|
||||||
message.value = '';
|
message.value = '';
|
||||||
errorMessage.value = '';
|
errorMessage.value = '';
|
||||||
|
passwordMessage.value = '';
|
||||||
|
passwordErrorMessage.value = '';
|
||||||
|
referralSummaryMessage.value = '';
|
||||||
|
referralSummaryErrorMessage.value = '';
|
||||||
referralMessage.value = '';
|
referralMessage.value = '';
|
||||||
referralErrorMessage.value = '';
|
referralErrorMessage.value = '';
|
||||||
referral.value = null;
|
referral.value = null;
|
||||||
profile.value = null;
|
profile.value = null;
|
||||||
|
contributionFilter.value = 'all';
|
||||||
|
reactionFilter.value = 'all';
|
||||||
|
commentFilter.value = 'all';
|
||||||
|
currentPassword.value = '';
|
||||||
|
newPassword.value = '';
|
||||||
|
confirmPassword.value = '';
|
||||||
resetActivity();
|
resetActivity();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -271,6 +367,32 @@ async function saveProfile() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function savePassword() {
|
||||||
|
passwordMessage.value = '';
|
||||||
|
passwordErrorMessage.value = '';
|
||||||
|
|
||||||
|
if (newPassword.value !== confirmPassword.value) {
|
||||||
|
passwordErrorMessage.value = t('auth.passwordMismatch');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
passwordBusy.value = true;
|
||||||
|
try {
|
||||||
|
const response = await api.changePassword({
|
||||||
|
currentPassword: currentPassword.value,
|
||||||
|
password: newPassword.value
|
||||||
|
});
|
||||||
|
currentPassword.value = '';
|
||||||
|
newPassword.value = '';
|
||||||
|
confirmPassword.value = '';
|
||||||
|
passwordMessage.value = response.message || t('pages.profile.passwordSaved');
|
||||||
|
} catch (error) {
|
||||||
|
passwordErrorMessage.value = error instanceof Error && error.message ? error.message : t('pages.profile.passwordSaveFailed');
|
||||||
|
} finally {
|
||||||
|
passwordBusy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function writeClipboard(value: string): Promise<void> {
|
function writeClipboard(value: string): Promise<void> {
|
||||||
if (navigator.clipboard?.writeText) {
|
if (navigator.clipboard?.writeText) {
|
||||||
return navigator.clipboard.writeText(value);
|
return navigator.clipboard.writeText(value);
|
||||||
@@ -289,20 +411,30 @@ function writeClipboard(value: string): Promise<void> {
|
|||||||
return copied ? Promise.resolve() : Promise.reject(new Error('Clipboard unavailable'));
|
return copied ? Promise.resolve() : Promise.reject(new Error('Clipboard unavailable'));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyReferralLink() {
|
async function copyReferralLink(surface: 'summary' | 'card' = 'card') {
|
||||||
if (!referral.value) {
|
if (!referral.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
referralSummaryMessage.value = '';
|
||||||
|
referralSummaryErrorMessage.value = '';
|
||||||
referralMessage.value = '';
|
referralMessage.value = '';
|
||||||
referralErrorMessage.value = '';
|
referralErrorMessage.value = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await writeClipboard(referral.value.url);
|
await writeClipboard(referral.value.url);
|
||||||
|
if (surface === 'summary') {
|
||||||
|
referralSummaryMessage.value = t('pages.profile.referralCopied');
|
||||||
|
} else {
|
||||||
referralMessage.value = t('pages.profile.referralCopied');
|
referralMessage.value = t('pages.profile.referralCopied');
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
if (surface === 'summary') {
|
||||||
|
referralSummaryErrorMessage.value = t('pages.profile.referralCopyFailed');
|
||||||
|
} else {
|
||||||
referralErrorMessage.value = t('pages.profile.referralCopyFailed');
|
referralErrorMessage.value = t('pages.profile.referralCopyFailed');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadActiveTab(force = false) {
|
async function loadActiveTab(force = false) {
|
||||||
@@ -351,7 +483,8 @@ async function loadReactions(reset = false) {
|
|||||||
try {
|
try {
|
||||||
const page = await api.userReactions(profile.value.user.id, {
|
const page = await api.userReactions(profile.value.user.id, {
|
||||||
cursor: reset ? null : reactionsCursor.value,
|
cursor: reset ? null : reactionsCursor.value,
|
||||||
limit: activityLimit
|
limit: activityLimit,
|
||||||
|
reactionType: reactionFilter.value === 'all' ? undefined : reactionFilter.value
|
||||||
});
|
});
|
||||||
reactions.value = reset ? page.items : [...reactions.value, ...page.items];
|
reactions.value = reset ? page.items : [...reactions.value, ...page.items];
|
||||||
reactionsCursor.value = page.nextCursor;
|
reactionsCursor.value = page.nextCursor;
|
||||||
@@ -373,7 +506,8 @@ async function loadComments(reset = false) {
|
|||||||
try {
|
try {
|
||||||
const page = await api.userComments(profile.value.user.id, {
|
const page = await api.userComments(profile.value.user.id, {
|
||||||
cursor: reset ? null : commentsCursor.value,
|
cursor: reset ? null : commentsCursor.value,
|
||||||
limit: activityLimit
|
limit: activityLimit,
|
||||||
|
source: commentFilter.value === 'all' ? undefined : commentFilter.value
|
||||||
});
|
});
|
||||||
comments.value = reset ? page.items : [...comments.value, ...page.items];
|
comments.value = reset ? page.items : [...comments.value, ...page.items];
|
||||||
commentsCursor.value = page.nextCursor;
|
commentsCursor.value = page.nextCursor;
|
||||||
@@ -438,6 +572,12 @@ function reactionLabel(type: LifeReactionType): string {
|
|||||||
return t(`pages.life.reaction${type.charAt(0).toUpperCase()}${type.slice(1)}`);
|
return t(`pages.life.reaction${type.charAt(0).toUpperCase()}${type.slice(1)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function contributionCategory(contentType: string): ContributionFilter {
|
||||||
|
return primaryContributionFilters.includes(contentType as PrimaryContributionFilter)
|
||||||
|
? (contentType as PrimaryContributionFilter)
|
||||||
|
: 'config';
|
||||||
|
}
|
||||||
|
|
||||||
function contentTypeLabel(contentType: string): string {
|
function contentTypeLabel(contentType: string): string {
|
||||||
const labels: Record<string, string> = {
|
const labels: Record<string, string> = {
|
||||||
pokemon: t('nav.pokemon'),
|
pokemon: t('nav.pokemon'),
|
||||||
@@ -534,6 +674,19 @@ onMounted(() => {
|
|||||||
<dd>{{ item.value }}</dd>
|
<dd>{{ item.value }}</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
|
<div v-if="canShowAccount && referral" class="profile-referral-summary">
|
||||||
|
<div>
|
||||||
|
<span>{{ t('pages.profile.referralCode') }}</span>
|
||||||
|
<strong>{{ referral.code }}</strong>
|
||||||
|
</div>
|
||||||
|
<button class="ui-button ui-button--blue" type="button" @click="copyReferralLink('summary')">
|
||||||
|
<Icon :icon="iconCopy" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('pages.profile.copyReferralLink') }}
|
||||||
|
</button>
|
||||||
|
<StatusMessage v-if="referralSummaryMessage" variant="success">{{ referralSummaryMessage }}</StatusMessage>
|
||||||
|
<StatusMessage v-if="referralSummaryErrorMessage" variant="danger">{{ referralSummaryErrorMessage }}</StatusMessage>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<Tabs id="profile-tabs" v-model="activeTab" :tabs="tabs" :label="t('pages.profile.tabsLabel')" />
|
<Tabs id="profile-tabs" v-model="activeTab" :tabs="tabs" :label="t('pages.profile.tabsLabel')" />
|
||||||
@@ -631,14 +784,22 @@ onMounted(() => {
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Tabs
|
||||||
|
id="profile-contribution-filter"
|
||||||
|
v-model="contributionFilter"
|
||||||
|
class="profile-secondary-tabs"
|
||||||
|
:tabs="contributionFilterTabs"
|
||||||
|
:label="t('pages.profile.contributionFiltersLabel')"
|
||||||
|
/>
|
||||||
|
|
||||||
<section class="profile-card profile-card--wide" :aria-label="t('pages.profile.contributionBreakdown')">
|
<section class="profile-card profile-card--wide" :aria-label="t('pages.profile.contributionBreakdown')">
|
||||||
<div class="profile-card__header">
|
<div class="profile-card__header">
|
||||||
<Icon :icon="iconProfile" class="profile-card__icon" aria-hidden="true" />
|
<Icon :icon="iconProfile" class="profile-card__icon" aria-hidden="true" />
|
||||||
<h2>{{ t('pages.profile.contributionBreakdown') }}</h2>
|
<h2>{{ t('pages.profile.contributionBreakdown') }}</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="profile.contributions.length" class="profile-contribution-list">
|
<div v-if="filteredContributions.length" class="profile-contribution-list">
|
||||||
<article v-for="item in profile.contributions" :key="item.contentType" class="profile-contribution-row">
|
<article v-for="item in filteredContributions" :key="item.contentType" class="profile-contribution-row">
|
||||||
<div>
|
<div>
|
||||||
<strong>{{ contentTypeLabel(item.contentType) }}</strong>
|
<strong>{{ contentTypeLabel(item.contentType) }}</strong>
|
||||||
<span v-if="item.lastContributedAt">{{ formatDateTime(item.lastContributedAt) }}</span>
|
<span v-if="item.lastContributedAt">{{ formatDateTime(item.lastContributedAt) }}</span>
|
||||||
@@ -666,7 +827,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
<div v-else class="profile-empty profile-empty--compact">
|
<div v-else class="profile-empty profile-empty--compact">
|
||||||
<Icon :icon="iconProfile" class="profile-empty__icon" aria-hidden="true" />
|
<Icon :icon="iconProfile" class="profile-empty__icon" aria-hidden="true" />
|
||||||
<h2>{{ t('pages.profile.contributionsEmpty') }}</h2>
|
<h2>{{ profile.contributions.length ? t('pages.profile.contributionsFilterEmpty') : t('pages.profile.contributionsEmpty') }}</h2>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
@@ -674,6 +835,14 @@ onMounted(() => {
|
|||||||
<section v-else-if="activeTab === 'reactions'" class="profile-tab-panel" :aria-label="t('pages.profile.tabReactions')">
|
<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>
|
<StatusMessage v-if="reactionsError" variant="danger" :duration="0">{{ reactionsError }}</StatusMessage>
|
||||||
|
|
||||||
|
<Tabs
|
||||||
|
id="profile-reaction-filter"
|
||||||
|
v-model="reactionFilter"
|
||||||
|
class="profile-secondary-tabs"
|
||||||
|
:tabs="reactionFilterTabs"
|
||||||
|
:label="t('pages.profile.reactionFiltersLabel')"
|
||||||
|
/>
|
||||||
|
|
||||||
<div v-if="reactionsLoading && !reactions.length" class="profile-activity-list" aria-hidden="true">
|
<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">
|
<article v-for="index in 3" :key="index" class="profile-activity-card">
|
||||||
<Skeleton width="180px" />
|
<Skeleton width="180px" />
|
||||||
@@ -713,13 +882,21 @@ onMounted(() => {
|
|||||||
|
|
||||||
<div v-else class="profile-empty">
|
<div v-else class="profile-empty">
|
||||||
<Icon :icon="iconReactionLike" class="profile-empty__icon" aria-hidden="true" />
|
<Icon :icon="iconReactionLike" class="profile-empty__icon" aria-hidden="true" />
|
||||||
<h2>{{ t('pages.profile.reactionsEmpty') }}</h2>
|
<h2>{{ reactionFilter === 'all' ? t('pages.profile.reactionsEmpty') : t('pages.profile.reactionsFilterEmpty') }}</h2>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-else-if="activeTab === 'comments'" class="profile-tab-panel" :aria-label="t('pages.profile.tabComments')">
|
<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>
|
<StatusMessage v-if="commentsError" variant="danger" :duration="0">{{ commentsError }}</StatusMessage>
|
||||||
|
|
||||||
|
<Tabs
|
||||||
|
id="profile-comment-filter"
|
||||||
|
v-model="commentFilter"
|
||||||
|
class="profile-secondary-tabs"
|
||||||
|
:tabs="commentFilterTabs"
|
||||||
|
:label="t('pages.profile.commentFiltersLabel')"
|
||||||
|
/>
|
||||||
|
|
||||||
<div v-if="commentsLoading && !comments.length" class="profile-activity-list" aria-hidden="true">
|
<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">
|
<article v-for="index in 3" :key="index" class="profile-activity-card">
|
||||||
<Skeleton width="180px" />
|
<Skeleton width="180px" />
|
||||||
@@ -754,7 +931,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
<div v-else class="profile-empty">
|
<div v-else class="profile-empty">
|
||||||
<Icon :icon="iconComment" class="profile-empty__icon" aria-hidden="true" />
|
<Icon :icon="iconComment" class="profile-empty__icon" aria-hidden="true" />
|
||||||
<h2>{{ t('pages.profile.commentsEmpty') }}</h2>
|
<h2>{{ commentFilter === 'all' ? t('pages.profile.commentsEmpty') : t('pages.profile.commentsFilterEmpty') }}</h2>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -815,7 +992,7 @@ onMounted(() => {
|
|||||||
<label for="profile-referral-url">{{ t('pages.profile.referralUrl') }}</label>
|
<label for="profile-referral-url">{{ t('pages.profile.referralUrl') }}</label>
|
||||||
<div class="profile-referral-link-row">
|
<div class="profile-referral-link-row">
|
||||||
<input id="profile-referral-url" class="profile-readonly-input" :value="referral.url" readonly />
|
<input id="profile-referral-url" class="profile-readonly-input" :value="referral.url" readonly />
|
||||||
<button class="ui-button ui-button--blue" type="button" @click="copyReferralLink">
|
<button class="ui-button ui-button--blue" type="button" @click="copyReferralLink()">
|
||||||
<Icon :icon="iconCopy" class="ui-icon" aria-hidden="true" />
|
<Icon :icon="iconCopy" class="ui-icon" aria-hidden="true" />
|
||||||
{{ t('pages.profile.copyReferralLink') }}
|
{{ t('pages.profile.copyReferralLink') }}
|
||||||
</button>
|
</button>
|
||||||
@@ -829,6 +1006,62 @@ onMounted(() => {
|
|||||||
|
|
||||||
<StatusMessage v-else-if="referralErrorMessage" variant="danger" :duration="0">{{ referralErrorMessage }}</StatusMessage>
|
<StatusMessage v-else-if="referralErrorMessage" variant="danger" :duration="0">{{ referralErrorMessage }}</StatusMessage>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="profile-card profile-card--password" :aria-label="t('pages.profile.passwordTitle')">
|
||||||
|
<div class="profile-card__header">
|
||||||
|
<Icon :icon="iconKey" class="profile-card__icon" aria-hidden="true" />
|
||||||
|
<h2>{{ t('pages.profile.passwordTitle') }}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="auth-form" @submit.prevent="savePassword">
|
||||||
|
<div class="field">
|
||||||
|
<label for="profile-current-password">{{ t('auth.currentPassword') }}</label>
|
||||||
|
<input
|
||||||
|
id="profile-current-password"
|
||||||
|
v-model="currentPassword"
|
||||||
|
autocomplete="current-password"
|
||||||
|
required
|
||||||
|
:disabled="passwordBusy"
|
||||||
|
type="password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="profile-new-password">{{ t('auth.newPassword') }}</label>
|
||||||
|
<input
|
||||||
|
id="profile-new-password"
|
||||||
|
v-model="newPassword"
|
||||||
|
autocomplete="new-password"
|
||||||
|
minlength="8"
|
||||||
|
required
|
||||||
|
:disabled="passwordBusy"
|
||||||
|
type="password"
|
||||||
|
/>
|
||||||
|
<small class="profile-field-note">{{ t('pages.profile.passwordHint') }}</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="profile-confirm-password">{{ t('auth.confirmPassword') }}</label>
|
||||||
|
<input
|
||||||
|
id="profile-confirm-password"
|
||||||
|
v-model="confirmPassword"
|
||||||
|
autocomplete="new-password"
|
||||||
|
minlength="8"
|
||||||
|
required
|
||||||
|
:disabled="passwordBusy"
|
||||||
|
type="password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<StatusMessage v-if="passwordMessage" variant="success">{{ passwordMessage }}</StatusMessage>
|
||||||
|
<StatusMessage v-if="passwordErrorMessage" variant="danger">{{ passwordErrorMessage }}</StatusMessage>
|
||||||
|
|
||||||
|
<button class="ui-button ui-button--primary" :disabled="passwordBusy" type="submit">
|
||||||
|
<Icon :icon="iconSave" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ passwordBusy ? t('common.saving') : t('pages.profile.savePassword') }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ export const systemWordingMessages = {
|
|||||||
accountAccess: 'Trainer Pass',
|
accountAccess: 'Trainer Pass',
|
||||||
email: 'Email',
|
email: 'Email',
|
||||||
password: 'Password',
|
password: 'Password',
|
||||||
|
currentPassword: 'Current password',
|
||||||
newPassword: 'New password',
|
newPassword: 'New password',
|
||||||
confirmPassword: 'Confirm password',
|
confirmPassword: 'Confirm password',
|
||||||
displayName: 'Display name',
|
displayName: 'Display name',
|
||||||
@@ -117,7 +118,7 @@ export const systemWordingMessages = {
|
|||||||
pages: {
|
pages: {
|
||||||
profile: {
|
profile: {
|
||||||
title: 'User profile',
|
title: 'User profile',
|
||||||
subtitle: 'Manage your account display name and email status.',
|
subtitle: 'Manage your account details, referral, and password.',
|
||||||
loading: 'Loading profile',
|
loading: 'Loading profile',
|
||||||
accountSummary: 'Account summary',
|
accountSummary: 'Account summary',
|
||||||
profileDetails: 'Profile details',
|
profileDetails: 'Profile details',
|
||||||
@@ -144,6 +145,20 @@ export const systemWordingMessages = {
|
|||||||
tabReactions: 'Reactions',
|
tabReactions: 'Reactions',
|
||||||
tabComments: 'Comments',
|
tabComments: 'Comments',
|
||||||
tabAccount: 'Account',
|
tabAccount: 'Account',
|
||||||
|
contributionFiltersLabel: 'Contribution categories',
|
||||||
|
contributionConfig: 'Config',
|
||||||
|
contributionsFilterEmpty: 'No contributions in this category',
|
||||||
|
reactionFiltersLabel: 'Reaction categories',
|
||||||
|
reactionsFilterEmpty: 'No reactions in this category',
|
||||||
|
commentFiltersLabel: 'Comment categories',
|
||||||
|
commentsFilterEmpty: 'No comments in this category',
|
||||||
|
lifeCommentCategory: 'Life',
|
||||||
|
discussionCommentCategory: 'Wiki',
|
||||||
|
passwordTitle: 'Change password',
|
||||||
|
passwordHint: 'Use at least 8 characters.',
|
||||||
|
passwordSaved: 'Password updated',
|
||||||
|
passwordSaveFailed: 'Password update failed',
|
||||||
|
savePassword: 'Save password',
|
||||||
joinedAt: 'Joined {date}',
|
joinedAt: 'Joined {date}',
|
||||||
lifePosts: 'Life posts',
|
lifePosts: 'Life posts',
|
||||||
lifeComments: 'Life comments',
|
lifeComments: 'Life comments',
|
||||||
@@ -662,9 +677,11 @@ export const systemWordingMessages = {
|
|||||||
emailVerified: 'Email verified',
|
emailVerified: 'Email verified',
|
||||||
checkPasswordResetEmail: 'If an account uses this email, a password reset link will be sent.',
|
checkPasswordResetEmail: 'If an account uses this email, a password reset link will be sent.',
|
||||||
passwordResetComplete: 'Password updated. You can log in with the new password.',
|
passwordResetComplete: 'Password updated. You can log in with the new password.',
|
||||||
|
passwordChanged: 'Password updated.',
|
||||||
invalidCredentials: 'Email or password is incorrect',
|
invalidCredentials: 'Email or password is incorrect',
|
||||||
verifyEmailFirst: 'Please complete email verification first',
|
verifyEmailFirst: 'Please complete email verification first',
|
||||||
invalidResetToken: 'The password reset link is invalid or expired',
|
invalidResetToken: 'The password reset link is invalid or expired',
|
||||||
|
currentPasswordInvalid: 'Current password is incorrect',
|
||||||
invalidReferralCode: 'Referral code is invalid'
|
invalidReferralCode: 'Referral code is invalid'
|
||||||
},
|
},
|
||||||
validation: {
|
validation: {
|
||||||
@@ -823,6 +840,7 @@ export const systemWordingMessages = {
|
|||||||
accountAccess: 'Trainer Pass',
|
accountAccess: 'Trainer Pass',
|
||||||
email: '邮箱',
|
email: '邮箱',
|
||||||
password: '密码',
|
password: '密码',
|
||||||
|
currentPassword: '当前密码',
|
||||||
newPassword: '新密码',
|
newPassword: '新密码',
|
||||||
confirmPassword: '确认密码',
|
confirmPassword: '确认密码',
|
||||||
displayName: '显示名',
|
displayName: '显示名',
|
||||||
@@ -872,7 +890,7 @@ export const systemWordingMessages = {
|
|||||||
pages: {
|
pages: {
|
||||||
profile: {
|
profile: {
|
||||||
title: '个人资料',
|
title: '个人资料',
|
||||||
subtitle: '管理账号显示名和邮箱状态。',
|
subtitle: '管理账号资料、邀请信息和密码。',
|
||||||
loading: '正在加载个人资料',
|
loading: '正在加载个人资料',
|
||||||
accountSummary: '账号概览',
|
accountSummary: '账号概览',
|
||||||
profileDetails: '资料详情',
|
profileDetails: '资料详情',
|
||||||
@@ -899,6 +917,20 @@ export const systemWordingMessages = {
|
|||||||
tabReactions: '互动',
|
tabReactions: '互动',
|
||||||
tabComments: '评论',
|
tabComments: '评论',
|
||||||
tabAccount: '账号',
|
tabAccount: '账号',
|
||||||
|
contributionFiltersLabel: '贡献分类',
|
||||||
|
contributionConfig: '配置',
|
||||||
|
contributionsFilterEmpty: '该分类暂无贡献',
|
||||||
|
reactionFiltersLabel: '互动分类',
|
||||||
|
reactionsFilterEmpty: '该分类暂无互动',
|
||||||
|
commentFiltersLabel: '评论分类',
|
||||||
|
commentsFilterEmpty: '该分类暂无评论',
|
||||||
|
lifeCommentCategory: 'Life',
|
||||||
|
discussionCommentCategory: 'Wiki',
|
||||||
|
passwordTitle: '修改密码',
|
||||||
|
passwordHint: '至少使用 8 个字符。',
|
||||||
|
passwordSaved: '密码已更新',
|
||||||
|
passwordSaveFailed: '密码更新失败',
|
||||||
|
savePassword: '保存密码',
|
||||||
joinedAt: '加入于 {date}',
|
joinedAt: '加入于 {date}',
|
||||||
lifePosts: 'Life 动态',
|
lifePosts: 'Life 动态',
|
||||||
lifeComments: 'Life 评论',
|
lifeComments: 'Life 评论',
|
||||||
@@ -1417,9 +1449,11 @@ export const systemWordingMessages = {
|
|||||||
emailVerified: '邮箱已验证',
|
emailVerified: '邮箱已验证',
|
||||||
checkPasswordResetEmail: '如果该邮箱已注册,系统会发送密码重置链接。',
|
checkPasswordResetEmail: '如果该邮箱已注册,系统会发送密码重置链接。',
|
||||||
passwordResetComplete: '密码已更新,请使用新密码登录。',
|
passwordResetComplete: '密码已更新,请使用新密码登录。',
|
||||||
|
passwordChanged: '密码已更新。',
|
||||||
invalidCredentials: '邮箱或密码不正确',
|
invalidCredentials: '邮箱或密码不正确',
|
||||||
verifyEmailFirst: '请先完成邮箱验证',
|
verifyEmailFirst: '请先完成邮箱验证',
|
||||||
invalidResetToken: '密码重置链接无效或已过期',
|
invalidResetToken: '密码重置链接无效或已过期',
|
||||||
|
currentPasswordInvalid: '当前密码不正确',
|
||||||
invalidReferralCode: '邀请码无效'
|
invalidReferralCode: '邀请码无效'
|
||||||
},
|
},
|
||||||
validation: {
|
validation: {
|
||||||
|
|||||||
Reference in New Issue
Block a user