mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
fix(desktop): refine activity profile editing
The token usage profile header had too much top spacing, the edit action competed with the content from the top-right corner, and the profile subtitle was fixed to the default project link. This keeps the profile controls close to the identity block, moves the modal scrim to the document body so it covers the whole desktop shell, and persists a user-editable second line with URL auto-linking. Constraint: Preserve existing profile preferences and backfill older records that do not have a subtitle field. Rejected: Keep the top-right edit button | it kept the action visually detached from the profile content. Rejected: Store the second line only in desktop local state | the preference already has a server-backed profile shape. Confidence: high Scope-risk: narrow Directive: Keep profile preference schema changes backward-compatible with older desktop-ui preference files. Tested: cd desktop && bun run test --run src/pages/ActivitySettings.test.tsx src/api/desktopUiPreferences.test.ts Tested: bun test src/server/__tests__/desktop-ui-preferences.test.ts Tested: bun run check:persistence-upgrade Tested: bun run check:server Tested: bun run check:desktop Tested: git diff --check Not-tested: Full Tauri native shell smoke; renderer flow was smoke-tested in browser only.
This commit is contained in:
parent
ef818f8d8c
commit
587922ba75
@ -6,6 +6,7 @@ const preferences = {
|
||||
schemaVersion: 2,
|
||||
profile: {
|
||||
displayName: 'cc-haha',
|
||||
subtitle: 'github.com/NanmiCoder/cc-haha',
|
||||
avatarFile: null,
|
||||
avatarUpdatedAt: null,
|
||||
},
|
||||
@ -43,7 +44,10 @@ describe('desktopUiPreferencesApi', () => {
|
||||
}))
|
||||
|
||||
await expect(desktopUiPreferencesApi.getPreferences()).resolves.toEqual({ exists: true, preferences })
|
||||
await expect(desktopUiPreferencesApi.updateProfilePreferences({ displayName: 'Local Captain' })).resolves.toEqual({
|
||||
await expect(desktopUiPreferencesApi.updateProfilePreferences({
|
||||
displayName: 'Local Captain',
|
||||
subtitle: 'local.example',
|
||||
})).resolves.toEqual({
|
||||
ok: true,
|
||||
preferences,
|
||||
})
|
||||
@ -54,7 +58,7 @@ describe('desktopUiPreferencesApi', () => {
|
||||
}))
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(2, 'http://127.0.0.1:49237/api/desktop-ui/preferences/profile', expect.objectContaining({
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ displayName: 'Local Captain' }),
|
||||
body: JSON.stringify({ displayName: 'Local Captain', subtitle: 'local.example' }),
|
||||
}))
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(3, 'http://127.0.0.1:49237/api/desktop-ui/preferences/profile/avatar', expect.objectContaining({
|
||||
method: 'DELETE',
|
||||
|
||||
@ -10,6 +10,7 @@ export type SidebarProjectPreferences = {
|
||||
|
||||
export type DesktopProfilePreferences = {
|
||||
displayName: string
|
||||
subtitle: string
|
||||
avatarFile: string | null
|
||||
avatarUpdatedAt: string | null
|
||||
}
|
||||
@ -37,7 +38,7 @@ export const desktopUiPreferencesApi = {
|
||||
)
|
||||
},
|
||||
|
||||
updateProfilePreferences(profile: Pick<DesktopProfilePreferences, 'displayName'>) {
|
||||
updateProfilePreferences(profile: Pick<DesktopProfilePreferences, 'displayName' | 'subtitle'>) {
|
||||
return api.put<{ ok: true; preferences: DesktopUiPreferences }>(
|
||||
'/api/desktop-ui/preferences/profile',
|
||||
profile,
|
||||
|
||||
@ -185,7 +185,8 @@ export const en = {
|
||||
'settings.activity.defaultHandle': 'github.com/NanmiCoder/cc-haha',
|
||||
'settings.activity.editProfile': 'Edit profile',
|
||||
'settings.activity.displayName': 'Display name',
|
||||
'settings.activity.displayNameHelper': 'Only the display name changes here; the second line stays as the project link.',
|
||||
'settings.activity.subtitle': 'Second line',
|
||||
'settings.activity.displayNameHelper': 'Edit the display name and second line; URLs in the second line open as links.',
|
||||
'settings.activity.avatar': 'Avatar',
|
||||
'settings.activity.avatarHelper': 'PNG, JPEG, or WebP up to 2 MB.',
|
||||
'settings.activity.changeAvatar': 'Change avatar',
|
||||
|
||||
@ -187,7 +187,8 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'settings.activity.defaultHandle': 'github.com/NanmiCoder/cc-haha',
|
||||
'settings.activity.editProfile': '编辑个人资料',
|
||||
'settings.activity.displayName': '显示名称',
|
||||
'settings.activity.displayNameHelper': '这里只修改主名称,第二行保持为项目链接。',
|
||||
'settings.activity.subtitle': '第二行',
|
||||
'settings.activity.displayNameHelper': '修改主名称和第二行;第二行如果是网址会自动作为链接打开。',
|
||||
'settings.activity.avatar': '头像',
|
||||
'settings.activity.avatarHelper': '支持 PNG、JPEG 或 WebP,最大 2 MB。',
|
||||
'settings.activity.changeAvatar': '更换头像',
|
||||
|
||||
@ -93,6 +93,7 @@ describe('ActivitySettings', () => {
|
||||
schemaVersion: 2,
|
||||
profile: {
|
||||
displayName: 'cc-haha',
|
||||
subtitle: 'github.com/NanmiCoder/cc-haha',
|
||||
avatarFile: null,
|
||||
avatarUpdatedAt: null,
|
||||
},
|
||||
@ -111,6 +112,7 @@ describe('ActivitySettings', () => {
|
||||
schemaVersion: 2,
|
||||
profile: {
|
||||
displayName: profile.displayName,
|
||||
subtitle: profile.subtitle,
|
||||
avatarFile: null,
|
||||
avatarUpdatedAt: null,
|
||||
},
|
||||
@ -129,6 +131,7 @@ describe('ActivitySettings', () => {
|
||||
schemaVersion: 2,
|
||||
profile: {
|
||||
displayName: 'cc-haha',
|
||||
subtitle: 'github.com/NanmiCoder/cc-haha',
|
||||
avatarFile: 'profile/avatar.png',
|
||||
avatarUpdatedAt: '2026-05-09T12:00:00.000Z',
|
||||
},
|
||||
@ -147,6 +150,7 @@ describe('ActivitySettings', () => {
|
||||
schemaVersion: 2,
|
||||
profile: {
|
||||
displayName: 'cc-haha',
|
||||
subtitle: 'github.com/NanmiCoder/cc-haha',
|
||||
avatarFile: null,
|
||||
avatarUpdatedAt: null,
|
||||
},
|
||||
@ -238,12 +242,17 @@ describe('ActivitySettings', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '编辑个人资料' }))
|
||||
const input = screen.getByLabelText('显示名称')
|
||||
fireEvent.change(input, { target: { value: '本地舰长' } })
|
||||
fireEvent.change(screen.getByLabelText('第二行'), { target: { value: 'relakkes.dev' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存' }))
|
||||
|
||||
await flushActivityLoad()
|
||||
|
||||
expect(updateProfilePreferencesMock).toHaveBeenCalledWith({ displayName: '本地舰长' })
|
||||
expect(updateProfilePreferencesMock).toHaveBeenCalledWith({
|
||||
displayName: '本地舰长',
|
||||
subtitle: 'relakkes.dev',
|
||||
})
|
||||
expect(screen.getByText('本地舰长')).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: 'relakkes.dev' })).toHaveAttribute('href', 'https://relakkes.dev')
|
||||
})
|
||||
|
||||
it('handles avatar upload, fallback, removal, save failure, and cancel reset', async () => {
|
||||
@ -253,6 +262,7 @@ describe('ActivitySettings', () => {
|
||||
schemaVersion: 2,
|
||||
profile: {
|
||||
displayName: 'Local Captain',
|
||||
subtitle: 'Local workspace',
|
||||
avatarFile: 'profile/avatar.webp',
|
||||
avatarUpdatedAt: '2026-05-09T12:00:00.000Z',
|
||||
},
|
||||
@ -266,7 +276,7 @@ describe('ActivitySettings', () => {
|
||||
},
|
||||
})
|
||||
updateProfilePreferencesMock.mockRejectedValueOnce(new Error('display name rejected'))
|
||||
const { container } = render(<ActivitySettings />)
|
||||
render(<ActivitySettings />)
|
||||
|
||||
await flushActivityLoad()
|
||||
|
||||
@ -286,12 +296,14 @@ describe('ActivitySettings', () => {
|
||||
expect(screen.getByText('display name rejected')).toBeInTheDocument()
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Display name'), { target: { value: 'Unsaved Name' } })
|
||||
fireEvent.change(screen.getByLabelText('Second line'), { target: { value: 'Unsaved subtitle' } })
|
||||
const cancelButtons = screen.getAllByRole('button', { name: 'Cancel' })
|
||||
fireEvent.click(cancelButtons[cancelButtons.length - 1]!)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit profile' }))
|
||||
expect(screen.getByLabelText('Display name')).toHaveValue('Local Captain')
|
||||
expect(screen.getByLabelText('Second line')).toHaveValue('Local workspace')
|
||||
|
||||
const input = container.querySelector('input[type="file"]') as HTMLInputElement
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement
|
||||
const file = new File([new Uint8Array([1, 2, 3])], 'avatar.png', { type: 'image/png' })
|
||||
fireEvent.change(input, { target: { files: [file] } })
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState, type ChangeEvent } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { activityStatsApi, type ActivityStatsResponse, type DailyActivity } from '../api/activityStats'
|
||||
import {
|
||||
desktopUiPreferencesApi,
|
||||
@ -52,10 +53,10 @@ const DATE_LOCALES: Record<Locale, string> = {
|
||||
}
|
||||
const DEFAULT_PROFILE: DesktopProfilePreferences = {
|
||||
displayName: 'cc-haha',
|
||||
subtitle: 'github.com/NanmiCoder/cc-haha',
|
||||
avatarFile: null,
|
||||
avatarUpdatedAt: null,
|
||||
}
|
||||
const PROFILE_LINK_URL = 'https://github.com/NanmiCoder/cc-haha'
|
||||
|
||||
function localDateKey(date: Date) {
|
||||
const year = date.getFullYear()
|
||||
@ -125,6 +126,16 @@ function formatMessageCount(value: number, t: ReturnType<typeof useTranslation>)
|
||||
return `${value} ${t('settings.activity.messages')}`
|
||||
}
|
||||
|
||||
function withProfileDefaults(profile: Partial<DesktopProfilePreferences> | null | undefined): DesktopProfilePreferences {
|
||||
return { ...DEFAULT_PROFILE, ...profile }
|
||||
}
|
||||
|
||||
function getProfileSubtitleHref(subtitle: string) {
|
||||
if (/^https?:\/\//i.test(subtitle)) return subtitle
|
||||
if (/^[\w.-]+\.[a-z]{2,}(?:\/.*)?$/i.test(subtitle)) return `https://${subtitle}`
|
||||
return null
|
||||
}
|
||||
|
||||
function calculateHeatCellSize(width: number) {
|
||||
const available = width - HEAT_LABEL_WIDTH - (WEEK_COUNT - 1) * HEAT_CELL_GAP
|
||||
return Math.max(HEAT_CELL_MIN, Math.min(HEAT_CELL_MAX, Math.floor(available / WEEK_COUNT)))
|
||||
@ -341,6 +352,7 @@ export function ActivitySettings() {
|
||||
const [isEditingProfile, setIsEditingProfile] = useState(false)
|
||||
const [isSavingProfile, setIsSavingProfile] = useState(false)
|
||||
const [draftDisplayName, setDraftDisplayName] = useState(DEFAULT_PROFILE.displayName)
|
||||
const [draftSubtitle, setDraftSubtitle] = useState(DEFAULT_PROFILE.subtitle)
|
||||
const [heatmapMode, setHeatmapMode] = useState<HeatmapMode>('daily')
|
||||
const [hoveredDate, setHoveredDate] = useState<string | null>(null)
|
||||
const [focusedDate, setFocusedDate] = useState<string | null>(null)
|
||||
@ -377,9 +389,10 @@ export function ActivitySettings() {
|
||||
desktopUiPreferencesApi.getPreferences()
|
||||
.then((result) => {
|
||||
if (cancelled) return
|
||||
const nextProfile = result.preferences.profile ?? DEFAULT_PROFILE
|
||||
const nextProfile = withProfileDefaults(result.preferences.profile)
|
||||
setProfile(nextProfile)
|
||||
setDraftDisplayName(nextProfile.displayName)
|
||||
setDraftSubtitle(nextProfile.subtitle)
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled) return
|
||||
@ -480,6 +493,7 @@ export function ActivitySettings() {
|
||||
const avatarClassName = profile.avatarFile
|
||||
? 'h-full w-full object-cover'
|
||||
: 'h-full w-full scale-[1.28] object-contain transition-transform'
|
||||
const profileSubtitleHref = getProfileSubtitleHref(profile.subtitle)
|
||||
const hasUsage = Boolean(stats && (stats.totalSessions > 0 || totalTokens > 0))
|
||||
const modeOptions: Array<{ mode: HeatmapMode; label: string; help: string }> = [
|
||||
{ mode: 'daily', label: t('settings.activity.mode.daily'), help: t('settings.activity.modeHelp.daily') },
|
||||
@ -492,9 +506,14 @@ export function ActivitySettings() {
|
||||
setProfileError(null)
|
||||
setProfileStatus(null)
|
||||
try {
|
||||
const result = await desktopUiPreferencesApi.updateProfilePreferences({ displayName: draftDisplayName })
|
||||
setProfile(result.preferences.profile)
|
||||
setDraftDisplayName(result.preferences.profile.displayName)
|
||||
const result = await desktopUiPreferencesApi.updateProfilePreferences({
|
||||
displayName: draftDisplayName,
|
||||
subtitle: draftSubtitle,
|
||||
})
|
||||
const nextProfile = withProfileDefaults(result.preferences.profile)
|
||||
setProfile(nextProfile)
|
||||
setDraftDisplayName(nextProfile.displayName)
|
||||
setDraftSubtitle(nextProfile.subtitle)
|
||||
setIsEditingProfile(false)
|
||||
setProfileStatus(t('settings.activity.profileSaved'))
|
||||
} catch (err) {
|
||||
@ -513,8 +532,10 @@ export function ActivitySettings() {
|
||||
setProfileStatus(null)
|
||||
try {
|
||||
const result = await desktopUiPreferencesApi.uploadProfileAvatar(file)
|
||||
setProfile(result.preferences.profile)
|
||||
setDraftDisplayName(result.preferences.profile.displayName)
|
||||
const nextProfile = withProfileDefaults(result.preferences.profile)
|
||||
setProfile(nextProfile)
|
||||
setDraftDisplayName(nextProfile.displayName)
|
||||
setDraftSubtitle(nextProfile.subtitle)
|
||||
setProfileStatus(t('settings.activity.profileSaved'))
|
||||
} catch (err) {
|
||||
setProfileError(err instanceof Error ? err.message : t('settings.activity.profileSaveFailed'))
|
||||
@ -529,7 +550,7 @@ export function ActivitySettings() {
|
||||
setProfileStatus(null)
|
||||
try {
|
||||
const result = await desktopUiPreferencesApi.deleteProfileAvatar()
|
||||
setProfile(result.preferences.profile)
|
||||
setProfile(withProfileDefaults(result.preferences.profile))
|
||||
setProfileStatus(t('settings.activity.profileSaved'))
|
||||
} catch (err) {
|
||||
setProfileError(err instanceof Error ? err.message : t('settings.activity.profileSaveFailed'))
|
||||
@ -540,21 +561,8 @@ export function ActivitySettings() {
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[1160px] min-w-0 pb-12">
|
||||
<section className="relative flex min-h-[310px] flex-col items-center justify-end pt-12 text-center">
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-0 top-0 inline-flex h-8 items-center gap-1.5 rounded-md px-2.5 text-sm font-medium text-[var(--color-text-secondary)] transition-[background-color,transform] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] active:translate-y-[1px] disabled:opacity-50"
|
||||
onClick={() => {
|
||||
setIsEditingProfile(true)
|
||||
setDraftDisplayName(profile.displayName)
|
||||
}}
|
||||
disabled={isProfileLoading}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">edit</span>
|
||||
{t('settings.activity.editProfile')}
|
||||
</button>
|
||||
|
||||
<div className="relative h-24 w-24 overflow-hidden rounded-full border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[0_12px_34px_-24px_rgba(15,23,42,0.65)]">
|
||||
<section className="relative flex min-h-[245px] flex-col items-center justify-start pt-6 text-center">
|
||||
<div className="relative h-[88px] w-[88px] overflow-hidden rounded-full border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[0_12px_34px_-24px_rgba(15,23,42,0.65)]">
|
||||
<img
|
||||
src={avatarSrc}
|
||||
alt={`${profile.displayName} avatar`}
|
||||
@ -565,20 +573,40 @@ export function ActivitySettings() {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<h1 className="mt-8 max-w-full truncate text-4xl font-semibold tracking-tight text-[var(--color-text-primary)] sm:text-[44px]">{profile.displayName}</h1>
|
||||
<a
|
||||
href={PROFILE_LINK_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="mt-4 inline-flex max-w-full items-center justify-center gap-2 truncate text-lg text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-text-primary)]"
|
||||
>
|
||||
<span>{t('settings.activity.defaultHandle')}</span>
|
||||
</a>
|
||||
<div className="mt-6 flex max-w-full items-center justify-center gap-2">
|
||||
<h1 className="max-w-[min(720px,calc(100%-2.25rem))] truncate text-4xl font-semibold tracking-tight text-[var(--color-text-primary)] sm:text-[44px]">{profile.displayName}</h1>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('settings.activity.editProfile')}
|
||||
title={t('settings.activity.editProfile')}
|
||||
className="mt-1 inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-[var(--color-text-tertiary)] transition-[background-color,color,transform] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-brand)] focus:ring-offset-2 focus:ring-offset-[var(--color-surface)] active:translate-y-[1px] disabled:opacity-50"
|
||||
onClick={() => {
|
||||
setIsEditingProfile(true)
|
||||
setDraftDisplayName(profile.displayName)
|
||||
setDraftSubtitle(profile.subtitle)
|
||||
}}
|
||||
disabled={isProfileLoading}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">edit</span>
|
||||
</button>
|
||||
</div>
|
||||
{profileSubtitleHref ? (
|
||||
<a
|
||||
href={profileSubtitleHref}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="mt-3 inline-flex max-w-full items-center justify-center gap-2 truncate text-lg text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-text-primary)]"
|
||||
>
|
||||
<span>{profile.subtitle}</span>
|
||||
</a>
|
||||
) : (
|
||||
<div className="mt-3 max-w-full truncate text-lg text-[var(--color-text-tertiary)]">{profile.subtitle}</div>
|
||||
)}
|
||||
{profileStatus && <div className="mt-3 text-xs text-[var(--color-success)]">{profileStatus}</div>}
|
||||
{profileError && !isEditingProfile && <div className="mt-3 text-xs text-[var(--color-error)]">{profileError}</div>}
|
||||
</section>
|
||||
|
||||
<section className="mx-auto mt-10 overflow-hidden rounded-[18px] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)]">
|
||||
<section className="mx-auto mt-8 overflow-hidden rounded-[18px] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)]">
|
||||
{isLoading ? (
|
||||
<div className="grid gap-0 sm:grid-cols-2 xl:grid-cols-5">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
@ -602,8 +630,8 @@ export function ActivitySettings() {
|
||||
)}
|
||||
</section>
|
||||
|
||||
{isEditingProfile && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/25 px-4 py-8" role="dialog" aria-modal="true" aria-labelledby="activity-profile-dialog-title">
|
||||
{isEditingProfile && createPortal(
|
||||
<div className="fixed inset-0 z-[10000] flex items-center justify-center bg-[var(--color-overlay-scrim)] px-4 py-8" role="dialog" aria-modal="true" aria-labelledby="activity-profile-dialog-title">
|
||||
<div className="w-full max-w-[420px] rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] p-5 shadow-2xl">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
@ -616,6 +644,7 @@ export function ActivitySettings() {
|
||||
onClick={() => {
|
||||
setIsEditingProfile(false)
|
||||
setDraftDisplayName(profile.displayName)
|
||||
setDraftSubtitle(profile.subtitle)
|
||||
setProfileError(null)
|
||||
}}
|
||||
aria-label={t('settings.activity.cancelEdit')}
|
||||
@ -637,6 +666,18 @@ export function ActivitySettings() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<label htmlFor="activity-profile-subtitle" className="text-xs font-medium text-[var(--color-text-secondary)]">
|
||||
{t('settings.activity.subtitle')}
|
||||
</label>
|
||||
<input
|
||||
id="activity-profile-subtitle"
|
||||
value={draftSubtitle}
|
||||
onChange={(event) => setDraftSubtitle(event.target.value)}
|
||||
className="h-10 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-3 text-sm text-[var(--color-text-primary)] outline-none transition-colors focus:border-[var(--color-border-focus)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<div className="text-xs font-medium text-[var(--color-text-secondary)]">{t('settings.activity.avatar')}</div>
|
||||
<p className="text-xs text-[var(--color-text-tertiary)]">{t('settings.activity.avatarHelper')}</p>
|
||||
@ -678,6 +719,7 @@ export function ActivitySettings() {
|
||||
onClick={() => {
|
||||
setIsEditingProfile(false)
|
||||
setDraftDisplayName(profile.displayName)
|
||||
setDraftSubtitle(profile.subtitle)
|
||||
setProfileError(null)
|
||||
}}
|
||||
>
|
||||
@ -693,7 +735,8 @@ export function ActivitySettings() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
|
||||
<div className="mt-16">
|
||||
|
||||
@ -59,6 +59,7 @@ describe('DesktopUiPreferencesService', () => {
|
||||
schemaVersion: 2,
|
||||
profile: {
|
||||
displayName: 'cc-haha',
|
||||
subtitle: 'github.com/NanmiCoder/cc-haha',
|
||||
avatarFile: null,
|
||||
avatarUpdatedAt: null,
|
||||
},
|
||||
@ -101,6 +102,7 @@ describe('DesktopUiPreferencesService', () => {
|
||||
futureField: { keep: true },
|
||||
profile: {
|
||||
displayName: 'cc-haha',
|
||||
subtitle: 'github.com/NanmiCoder/cc-haha',
|
||||
avatarFile: null,
|
||||
avatarUpdatedAt: null,
|
||||
},
|
||||
@ -117,6 +119,7 @@ describe('DesktopUiPreferencesService', () => {
|
||||
futureField: { keep: true },
|
||||
profile: {
|
||||
displayName: 'cc-haha',
|
||||
subtitle: 'github.com/NanmiCoder/cc-haha',
|
||||
avatarFile: null,
|
||||
avatarUpdatedAt: null,
|
||||
},
|
||||
@ -149,6 +152,7 @@ describe('DesktopUiPreferencesService', () => {
|
||||
const service = new DesktopUiPreferencesService()
|
||||
const after = await service.updateProfilePreferences({
|
||||
displayName: ' Claude Captain ',
|
||||
subtitle: ' local.example/profile ',
|
||||
avatarFile: '../escape.png',
|
||||
avatarUpdatedAt: 42,
|
||||
})
|
||||
@ -157,6 +161,7 @@ describe('DesktopUiPreferencesService', () => {
|
||||
schemaVersion: 2,
|
||||
profile: {
|
||||
displayName: 'Claude Captain',
|
||||
subtitle: 'local.example/profile',
|
||||
avatarFile: null,
|
||||
avatarUpdatedAt: null,
|
||||
},
|
||||
@ -226,6 +231,7 @@ describe('desktop UI preferences API', () => {
|
||||
schemaVersion: 2,
|
||||
profile: {
|
||||
displayName: 'cc-haha',
|
||||
subtitle: 'github.com/NanmiCoder/cc-haha',
|
||||
avatarFile: null,
|
||||
avatarUpdatedAt: null,
|
||||
},
|
||||
@ -250,6 +256,7 @@ describe('desktop UI preferences API', () => {
|
||||
schemaVersion: 2,
|
||||
profile: {
|
||||
displayName: 'cc-haha',
|
||||
subtitle: 'github.com/NanmiCoder/cc-haha',
|
||||
avatarFile: null,
|
||||
avatarUpdatedAt: null,
|
||||
},
|
||||
@ -267,6 +274,7 @@ describe('desktop UI preferences API', () => {
|
||||
test('persists profile preferences and avatar uploads through the API', async () => {
|
||||
const profileReq = makeRequest('PUT', '/api/desktop-ui/preferences/profile', {
|
||||
displayName: ' Local Operator ',
|
||||
subtitle: ' operator.example ',
|
||||
})
|
||||
const profileRes = await handleDesktopUiApi(profileReq.req, profileReq.url, profileReq.segments)
|
||||
const profileBody = await profileRes.json() as Record<string, unknown>
|
||||
@ -277,6 +285,7 @@ describe('desktop UI preferences API', () => {
|
||||
preferences: {
|
||||
profile: {
|
||||
displayName: 'Local Operator',
|
||||
subtitle: 'operator.example',
|
||||
avatarFile: null,
|
||||
},
|
||||
},
|
||||
@ -297,6 +306,7 @@ describe('desktop UI preferences API', () => {
|
||||
preferences: {
|
||||
profile: {
|
||||
displayName: 'Local Operator',
|
||||
subtitle: 'operator.example',
|
||||
avatarFile: 'profile/avatar.jpg',
|
||||
},
|
||||
},
|
||||
|
||||
@ -9,7 +9,9 @@ import { ensurePersistentStorageUpgraded } from './persistentStorageMigrations.j
|
||||
const CURRENT_DESKTOP_UI_PREFERENCES_SCHEMA_VERSION = 2
|
||||
const MAX_PROJECT_PREFERENCE_ENTRIES = 2_000
|
||||
const MAX_PROFILE_DISPLAY_NAME_LENGTH = 80
|
||||
const MAX_PROFILE_SUBTITLE_LENGTH = 160
|
||||
const MAX_PROFILE_AVATAR_BYTES = 2_000_000
|
||||
const DEFAULT_PROFILE_SUBTITLE = 'github.com/NanmiCoder/cc-haha'
|
||||
|
||||
const AVATAR_CONTENT_TYPES = {
|
||||
'image/png': { extension: 'png', mediaType: 'image/png' },
|
||||
@ -27,6 +29,7 @@ export type SidebarProjectPreferences = {
|
||||
|
||||
export type DesktopProfilePreferences = {
|
||||
displayName: string
|
||||
subtitle: string
|
||||
avatarFile: string | null
|
||||
avatarUpdatedAt: string | null
|
||||
}
|
||||
@ -53,6 +56,7 @@ const DEFAULT_SIDEBAR_PROJECT_PREFERENCES: SidebarProjectPreferences = {
|
||||
|
||||
const DEFAULT_PROFILE_PREFERENCES: DesktopProfilePreferences = {
|
||||
displayName: 'cc-haha',
|
||||
subtitle: DEFAULT_PROFILE_SUBTITLE,
|
||||
avatarFile: null,
|
||||
avatarUpdatedAt: null,
|
||||
}
|
||||
@ -102,6 +106,13 @@ function normalizeProfileDisplayName(value: unknown): string {
|
||||
return trimmed.slice(0, MAX_PROFILE_DISPLAY_NAME_LENGTH)
|
||||
}
|
||||
|
||||
function normalizeProfileSubtitle(value: unknown): string {
|
||||
if (typeof value !== 'string') return DEFAULT_PROFILE_PREFERENCES.subtitle
|
||||
const trimmed = value.trim().replace(/\s+/g, ' ')
|
||||
if (trimmed.length === 0) return DEFAULT_PROFILE_PREFERENCES.subtitle
|
||||
return trimmed.slice(0, MAX_PROFILE_SUBTITLE_LENGTH)
|
||||
}
|
||||
|
||||
function normalizeAvatarFile(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null
|
||||
if (!/^profile\/avatar\.(png|jpg|webp)$/.test(value)) return null
|
||||
@ -116,6 +127,7 @@ function normalizeProfilePreferences(value: unknown): DesktopProfilePreferences
|
||||
const record = value as Record<string, unknown>
|
||||
return {
|
||||
displayName: normalizeProfileDisplayName(record.displayName),
|
||||
subtitle: normalizeProfileSubtitle(record.subtitle),
|
||||
avatarFile: normalizeAvatarFile(record.avatarFile),
|
||||
avatarUpdatedAt: typeof record.avatarUpdatedAt === 'string' ? record.avatarUpdatedAt : null,
|
||||
}
|
||||
@ -265,6 +277,9 @@ export class DesktopUiPreferencesService {
|
||||
displayName: Object.prototype.hasOwnProperty.call(patch, 'displayName')
|
||||
? patch.displayName
|
||||
: currentProfile.displayName,
|
||||
subtitle: Object.prototype.hasOwnProperty.call(patch, 'subtitle')
|
||||
? patch.subtitle
|
||||
: currentProfile.subtitle,
|
||||
})
|
||||
const nextPreferences: DesktopUiPreferences = {
|
||||
...preferences,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user