mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
Add jp / kr / zh-TW translations and wire them into the i18n runtime so the desktop app can switch among English, Simplified Chinese, Traditional Chinese, Japanese, and Korean. - new locale files jp.ts / kr.ts / zh-TW.ts with full TranslationKey coverage - register the locales in the i18n index and extend the Locale union - accept the new codes in settingsStore.getStoredLocale (default stays zh) - add language-switcher entries (简体中文 / 繁體中文 / 日本語 / 한국어) - localize chat message timestamps: Han 年月日 for zh/zh-TW/jp, Intl ko-KR for kr - extend the DATE_LOCALES map in ActivitySettings (ja-JP / ko-KR / zh-TW) - tests: formatMessageTimestamp locale branches + i18n locale resolution Korean terminology follows the Microsoft Korean localization style guide (새로 고침 / 사용·사용 안 함 / 이름 바꾸기, 합니다-style sentences). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
903 lines
37 KiB
TypeScript
903 lines
37 KiB
TypeScript
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,
|
|
getProfileAvatarUrl,
|
|
type DesktopProfilePreferences,
|
|
} from '../api/desktopUiPreferences'
|
|
import { type Locale, useTranslation } from '../i18n'
|
|
import { useSettingsStore } from '../stores/settingsStore'
|
|
|
|
type HeatmapDay = {
|
|
date: string
|
|
sessionCount: number
|
|
messageCount: number
|
|
toolCallCount: number
|
|
tokens: number
|
|
level: number
|
|
mode: HeatmapMode
|
|
rangeStart?: string
|
|
rangeEnd?: string
|
|
}
|
|
|
|
type SummaryMetric = {
|
|
label: string
|
|
value: string
|
|
detail?: string
|
|
}
|
|
|
|
type HeatmapMode = 'daily' | 'weekly' | 'cumulative'
|
|
|
|
const WEEK_COUNT = 52
|
|
const WEEKDAY_LABEL_KEYS = [
|
|
'settings.activity.weekday.mon',
|
|
'settings.activity.weekday.wed',
|
|
'settings.activity.weekday.fri',
|
|
] as const
|
|
const HEAT_CELL_GAP = 3
|
|
const HEAT_LABEL_WIDTH = 38
|
|
const HEAT_CELL_MIN = 6
|
|
const HEAT_CELL_MAX = 22
|
|
const TOOLTIP_WIDTH = 172
|
|
const HEAT_COLORS = [
|
|
'var(--color-activity-heat-0)',
|
|
'var(--color-activity-heat-1)',
|
|
'var(--color-activity-heat-2)',
|
|
'var(--color-activity-heat-3)',
|
|
'var(--color-activity-heat-4)',
|
|
]
|
|
const DATE_LOCALES: Record<Locale, string> = {
|
|
en: 'en-US',
|
|
zh: 'zh-CN',
|
|
'zh-TW': 'zh-TW',
|
|
jp: 'ja-JP',
|
|
kr: 'ko-KR',
|
|
}
|
|
const DEFAULT_PROFILE: DesktopProfilePreferences = {
|
|
displayName: 'cc-haha',
|
|
subtitle: 'github.com/NanmiCoder/cc-haha',
|
|
avatarFile: null,
|
|
avatarUpdatedAt: null,
|
|
}
|
|
|
|
function localDateKey(date: Date) {
|
|
const year = date.getFullYear()
|
|
const month = `${date.getMonth() + 1}`.padStart(2, '0')
|
|
const day = `${date.getDate()}`.padStart(2, '0')
|
|
return `${year}-${month}-${day}`
|
|
}
|
|
|
|
function parseLocalDate(dateKey: string) {
|
|
return new Date(`${dateKey}T00:00:00`)
|
|
}
|
|
|
|
function addDays(date: Date, days: number) {
|
|
const next = new Date(date)
|
|
next.setDate(next.getDate() + days)
|
|
return next
|
|
}
|
|
|
|
function startOfWeek(date: Date) {
|
|
const next = new Date(date)
|
|
next.setHours(0, 0, 0, 0)
|
|
next.setDate(next.getDate() - next.getDay())
|
|
return next
|
|
}
|
|
|
|
function formatDateLabel(dateKey: string, locale: Locale) {
|
|
return parseLocalDate(dateKey).toLocaleDateString(DATE_LOCALES[locale], {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
year: 'numeric',
|
|
})
|
|
}
|
|
|
|
function formatTokens(tokens: number) {
|
|
if (tokens >= 1_000_000_000) return `${(tokens / 1_000_000_000).toFixed(tokens >= 10_000_000_000 ? 0 : 1)}B`
|
|
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(tokens >= 10_000_000 ? 0 : 1)}M`
|
|
if (tokens >= 1_000) return `${Math.round(tokens / 1_000)}K`
|
|
return `${tokens}`
|
|
}
|
|
|
|
function formatDayCount(value: number, t: ReturnType<typeof useTranslation>) {
|
|
return t(value === 1 ? 'settings.activity.count.dayOne' : 'settings.activity.count.dayOther', { count: value })
|
|
}
|
|
|
|
function formatTaskDuration(duration: number | undefined, locale: Locale, t: ReturnType<typeof useTranslation>) {
|
|
if (!duration || duration <= 0) return t('settings.activity.noDuration')
|
|
const totalMinutes = Math.max(1, Math.round(duration / 60_000))
|
|
const hours = Math.floor(totalMinutes / 60)
|
|
const minutes = totalMinutes % 60
|
|
|
|
if (locale === 'zh') {
|
|
if (hours > 0 && minutes > 0) return `${hours} 小时 ${minutes} 分钟`
|
|
if (hours > 0) return `${hours} 小时`
|
|
return `${minutes} 分钟`
|
|
}
|
|
|
|
if (hours > 0 && minutes > 0) return `${hours}h ${minutes}m`
|
|
if (hours > 0) return `${hours}h`
|
|
return `${minutes}m`
|
|
}
|
|
|
|
function formatSessionCount(value: number, t: ReturnType<typeof useTranslation>) {
|
|
return t(value === 1 ? 'settings.activity.count.sessionOne' : 'settings.activity.count.sessionOther', { count: value })
|
|
}
|
|
|
|
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)))
|
|
}
|
|
|
|
function sumDailyUsage(days: HeatmapDay[]) {
|
|
return days.reduce(
|
|
(sum, day) => ({
|
|
sessions: sum.sessions + day.sessionCount,
|
|
tokens: sum.tokens + day.tokens,
|
|
}),
|
|
{ sessions: 0, tokens: 0 },
|
|
)
|
|
}
|
|
|
|
function getDailyTokenMap(stats: ActivityStatsResponse | null) {
|
|
const map = new Map<string, number>()
|
|
for (const day of stats?.dailyModelTokens ?? []) {
|
|
const total = Object.values(day.tokensByModel).reduce((sum, tokens) => sum + tokens, 0)
|
|
map.set(day.date, total)
|
|
}
|
|
return map
|
|
}
|
|
|
|
function getHeatLevel(day: DailyActivity | undefined, tokens: number, maxScore: number) {
|
|
const sessionCount = day?.sessionCount ?? 0
|
|
if (sessionCount === 0 && tokens === 0) return 0
|
|
if (maxScore <= 0) return 1
|
|
|
|
const score = sessionCount * 3 + Math.ceil(tokens / 50_000)
|
|
const ratio = score / maxScore
|
|
if (ratio >= 0.78) return 4
|
|
if (ratio >= 0.5) return 3
|
|
if (ratio >= 0.24) return 2
|
|
return 1
|
|
}
|
|
|
|
function getBarHeight(value: number, maxValue: number) {
|
|
if (value <= 0 || maxValue <= 0) return 0
|
|
return Math.max(1, Math.min(7, Math.ceil((value / maxValue) * 7)))
|
|
}
|
|
|
|
function getBarLevel(value: number, maxValue: number) {
|
|
if (value <= 0) return 0
|
|
if (maxValue <= 0) return 1
|
|
const ratio = value / maxValue
|
|
if (ratio >= 0.78) return 4
|
|
if (ratio >= 0.5) return 3
|
|
if (ratio >= 0.24) return 2
|
|
return 1
|
|
}
|
|
|
|
function buildHeatmapDays(stats: ActivityStatsResponse | null, mode: HeatmapMode) {
|
|
const today = new Date()
|
|
today.setHours(0, 0, 0, 0)
|
|
|
|
const finalWeekStart = startOfWeek(today)
|
|
const start = addDays(finalWeekStart, -(WEEK_COUNT - 1) * 7)
|
|
const activityMap = new Map((stats?.dailyActivity ?? []).map((day) => [day.date, day]))
|
|
const tokenMap = getDailyTokenMap(stats)
|
|
const dates: string[] = []
|
|
for (let cursor = new Date(start); cursor <= today; cursor = addDays(cursor, 1)) {
|
|
dates.push(localDateKey(cursor))
|
|
}
|
|
|
|
const scores: number[] = []
|
|
let cumulativeTokens = 0
|
|
for (const dateKey of dates) {
|
|
const day = activityMap.get(dateKey)
|
|
const tokens = tokenMap.get(dateKey) ?? 0
|
|
cumulativeTokens += tokens
|
|
scores.push((day?.sessionCount ?? 0) * 3 + Math.ceil(tokens / 50_000))
|
|
}
|
|
const maxScore = Math.max(...scores, 0)
|
|
|
|
const days: HeatmapDay[] = []
|
|
cumulativeTokens = 0
|
|
for (const dateKey of dates) {
|
|
const day = activityMap.get(dateKey)
|
|
const tokens = tokenMap.get(dateKey) ?? 0
|
|
cumulativeTokens += tokens
|
|
days.push({
|
|
date: dateKey,
|
|
sessionCount: day?.sessionCount ?? 0,
|
|
messageCount: day?.messageCount ?? 0,
|
|
toolCallCount: day?.toolCallCount ?? 0,
|
|
tokens,
|
|
level: getHeatLevel(day, tokens, maxScore),
|
|
mode: 'daily',
|
|
})
|
|
}
|
|
|
|
if (mode === 'daily') return days
|
|
|
|
const weeks = Array.from({ length: WEEK_COUNT }, (_, index) => {
|
|
const rangeStart = dates[index * 7] ?? ''
|
|
const rangeEnd = dates[Math.min(index * 7 + 6, dates.length - 1)] ?? rangeStart
|
|
return {
|
|
rangeStart,
|
|
rangeEnd,
|
|
sessionCount: 0,
|
|
messageCount: 0,
|
|
toolCallCount: 0,
|
|
tokens: 0,
|
|
cumulativeTokens: 0,
|
|
}
|
|
})
|
|
|
|
dates.forEach((dateKey, index) => {
|
|
const week = weeks[Math.floor(index / 7)]
|
|
const day = activityMap.get(dateKey)
|
|
if (!week) return
|
|
week.sessionCount += day?.sessionCount ?? 0
|
|
week.messageCount += day?.messageCount ?? 0
|
|
week.toolCallCount += day?.toolCallCount ?? 0
|
|
week.tokens += tokenMap.get(dateKey) ?? 0
|
|
})
|
|
|
|
let runningTotal = 0
|
|
for (const week of weeks) {
|
|
runningTotal += week.tokens
|
|
week.cumulativeTokens = runningTotal
|
|
}
|
|
|
|
const maxValue = Math.max(
|
|
...weeks.map((week) => (mode === 'weekly' ? week.tokens : week.cumulativeTokens)),
|
|
0,
|
|
)
|
|
|
|
return dates.map((dateKey, index) => {
|
|
const week = weeks[Math.floor(index / 7)]
|
|
const row = index % 7
|
|
const tokens = mode === 'weekly' ? week?.tokens ?? 0 : week?.cumulativeTokens ?? 0
|
|
const height = getBarHeight(tokens, maxValue)
|
|
const isFilled = height > 0 && row >= 7 - height
|
|
|
|
return {
|
|
date: dateKey,
|
|
sessionCount: week?.sessionCount ?? 0,
|
|
messageCount: week?.messageCount ?? 0,
|
|
toolCallCount: week?.toolCallCount ?? 0,
|
|
tokens,
|
|
level: isFilled ? getBarLevel(tokens, maxValue) : 0,
|
|
mode,
|
|
rangeStart: week?.rangeStart,
|
|
rangeEnd: week?.rangeEnd,
|
|
}
|
|
})
|
|
}
|
|
|
|
function buildMonthLabels(days: HeatmapDay[], locale: Locale) {
|
|
if (days.length === 0) return []
|
|
const labels: Array<{ week: number; label: string }> = []
|
|
const firstDay = days[0]
|
|
const lastDay = days[days.length - 1]
|
|
if (!firstDay || !lastDay) return labels
|
|
|
|
const firstDate = parseLocalDate(firstDay.date)
|
|
const lastDate = parseLocalDate(lastDay.date)
|
|
let previousMonth = -1
|
|
|
|
for (let week = 0; week < WEEK_COUNT; week += 1) {
|
|
const weekDate = addDays(firstDate, week * 7)
|
|
if (weekDate > lastDate) break
|
|
if (weekDate.getMonth() !== previousMonth) {
|
|
labels.push({
|
|
week,
|
|
label: weekDate.toLocaleDateString(DATE_LOCALES[locale], { month: 'short' }),
|
|
})
|
|
previousMonth = weekDate.getMonth()
|
|
}
|
|
}
|
|
|
|
return labels
|
|
}
|
|
|
|
function getHeatmapCellTitle(day: HeatmapDay, locale: Locale, t: ReturnType<typeof useTranslation>) {
|
|
if (day.mode === 'weekly') {
|
|
return t('settings.activity.weekRange', {
|
|
start: formatDateLabel(day.rangeStart ?? day.date, locale),
|
|
end: formatDateLabel(day.rangeEnd ?? day.date, locale),
|
|
})
|
|
}
|
|
|
|
if (day.mode === 'cumulative') {
|
|
return t('settings.activity.cumulativeThrough', {
|
|
date: formatDateLabel(day.rangeEnd ?? day.date, locale),
|
|
})
|
|
}
|
|
|
|
return formatDateLabel(day.date, locale)
|
|
}
|
|
|
|
function getHeatmapCellDetail(day: HeatmapDay, t: ReturnType<typeof useTranslation>) {
|
|
if (day.mode === 'cumulative') {
|
|
return t('settings.activity.tokenValue', { tokens: formatTokens(day.tokens) })
|
|
}
|
|
|
|
return `${formatSessionCount(day.sessionCount, t)} · ${formatTokens(day.tokens)} ${t('settings.activity.tokens')}`
|
|
}
|
|
|
|
export function ActivitySettings() {
|
|
const t = useTranslation()
|
|
const locale = useSettingsStore((state) => state.locale)
|
|
const heatmapMeasureRef = useRef<HTMLDivElement | null>(null)
|
|
const avatarInputRef = useRef<HTMLInputElement | null>(null)
|
|
const [stats, setStats] = useState<ActivityStatsResponse | null>(null)
|
|
const [isLoading, setIsLoading] = useState(true)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [profile, setProfile] = useState<DesktopProfilePreferences>(DEFAULT_PROFILE)
|
|
const [profileError, setProfileError] = useState<string | null>(null)
|
|
const [profileStatus, setProfileStatus] = useState<string | null>(null)
|
|
const [isProfileLoading, setIsProfileLoading] = useState(true)
|
|
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)
|
|
const [heatCellSize, setHeatCellSize] = useState(10)
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
setIsLoading(true)
|
|
setError(null)
|
|
|
|
activityStatsApi.getStats('all')
|
|
.then((nextStats) => {
|
|
if (cancelled) return
|
|
setStats(nextStats)
|
|
})
|
|
.catch((err) => {
|
|
if (cancelled) return
|
|
setError(err instanceof Error ? err.message : String(err))
|
|
})
|
|
.finally(() => {
|
|
if (!cancelled) setIsLoading(false)
|
|
})
|
|
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
setIsProfileLoading(true)
|
|
setProfileError(null)
|
|
|
|
desktopUiPreferencesApi.getPreferences()
|
|
.then((result) => {
|
|
if (cancelled) return
|
|
const nextProfile = withProfileDefaults(result.preferences.profile)
|
|
setProfile(nextProfile)
|
|
setDraftDisplayName(nextProfile.displayName)
|
|
setDraftSubtitle(nextProfile.subtitle)
|
|
})
|
|
.catch((err) => {
|
|
if (cancelled) return
|
|
setProfileError(err instanceof Error ? err.message : String(err))
|
|
})
|
|
.finally(() => {
|
|
if (!cancelled) setIsProfileLoading(false)
|
|
})
|
|
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (isLoading || error) return
|
|
const element = heatmapMeasureRef.current
|
|
if (!element) return
|
|
|
|
const updateCellSize = () => {
|
|
const nextSize = calculateHeatCellSize(element.clientWidth)
|
|
setHeatCellSize((current) => (current === nextSize ? current : nextSize))
|
|
}
|
|
|
|
updateCellSize()
|
|
|
|
if (typeof ResizeObserver !== 'undefined') {
|
|
const observer = new ResizeObserver(updateCellSize)
|
|
observer.observe(element)
|
|
return () => observer.disconnect()
|
|
}
|
|
|
|
window.addEventListener('resize', updateCellSize)
|
|
return () => window.removeEventListener('resize', updateCellSize)
|
|
}, [error, isLoading])
|
|
|
|
const days = useMemo(() => buildHeatmapDays(stats, heatmapMode), [heatmapMode, stats])
|
|
const monthLabels = useMemo(() => buildMonthLabels(days, locale), [days, locale])
|
|
const today = days.length > 0 ? days[days.length - 1] : null
|
|
const activeTooltipDate = hoveredDate ?? focusedDate
|
|
const tooltipDay = days.find((day) => day.date === activeTooltipDate) ?? null
|
|
const tooltipIndex = tooltipDay ? days.findIndex((day) => day.date === tooltipDay.date) : -1
|
|
const heatGridWidth = WEEK_COUNT * heatCellSize + (WEEK_COUNT - 1) * HEAT_CELL_GAP
|
|
const heatGridHeight = 7 * heatCellSize + 6 * HEAT_CELL_GAP
|
|
const heatmapWidth = HEAT_LABEL_WIDTH + heatGridWidth
|
|
const tooltipStyle = tooltipIndex >= 0
|
|
? {
|
|
left: Math.max(
|
|
HEAT_LABEL_WIDTH,
|
|
Math.min(
|
|
heatmapWidth - TOOLTIP_WIDTH,
|
|
HEAT_LABEL_WIDTH + Math.floor(tooltipIndex / 7) * (heatCellSize + HEAT_CELL_GAP) - 52,
|
|
),
|
|
),
|
|
top: Math.max(28, 30 + (tooltipIndex % 7) * (heatCellSize + HEAT_CELL_GAP) - 50),
|
|
}
|
|
: undefined
|
|
const last30Usage = sumDailyUsage(days.slice(-30))
|
|
const totalTokens = useMemo(() => {
|
|
return (stats?.dailyModelTokens ?? []).reduce((sum, day) => (
|
|
sum + Object.values(day.tokensByModel).reduce((daySum, tokens) => daySum + tokens, 0)
|
|
), 0)
|
|
}, [stats])
|
|
const peakTokens = useMemo(() => {
|
|
return (stats?.dailyModelTokens ?? []).reduce((peak, day) => {
|
|
const dayTotal = Object.values(day.tokensByModel).reduce((sum, tokens) => sum + tokens, 0)
|
|
return Math.max(peak, dayTotal)
|
|
}, 0)
|
|
}, [stats])
|
|
const metrics: SummaryMetric[] = [
|
|
{
|
|
label: t('settings.activity.totalTokens'),
|
|
value: formatTokens(totalTokens),
|
|
detail: formatDayCount(stats?.activeDays ?? 0, t),
|
|
},
|
|
{
|
|
label: t('settings.activity.peakTokens'),
|
|
value: formatTokens(peakTokens),
|
|
detail: stats?.peakActivityDay ? formatDateLabel(stats.peakActivityDay, locale) : undefined,
|
|
},
|
|
{
|
|
label: t('settings.activity.longestTask'),
|
|
value: formatTaskDuration(stats?.longestSession?.duration, locale, t),
|
|
detail: stats?.longestSession ? formatMessageCount(stats.longestSession.messageCount, t) : undefined,
|
|
},
|
|
{
|
|
label: t('settings.activity.currentStreak'),
|
|
value: formatDayCount(stats?.streaks.currentStreak ?? 0, t),
|
|
detail: today ? `${formatTokens(today.tokens)} ${t('settings.activity.tokens')}` : undefined,
|
|
},
|
|
{
|
|
label: t('settings.activity.longestStreak'),
|
|
value: formatDayCount(stats?.streaks.longestStreak ?? 0, t),
|
|
detail: formatSessionCount(last30Usage.sessions, t),
|
|
},
|
|
]
|
|
const avatarSrc = profile.avatarFile ? getProfileAvatarUrl(profile.avatarUpdatedAt) : '/app-icon.png'
|
|
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') },
|
|
{ mode: 'weekly', label: t('settings.activity.mode.weekly'), help: t('settings.activity.modeHelp.weekly') },
|
|
{ mode: 'cumulative', label: t('settings.activity.mode.cumulative'), help: t('settings.activity.modeHelp.cumulative') },
|
|
]
|
|
|
|
const saveProfile = async () => {
|
|
setIsSavingProfile(true)
|
|
setProfileError(null)
|
|
setProfileStatus(null)
|
|
try {
|
|
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) {
|
|
setProfileError(err instanceof Error ? err.message : t('settings.activity.profileSaveFailed'))
|
|
} finally {
|
|
setIsSavingProfile(false)
|
|
}
|
|
}
|
|
|
|
const handleAvatarChange = async (event: ChangeEvent<HTMLInputElement>) => {
|
|
const file = event.target.files?.[0]
|
|
event.target.value = ''
|
|
if (!file) return
|
|
setIsSavingProfile(true)
|
|
setProfileError(null)
|
|
setProfileStatus(null)
|
|
try {
|
|
const result = await desktopUiPreferencesApi.uploadProfileAvatar(file)
|
|
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'))
|
|
} finally {
|
|
setIsSavingProfile(false)
|
|
}
|
|
}
|
|
|
|
const removeAvatar = async () => {
|
|
setIsSavingProfile(true)
|
|
setProfileError(null)
|
|
setProfileStatus(null)
|
|
try {
|
|
const result = await desktopUiPreferencesApi.deleteProfileAvatar()
|
|
setProfile(withProfileDefaults(result.preferences.profile))
|
|
setProfileStatus(t('settings.activity.profileSaved'))
|
|
} catch (err) {
|
|
setProfileError(err instanceof Error ? err.message : t('settings.activity.profileSaveFailed'))
|
|
} finally {
|
|
setIsSavingProfile(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="mx-auto w-full max-w-[1160px] min-w-0 pb-12">
|
|
<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`}
|
|
className={avatarClassName}
|
|
onError={(event) => {
|
|
event.currentTarget.src = '/app-icon.png'
|
|
event.currentTarget.className = 'h-full w-full scale-[1.28] object-contain transition-transform'
|
|
}}
|
|
/>
|
|
</div>
|
|
<div className="group/activity-profile 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)] opacity-0 transition-[background-color,color,opacity,transform] group-hover/activity-profile:opacity-100 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)] focus-visible:opacity-100 active:translate-y-[1px] disabled:pointer-events-none disabled:opacity-0"
|
|
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-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) => (
|
|
<div key={index} className="h-[88px] animate-pulse border-t border-[var(--color-border)] bg-[var(--color-surface)] sm:border-l sm:border-t-0 first:sm:border-l-0" />
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="grid gap-0 sm:grid-cols-2 xl:grid-cols-5">
|
|
{metrics.map((metric, index) => (
|
|
<div
|
|
key={metric.label}
|
|
className="min-w-0 border-t border-[var(--color-border)] px-5 py-5 text-center opacity-0 [animation:activity-reveal_420ms_cubic-bezier(0.16,1,0.3,1)_forwards] sm:border-l sm:border-t-0 first:sm:border-l-0"
|
|
style={{ animationDelay: `${index * 45}ms` }}
|
|
>
|
|
<div className="truncate text-2xl font-semibold tracking-tight text-[var(--color-text-primary)]">{metric.value}</div>
|
|
<div className="mt-1 truncate text-sm font-semibold text-[var(--color-text-secondary)]">{metric.label}</div>
|
|
{metric.detail && <div className="mt-0.5 truncate text-xs text-[var(--color-text-tertiary)]">{metric.detail}</div>}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
{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>
|
|
<h2 id="activity-profile-dialog-title" className="text-base font-semibold text-[var(--color-text-primary)]">{t('settings.activity.editProfile')}</h2>
|
|
<p className="mt-1 text-xs text-[var(--color-text-tertiary)]">{t('settings.activity.displayNameHelper')}</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-[var(--color-text-tertiary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]"
|
|
onClick={() => {
|
|
setIsEditingProfile(false)
|
|
setDraftDisplayName(profile.displayName)
|
|
setDraftSubtitle(profile.subtitle)
|
|
setProfileError(null)
|
|
}}
|
|
aria-label={t('settings.activity.cancelEdit')}
|
|
>
|
|
<span className="material-symbols-outlined text-[17px]" aria-hidden="true">close</span>
|
|
</button>
|
|
</div>
|
|
|
|
<div className="mt-5 grid gap-4">
|
|
<div className="grid gap-2">
|
|
<label htmlFor="activity-profile-display-name" className="text-xs font-medium text-[var(--color-text-secondary)]">
|
|
{t('settings.activity.displayName')}
|
|
</label>
|
|
<input
|
|
id="activity-profile-display-name"
|
|
value={draftDisplayName}
|
|
onChange={(event) => setDraftDisplayName(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">
|
|
<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>
|
|
<div className="flex flex-wrap gap-2">
|
|
<input
|
|
ref={avatarInputRef}
|
|
type="file"
|
|
accept="image/png,image/jpeg,image/webp"
|
|
className="hidden"
|
|
onChange={handleAvatarChange}
|
|
/>
|
|
<button
|
|
type="button"
|
|
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-[var(--color-border)] px-2.5 text-xs font-medium text-[var(--color-text-secondary)] transition-[background-color,transform] hover:bg-[var(--color-surface-hover)] active:translate-y-[1px]"
|
|
onClick={() => avatarInputRef.current?.click()}
|
|
>
|
|
<span className="material-symbols-outlined text-[15px]" aria-hidden="true">upload</span>
|
|
{t('settings.activity.changeAvatar')}
|
|
</button>
|
|
{profile.avatarFile && (
|
|
<button
|
|
type="button"
|
|
className="inline-flex h-8 items-center gap-1.5 rounded-md px-2.5 text-xs font-medium text-[var(--color-text-tertiary)] transition-[background-color,transform] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] active:translate-y-[1px]"
|
|
onClick={removeAvatar}
|
|
>
|
|
{t('settings.activity.removeAvatar')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{profileError && <div className="mt-4 rounded-md border border-[var(--color-error)]/30 bg-[var(--color-error)]/10 px-3 py-2 text-xs text-[var(--color-error)]">{profileError}</div>}
|
|
|
|
<div className="mt-5 flex justify-end gap-2">
|
|
<button
|
|
type="button"
|
|
className="h-8 rounded-md px-3 text-xs font-medium text-[var(--color-text-secondary)] transition-[background-color,transform] hover:bg-[var(--color-surface-hover)] active:translate-y-[1px]"
|
|
onClick={() => {
|
|
setIsEditingProfile(false)
|
|
setDraftDisplayName(profile.displayName)
|
|
setDraftSubtitle(profile.subtitle)
|
|
setProfileError(null)
|
|
}}
|
|
>
|
|
{t('settings.activity.cancelEdit')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="h-8 rounded-md bg-[var(--color-text-primary)] px-3 text-xs font-medium text-[var(--color-surface)] transition-[opacity,transform] active:translate-y-[1px] disabled:opacity-50"
|
|
onClick={saveProfile}
|
|
disabled={isSavingProfile}
|
|
>
|
|
{t('settings.activity.saveProfile')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>,
|
|
document.body,
|
|
)}
|
|
|
|
<div className="mt-16">
|
|
<div className="mb-5 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
<div>
|
|
<h2 className="text-xl font-semibold text-[var(--color-text-primary)]">{t('settings.activity.tokenActivity')}</h2>
|
|
</div>
|
|
<div className="inline-flex w-fit items-center gap-7">
|
|
{modeOptions.map((option) => (
|
|
<button
|
|
key={option.mode}
|
|
type="button"
|
|
aria-pressed={heatmapMode === option.mode}
|
|
title={option.help}
|
|
className={`text-lg font-semibold transition-[color,transform] active:translate-y-[1px] ${
|
|
heatmapMode === option.mode
|
|
? 'text-[var(--color-text-primary)]'
|
|
: 'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)]'
|
|
}`}
|
|
onClick={() => setHeatmapMode(option.mode)}
|
|
>
|
|
{option.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{isLoading ? (
|
|
<div className="min-h-[190px] space-y-3">
|
|
<div className="h-4 w-1/4 animate-pulse rounded bg-[var(--color-surface-container)]" />
|
|
<div className="grid grid-flow-col gap-[3px]">
|
|
{Array.from({ length: 52 }).map((_, col) => (
|
|
<div key={col} className="grid grid-rows-7 gap-[3px]">
|
|
{Array.from({ length: 7 }).map((__, row) => (
|
|
<div key={row} className="h-2.5 w-2.5 animate-pulse rounded-[3px] bg-[var(--color-surface-container)]" />
|
|
))}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : error ? (
|
|
<div className="rounded-md border border-[var(--color-error)]/30 bg-[var(--color-error)]/10 px-4 py-3 text-sm text-[var(--color-error)]">
|
|
{error}
|
|
</div>
|
|
) : !hasUsage ? (
|
|
<div className="flex min-h-[190px] items-center justify-center">
|
|
<div className="max-w-sm text-center">
|
|
<div className="mx-auto flex h-11 w-11 items-center justify-center rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] text-[var(--color-text-tertiary)]">
|
|
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">monitoring</span>
|
|
</div>
|
|
<div className="mt-3 text-sm font-medium text-[var(--color-text-primary)]">{t('settings.activity.emptyTitle')}</div>
|
|
<p className="mt-1 text-sm leading-5 text-[var(--color-text-tertiary)]">{t('settings.activity.emptyBody')}</p>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div ref={heatmapMeasureRef} className="min-w-0 pb-2">
|
|
<div className="relative" style={{ width: heatmapWidth, maxWidth: '100%' }}>
|
|
<div
|
|
className="mb-4 grid h-5 text-[11px] leading-none text-[var(--color-text-tertiary)]"
|
|
style={{
|
|
marginLeft: HEAT_LABEL_WIDTH,
|
|
gridTemplateColumns: `repeat(${WEEK_COUNT}, ${heatCellSize}px)`,
|
|
columnGap: HEAT_CELL_GAP,
|
|
}}
|
|
>
|
|
{monthLabels.map((month) => (
|
|
<div key={`${month.week}-${month.label}`} style={{ gridColumn: `${month.week + 1} / span 4` }}>
|
|
{month.label}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className="flex items-start" style={{ gap: HEAT_CELL_GAP }}>
|
|
<div
|
|
className="grid shrink-0 grid-rows-7 text-[11px] leading-none text-[var(--color-text-tertiary)]"
|
|
style={{ width: HEAT_LABEL_WIDTH, height: heatGridHeight, rowGap: HEAT_CELL_GAP }}
|
|
>
|
|
<div className="row-start-2 flex items-center">{t(WEEKDAY_LABEL_KEYS[0])}</div>
|
|
<div className="row-start-4 flex items-center">{t(WEEKDAY_LABEL_KEYS[1])}</div>
|
|
<div className="row-start-6 flex items-center">{t(WEEKDAY_LABEL_KEYS[2])}</div>
|
|
</div>
|
|
|
|
<div
|
|
role="grid"
|
|
aria-label={t('settings.activity.heatmapLabel')}
|
|
className="grid grid-flow-col"
|
|
style={{
|
|
gridTemplateRows: `repeat(7, ${heatCellSize}px)`,
|
|
gridAutoColumns: `${heatCellSize}px`,
|
|
columnGap: HEAT_CELL_GAP,
|
|
rowGap: HEAT_CELL_GAP,
|
|
}}
|
|
onMouseLeave={() => setHoveredDate(null)}
|
|
>
|
|
{days.map((day) => {
|
|
const isSelected = activeTooltipDate === day.date
|
|
const tooltipId = `activity-day-tooltip-${day.date}`
|
|
const cellTitle = getHeatmapCellTitle(day, locale, t)
|
|
const cellDetail = getHeatmapCellDetail(day, t)
|
|
return (
|
|
<button
|
|
key={day.date}
|
|
type="button"
|
|
role="gridcell"
|
|
aria-label={`${cellTitle}: ${cellDetail}`}
|
|
aria-describedby={activeTooltipDate === day.date ? tooltipId : undefined}
|
|
className={`activity-heat-cell rounded-[3px] border focus:outline-none focus:ring-2 focus:ring-[var(--color-brand)] focus:ring-offset-2 focus:ring-offset-[var(--color-surface)] ${
|
|
isSelected
|
|
? 'is-active border-[var(--color-activity-cell-border-active)]'
|
|
: 'border-[var(--color-activity-cell-border)] hover:border-[var(--color-activity-cell-border-hover)]'
|
|
}`}
|
|
style={{
|
|
width: heatCellSize,
|
|
height: heatCellSize,
|
|
backgroundColor: HEAT_COLORS[day.level],
|
|
}}
|
|
onFocus={() => setFocusedDate(day.date)}
|
|
onBlur={() => setFocusedDate(null)}
|
|
onMouseEnter={() => setHoveredDate(day.date)}
|
|
/>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{tooltipDay && (
|
|
<div
|
|
id={`activity-day-tooltip-${tooltipDay.date}`}
|
|
role="tooltip"
|
|
className="pointer-events-none absolute z-20 min-w-[172px] rounded-md border border-[var(--color-activity-tooltip-border)] bg-[var(--color-activity-tooltip-surface)] px-3 py-2 text-xs shadow-xl"
|
|
style={tooltipStyle}
|
|
>
|
|
<div className="font-medium text-[var(--color-activity-tooltip-text)]">{getHeatmapCellTitle(tooltipDay, locale, t)}</div>
|
|
<div className="mt-1 text-[var(--color-activity-tooltip-muted)]">
|
|
{getHeatmapCellDetail(tooltipDay, t)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-3 flex items-center justify-end gap-2 text-xs text-[var(--color-text-tertiary)] xl:mt-4">
|
|
<span>{t('settings.activity.less')}</span>
|
|
{HEAT_COLORS.map((color) => (
|
|
<span
|
|
key={color}
|
|
aria-hidden="true"
|
|
className="rounded-[3px] border border-[var(--color-activity-cell-border)]"
|
|
style={{ width: heatCellSize, height: heatCellSize, backgroundColor: color }}
|
|
/>
|
|
))}
|
|
<span>{t('settings.activity.more')}</span>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|