mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-08 03:07:51 +00:00
feat(theme): add browser-local light and dark mode
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
parent
ac42c2346c
commit
ebac94a043
24 changed files with 332 additions and 34 deletions
48
web/e2e/theme-toggle.spec.ts
Normal file
48
web/e2e/theme-toggle.spec.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { expect, test } from '@playwright/test'
|
||||
import { setEnglishLocale } from './helpers/auth-fixtures'
|
||||
|
||||
test.describe('Light and dark theme', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setEnglishLocale(page)
|
||||
await page.context().setExtraHTTPHeaders({ 'X-Mock-User-Id': 'local-user' })
|
||||
await page.addInitScript(() => {
|
||||
if (!window.sessionStorage.getItem('theme-test-initialized')) {
|
||||
window.localStorage.removeItem('skillhub-theme')
|
||||
window.sessionStorage.setItem('theme-test-initialized', 'true')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test('switches themes and restores only the browser-local selection', async ({ page }, testInfo) => {
|
||||
const consoleErrors: string[] = []
|
||||
const pageErrors: string[] = []
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') consoleErrors.push(message.text())
|
||||
})
|
||||
page.on('pageerror', (error) => pageErrors.push(error.stack ?? error.message))
|
||||
|
||||
await page.goto('/')
|
||||
await expect(page.locator('html')).not.toHaveClass(/dark/)
|
||||
|
||||
await page.getByRole('button', { name: 'Switch to dark theme' }).click()
|
||||
await expect(page.locator('html')).toHaveClass(/dark/)
|
||||
await expect.poll(() => page.evaluate(() => window.localStorage.getItem('skillhub-theme'))).toBe('dark')
|
||||
|
||||
await page.reload()
|
||||
await expect(page.locator('html')).toHaveClass(/dark/)
|
||||
await expect(page.getByRole('button', { name: 'Switch to light theme' })).toBeVisible()
|
||||
await expect(page.getByRole('heading', { name: 'SkillHub', exact: true })).toBeVisible()
|
||||
await page.screenshot({ path: testInfo.outputPath('dark-desktop.png'), fullPage: true })
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await expect(page.getByRole('button', { name: 'Switch to light theme' })).toBeVisible()
|
||||
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true)
|
||||
await page.screenshot({ path: testInfo.outputPath('dark-mobile.png'), fullPage: true })
|
||||
|
||||
const unexpectedConsoleErrors = consoleErrors.filter((message) => (
|
||||
!message.includes("frame-ancestors' is ignored when delivered via a <meta> element")
|
||||
))
|
||||
expect(unexpectedConsoleErrors).toEqual([])
|
||||
expect(pageErrors).toEqual([])
|
||||
})
|
||||
})
|
||||
|
|
@ -3,6 +3,7 @@ import { Outlet, Link, useRouterState } from '@tanstack/react-router'
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuth } from '@/features/auth/use-auth'
|
||||
import { LanguageSwitcher } from '@/shared/components/language-switcher'
|
||||
import { ThemeToggle } from '@/shared/components/theme-toggle'
|
||||
import { UserMenu } from '@/shared/components/user-menu'
|
||||
import { NotificationBell } from '@/features/notification/notification-bell'
|
||||
import { dismissOpenOverlays } from '@/shared/lib/dismiss-open-overlays'
|
||||
|
|
@ -115,7 +116,8 @@ export function Layout() {
|
|||
})}
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-6 text-[15px] font-normal" style={{ color: 'hsl(var(--text-secondary))' }}>
|
||||
<div className="flex items-center gap-3 text-[15px] font-normal sm:gap-4" style={{ color: 'hsl(var(--text-secondary))' }}>
|
||||
<ThemeToggle />
|
||||
<LanguageSwitcher />
|
||||
{user && <NotificationBell />}
|
||||
{isLoading ? null : user ? (
|
||||
|
|
@ -149,7 +151,7 @@ export function Layout() {
|
|||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="relative z-10 border-t rounded-t-2xl mt-auto" style={{ background: '#F1F5F9', borderColor: 'hsl(var(--border))' }}>
|
||||
<footer className="relative z-10 mt-auto rounded-t-2xl border-t bg-secondary/70" style={{ borderColor: 'hsl(var(--border))' }}>
|
||||
<div className="max-w-6xl mx-auto px-6 md:px-12 py-10">
|
||||
<div className="flex flex-col md:flex-row md:items-start md:justify-between gap-10 md:gap-12">
|
||||
<div className="flex-shrink-0">
|
||||
|
|
|
|||
|
|
@ -5,6 +5,10 @@
|
|||
* that config or a safe fallback object before importing the main entry.
|
||||
*/
|
||||
import './legacy-polyfills'
|
||||
import { initializeTheme } from './shared/lib/theme'
|
||||
|
||||
initializeTheme()
|
||||
|
||||
async function loadRuntimeConfig() {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const script = document.createElement('script')
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ export function NotificationDropdown({ onClose }: Props) {
|
|||
|
||||
return (
|
||||
<div
|
||||
className="absolute right-0 top-10 z-50 w-80 rounded-xl border bg-white shadow-lg"
|
||||
className="absolute right-0 top-10 z-50 w-80 rounded-xl border bg-popover text-popover-foreground shadow-lg"
|
||||
style={{ borderColor: 'hsl(var(--border))' }}
|
||||
>
|
||||
{/* Header */}
|
||||
|
|
|
|||
|
|
@ -27,7 +27,10 @@ export function createNotificationSseConnection(
|
|||
url: string,
|
||||
eventSourceFactory: EventSourceFactory = (targetUrl) =>
|
||||
new EventSource(targetUrl, { withCredentials: true }),
|
||||
timerApi: TimerApi = { setTimeout, clearTimeout },
|
||||
timerApi: TimerApi = {
|
||||
setTimeout: (...args) => globalThis.setTimeout(...args),
|
||||
clearTimeout: (timer) => globalThis.clearTimeout(timer),
|
||||
},
|
||||
): NotificationSseConnection {
|
||||
return new ManagedNotificationSseConnection(url, eventSourceFactory, timerApi)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ export function SkillCard({ skill, onClick, highlightStarred = true }: SkillCard
|
|||
|
||||
return (
|
||||
<Card
|
||||
className="h-full p-5 cursor-pointer group relative overflow-hidden bg-white border shadow-sm transition-shadow hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/70 focus-visible:ring-offset-2"
|
||||
className="group relative h-full cursor-pointer overflow-hidden border bg-card p-5 text-card-foreground shadow-sm transition-shadow hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/70 focus-visible:ring-offset-2"
|
||||
style={{ borderColor: 'hsl(var(--border-card))' }}
|
||||
onClick={onClick}
|
||||
onKeyDown={(event) => {
|
||||
|
|
|
|||
|
|
@ -147,8 +147,8 @@ export function SkillLabelPanel({ namespace, slug, initialLabels, canManage, isS
|
|||
className={cn(
|
||||
'inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-medium',
|
||||
label.type === 'PRIVILEGED'
|
||||
? 'border-amber-500/40 bg-amber-100 text-amber-900'
|
||||
: 'border-slate-300 bg-slate-100 text-slate-800',
|
||||
? 'border-amber-500/40 bg-amber-100 text-amber-900 dark:bg-amber-950/60 dark:text-amber-300'
|
||||
: 'border-border bg-secondary text-secondary-foreground',
|
||||
)}
|
||||
>
|
||||
{label.displayName}
|
||||
|
|
|
|||
|
|
@ -487,6 +487,10 @@
|
|||
"pageTitle": "Token Management",
|
||||
"pageSubtitle": "Manage access credentials for CLI and API"
|
||||
},
|
||||
"theme": {
|
||||
"switchToLight": "Switch to light theme",
|
||||
"switchToDark": "Switch to dark theme"
|
||||
},
|
||||
"reviews": {
|
||||
"title": "Review Center",
|
||||
"subtitle": "Manage platform review tasks",
|
||||
|
|
|
|||
|
|
@ -487,6 +487,10 @@
|
|||
"pageTitle": "Управление токенами",
|
||||
"pageSubtitle": "Учётные данные доступа для CLI и API"
|
||||
},
|
||||
"theme": {
|
||||
"switchToLight": "Переключить на светлую тему",
|
||||
"switchToDark": "Переключить на тёмную тему"
|
||||
},
|
||||
"reviews": {
|
||||
"title": "Центр рецензирования",
|
||||
"subtitle": "Задачи рецензирования на платформе",
|
||||
|
|
|
|||
|
|
@ -487,6 +487,10 @@
|
|||
"pageTitle": "Token 管理",
|
||||
"pageSubtitle": "管理 CLI 和 API 使用的访问凭证"
|
||||
},
|
||||
"theme": {
|
||||
"switchToLight": "切换到浅色主题",
|
||||
"switchToDark": "切换到深色主题"
|
||||
},
|
||||
"reviews": {
|
||||
"title": "审核中心",
|
||||
"subtitle": "管理平台审核事务",
|
||||
|
|
|
|||
|
|
@ -74,6 +74,10 @@
|
|||
--surface-glass: 222 35% 11%;
|
||||
--glow-primary: 239 100% 75%;
|
||||
--glow-accent: 271 100% 72%;
|
||||
--text-secondary: 215 20% 72%;
|
||||
--text-muted: 215 16% 63%;
|
||||
--text-placeholder: 215 14% 48%;
|
||||
--border-card: 222 20% 18%;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -323,9 +327,9 @@
|
|||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #0369a1;
|
||||
background: #e0f2fe;
|
||||
border: 1px solid #7dd3fc;
|
||||
color: hsl(199 76% 34%);
|
||||
background: hsl(199 89% 94%);
|
||||
border: 1px solid hsl(199 93% 74%);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
|
|
@ -342,7 +346,7 @@
|
|||
}
|
||||
|
||||
.status-pill--published { background: #16a34a; }
|
||||
.status-pill--review { background: #ea580c; }
|
||||
.status-pill--review { background: #c2410c; }
|
||||
.status-pill--archived { background: #6b7280; }
|
||||
.status-pill--rejected { background: #dc2626; }
|
||||
|
||||
|
|
@ -378,10 +382,26 @@
|
|||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.dark .handle-tag {
|
||||
color: hsl(196 80% 72%);
|
||||
background: hsl(199 70% 15%);
|
||||
border-color: hsl(199 60% 30%);
|
||||
}
|
||||
|
||||
.dark .badge-soft-green {
|
||||
background: hsl(142 55% 16%);
|
||||
color: hsl(142 70% 72%);
|
||||
}
|
||||
|
||||
.dark .badge-soft-blue {
|
||||
background: hsl(221 55% 18%);
|
||||
color: hsl(213 90% 75%);
|
||||
}
|
||||
|
||||
/* ─── Tab active ─── */
|
||||
.tab-active {
|
||||
border-bottom: 2px solid #3b82f6;
|
||||
color: #1d4ed8;
|
||||
border-bottom: 2px solid hsl(var(--primary));
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
/* ─── Code block ─── */
|
||||
|
|
|
|||
|
|
@ -110,7 +110,7 @@ export function LandingPage() {
|
|||
{/* Search box */}
|
||||
<div className="w-full max-w-2xl mb-8">
|
||||
<div
|
||||
className="flex items-center bg-white rounded-xl border shadow-sm px-5 py-3.5"
|
||||
className="flex items-center rounded-xl border bg-card px-5 py-3.5 text-card-foreground shadow-sm"
|
||||
style={{ borderColor: 'hsl(var(--border))' }}
|
||||
>
|
||||
<SearchIcon className="w-5 h-5 flex-shrink-0 mr-3" style={{ color: 'hsl(var(--text-placeholder))' }} strokeWidth={1.5} />
|
||||
|
|
@ -181,7 +181,7 @@ export function LandingPage() {
|
|||
{features.map((feature) => (
|
||||
<div
|
||||
key={feature.title}
|
||||
className="bg-white rounded-xl p-8 border shadow-sm transition-shadow hover:shadow-md"
|
||||
className="rounded-xl border bg-card p-8 text-card-foreground shadow-sm transition-shadow hover:shadow-md"
|
||||
style={{ borderColor: 'hsl(var(--border-card))' }}
|
||||
>
|
||||
<div className="feature-icon w-12 h-12 rounded-2xl flex items-center justify-center mb-6 mx-auto bg-brand-gradient">
|
||||
|
|
|
|||
|
|
@ -168,7 +168,7 @@ export function RegisterPage() {
|
|||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-[70vh] max-w-2xl items-center justify-center">
|
||||
<Card className="w-full border-slate-200 bg-white/95 shadow-xl">
|
||||
<Card className="w-full border-border bg-card/95 shadow-xl">
|
||||
<CardHeader className="space-y-3 text-center">
|
||||
<CardTitle>{t('register.title')}</CardTitle>
|
||||
<CardDescription>{t('register.subtitle')}</CardDescription>
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ export function ResetPasswordPage() {
|
|||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-[70vh] max-w-2xl items-center justify-center">
|
||||
<Card className="w-full border-slate-200 bg-white/95 shadow-xl">
|
||||
<Card className="w-full border-border bg-card/95 shadow-xl">
|
||||
<CardHeader className="space-y-3 text-center">
|
||||
<CardTitle>{t('resetPassword.title')}</CardTitle>
|
||||
<CardDescription>{t('resetPassword.subtitle')}</CardDescription>
|
||||
|
|
|
|||
|
|
@ -827,12 +827,12 @@ export function SkillDetailPage() {
|
|||
</span>
|
||||
)}
|
||||
{isReviewFlowPending && (
|
||||
<span className="badge-soft" style={{ background: '#fef3c7', color: '#92400e' }}>
|
||||
<span className="badge-soft bg-amber-100 text-amber-900 dark:bg-amber-950/60 dark:text-amber-300">
|
||||
{t('skillDetail.versionStatusPendingReview')}
|
||||
</span>
|
||||
)}
|
||||
{!isPendingPreview && (isRejectedPreview || hasRejectedOwnerPreview) && skill.canManageLifecycle && (
|
||||
<span className="badge-soft" style={{ background: '#fee2e2', color: '#991b1b' }}>
|
||||
<span className="badge-soft bg-red-100 text-red-900 dark:bg-red-950/60 dark:text-red-300">
|
||||
{t('skillDetail.rejectedBadge')}
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -861,8 +861,8 @@ export function SkillDetailPage() {
|
|||
className={cn(
|
||||
'inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/70 focus-visible:ring-offset-2',
|
||||
label.type === 'PRIVILEGED'
|
||||
? 'border-amber-500/40 bg-amber-100 text-amber-900 hover:bg-amber-200/80'
|
||||
: 'border-slate-300 bg-slate-100 text-slate-800 hover:bg-slate-200/80',
|
||||
? 'border-amber-500/40 bg-amber-100 text-amber-900 hover:bg-amber-200/80 dark:bg-amber-950/60 dark:text-amber-300 dark:hover:bg-amber-900/70'
|
||||
: 'border-border bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
)}
|
||||
>
|
||||
{label.displayName}
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ function CompactCopyButton({ text }: { text: string }) {
|
|||
onClick={handleCopy}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className="absolute right-2.5 top-1/2 flex h-9 w-9 -translate-y-1/2 items-center justify-center rounded-xl border bg-white transition-colors hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 cursor-pointer"
|
||||
className="absolute right-2 top-1/2 flex h-11 w-11 -translate-y-1/2 cursor-pointer items-center justify-center rounded-xl border bg-card text-card-foreground transition-colors hover:bg-secondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
style={{ borderColor: 'hsl(var(--border))', color: 'hsl(var(--foreground))' }}
|
||||
>
|
||||
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
||||
|
|
@ -111,12 +111,11 @@ export function LandingQuickStartSection() {
|
|||
</div>
|
||||
|
||||
<div
|
||||
className="mx-auto max-w-2xl rounded-[28px] border bg-white p-3 shadow-[0_24px_60px_-28px_rgba(15,23,42,0.25)]"
|
||||
className="mx-auto max-w-2xl rounded-[28px] border bg-card p-3 text-card-foreground shadow-[0_24px_60px_-28px_hsl(var(--foreground)/0.18)]"
|
||||
style={{ borderColor: 'hsl(var(--border-card))' }}
|
||||
>
|
||||
<div
|
||||
className="grid grid-cols-1 gap-2 rounded-2xl p-1.5 md:grid-cols-3"
|
||||
style={{ background: 'linear-gradient(180deg, rgba(248,250,252,0.98) 0%, rgba(241,245,249,0.92) 100%)' }}
|
||||
className="grid grid-cols-1 gap-2 rounded-2xl bg-muted/70 p-1.5 md:grid-cols-3"
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const isActive = tab.id === currentTab.id
|
||||
|
|
@ -130,9 +129,9 @@ export function LandingQuickStartSection() {
|
|||
aria-pressed={isActive}
|
||||
className="flex min-h-11 items-center justify-center gap-2 rounded-[14px] px-4 py-3 text-base font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 cursor-pointer"
|
||||
style={{
|
||||
background: isActive ? 'rgba(255,255,255,0.96)' : 'transparent',
|
||||
background: isActive ? 'hsl(var(--card))' : 'transparent',
|
||||
color: isActive ? 'hsl(var(--foreground))' : 'hsl(var(--muted-foreground))',
|
||||
boxShadow: isActive ? '0 6px 18px rgba(15, 23, 42, 0.08)' : 'none',
|
||||
boxShadow: isActive ? '0 6px 18px hsl(var(--foreground) / 0.08)' : 'none',
|
||||
}}
|
||||
>
|
||||
<Icon className="h-4 w-4" strokeWidth={1.75} />
|
||||
|
|
@ -151,13 +150,12 @@ export function LandingQuickStartSection() {
|
|||
</p>
|
||||
|
||||
<div
|
||||
className="relative rounded-2xl border bg-slate-50/90 px-4 py-3 pr-14 shadow-[inset_0_1px_0_rgba(255,255,255,0.7)]"
|
||||
className="relative rounded-2xl border bg-muted/65 px-4 py-3 pr-16"
|
||||
style={{ borderColor: 'hsl(var(--border))' }}
|
||||
>
|
||||
<div className="overflow-x-auto whitespace-nowrap">
|
||||
<code
|
||||
className="font-mono text-sm md:text-base"
|
||||
style={{ color: currentTab.id === 'agent' ? '#16A34A' : '#0F172A' }}
|
||||
className={`font-mono text-sm md:text-base ${currentTab.id === 'agent' ? 'text-emerald-700 dark:text-emerald-400' : 'text-foreground'}`}
|
||||
>
|
||||
{currentTab.command}
|
||||
</code>
|
||||
|
|
|
|||
|
|
@ -65,8 +65,8 @@ export function LanguageSwitcher({ className }: LanguageSwitcherProps) {
|
|||
onClick={() => setOpen((current) => !current)}
|
||||
>
|
||||
<Globe className="h-4 w-4" />
|
||||
<span className="text-sm text-inherit">{currentLanguage.name}</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 opacity-70" />
|
||||
<span className="hidden text-sm text-inherit sm:inline">{currentLanguage.name}</span>
|
||||
<ChevronDown className="hidden h-3.5 w-3.5 opacity-70 sm:block" />
|
||||
</Button>
|
||||
{open ? (
|
||||
<div className="absolute right-0 top-full z-50 pt-2">
|
||||
|
|
|
|||
34
web/src/shared/components/theme-toggle.test.tsx
Normal file
34
web/src/shared/components/theme-toggle.test.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
/** @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { THEME_STORAGE_KEY } from '@/shared/lib/theme'
|
||||
import { ThemeToggle } from './theme-toggle'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('ThemeToggle', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear()
|
||||
document.documentElement.classList.remove('dark')
|
||||
document.documentElement.dataset.theme = 'light'
|
||||
document.documentElement.style.colorScheme = 'light'
|
||||
})
|
||||
|
||||
it('switches theme and keeps the selection in browser-local storage', () => {
|
||||
render(<ThemeToggle />)
|
||||
|
||||
const toggle = screen.getByRole('button', { name: 'theme.switchToDark' })
|
||||
expect(toggle.getAttribute('aria-pressed')).toBe('false')
|
||||
|
||||
fireEvent.click(toggle)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'theme.switchToLight' }).getAttribute('aria-pressed')).toBe('true')
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(true)
|
||||
expect(window.localStorage.getItem(THEME_STORAGE_KEY)).toBe('dark')
|
||||
})
|
||||
})
|
||||
44
web/src/shared/components/theme-toggle.tsx
Normal file
44
web/src/shared/components/theme-toggle.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { Moon, Sun } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useTheme } from '@/shared/hooks/use-theme'
|
||||
import { cn } from '@/shared/lib/utils'
|
||||
|
||||
interface ThemeToggleProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function ThemeToggle({ className }: ThemeToggleProps) {
|
||||
const { t } = useTranslation()
|
||||
const { theme, toggleTheme } = useTheme()
|
||||
const isDark = theme === 'dark'
|
||||
const label = isDark ? t('theme.switchToLight') : t('theme.switchToDark')
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
aria-pressed={isDark}
|
||||
title={label}
|
||||
onClick={toggleTheme}
|
||||
className={cn(
|
||||
'group relative inline-flex h-11 w-11 shrink-0 items-center justify-center overflow-hidden rounded-full border border-border/70 bg-card/80 text-muted-foreground shadow-sm transition-colors duration-200 hover:border-primary/35 hover:bg-secondary hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Sun
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'absolute h-[18px] w-[18px] transition-[opacity,transform] duration-200 motion-reduce:transition-none',
|
||||
isDark ? 'rotate-90 scale-75 opacity-0' : 'rotate-0 scale-100 opacity-100',
|
||||
)}
|
||||
/>
|
||||
<Moon
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'absolute h-[18px] w-[18px] transition-[opacity,transform] duration-200 motion-reduce:transition-none',
|
||||
isDark ? 'rotate-0 scale-100 opacity-100' : '-rotate-90 scale-75 opacity-0',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
|
@ -130,7 +130,7 @@ export function UserMenu({ user, triggerClassName }: UserMenuProps) {
|
|||
className="w-8 h-8 rounded-full border border-border/60"
|
||||
/>
|
||||
)}
|
||||
<span className="text-sm font-medium text-inherit">
|
||||
<span className="hidden text-sm font-medium text-inherit lg:inline">
|
||||
{user.displayName}
|
||||
</span>
|
||||
</button>
|
||||
|
|
|
|||
24
web/src/shared/hooks/use-theme.ts
Normal file
24
web/src/shared/hooks/use-theme.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { useCallback, useState } from 'react'
|
||||
import {
|
||||
applyTheme,
|
||||
DEFAULT_THEME,
|
||||
readStoredTheme,
|
||||
saveTheme,
|
||||
type Theme,
|
||||
} from '@/shared/lib/theme'
|
||||
|
||||
export function useTheme() {
|
||||
const [theme, setThemeState] = useState<Theme>(() => readStoredTheme() ?? DEFAULT_THEME)
|
||||
|
||||
const setTheme = useCallback((nextTheme: Theme) => {
|
||||
applyTheme(nextTheme)
|
||||
saveTheme(nextTheme)
|
||||
setThemeState(nextTheme)
|
||||
}, [])
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
setTheme(theme === 'light' ? 'dark' : 'light')
|
||||
}, [setTheme, theme])
|
||||
|
||||
return { theme, setTheme, toggleTheme }
|
||||
}
|
||||
53
web/src/shared/lib/theme.test.ts
Normal file
53
web/src/shared/lib/theme.test.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
/** @vitest-environment jsdom */
|
||||
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
applyTheme,
|
||||
DEFAULT_THEME,
|
||||
initializeTheme,
|
||||
readStoredTheme,
|
||||
saveTheme,
|
||||
THEME_STORAGE_KEY,
|
||||
} from './theme'
|
||||
|
||||
describe('theme preference', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear()
|
||||
document.documentElement.classList.remove('dark')
|
||||
document.documentElement.removeAttribute('data-theme')
|
||||
document.documentElement.style.colorScheme = ''
|
||||
})
|
||||
|
||||
it('uses light when the browser has no saved preference', () => {
|
||||
expect(initializeTheme()).toBe(DEFAULT_THEME)
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(false)
|
||||
expect(document.documentElement.dataset.theme).toBe('light')
|
||||
expect(document.documentElement.style.colorScheme).toBe('light')
|
||||
})
|
||||
|
||||
it('restores a valid browser-local preference before render', () => {
|
||||
window.localStorage.setItem(THEME_STORAGE_KEY, 'dark')
|
||||
|
||||
expect(initializeTheme()).toBe('dark')
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(true)
|
||||
expect(document.documentElement.dataset.theme).toBe('dark')
|
||||
})
|
||||
|
||||
it('ignores invalid and inaccessible storage values', () => {
|
||||
expect(readStoredTheme({ getItem: () => 'system', setItem: () => undefined })).toBeNull()
|
||||
expect(readStoredTheme({
|
||||
getItem: () => {
|
||||
throw new Error('blocked')
|
||||
},
|
||||
setItem: () => undefined,
|
||||
})).toBeNull()
|
||||
})
|
||||
|
||||
it('applies and saves only the selected frontend theme', () => {
|
||||
applyTheme('dark')
|
||||
saveTheme('dark')
|
||||
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(true)
|
||||
expect(window.localStorage.getItem(THEME_STORAGE_KEY)).toBe('dark')
|
||||
})
|
||||
})
|
||||
56
web/src/shared/lib/theme.ts
Normal file
56
web/src/shared/lib/theme.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
export type Theme = 'light' | 'dark'
|
||||
|
||||
export const THEME_STORAGE_KEY = 'skillhub-theme'
|
||||
export const DEFAULT_THEME: Theme = 'light'
|
||||
|
||||
interface ThemeStorage {
|
||||
getItem(key: string): string | null
|
||||
setItem(key: string, value: string): void
|
||||
}
|
||||
|
||||
export function isTheme(value: unknown): value is Theme {
|
||||
return value === 'light' || value === 'dark'
|
||||
}
|
||||
|
||||
export function readStoredTheme(storage: ThemeStorage | undefined = getBrowserStorage()): Theme | null {
|
||||
if (!storage) return null
|
||||
|
||||
try {
|
||||
const value = storage.getItem(THEME_STORAGE_KEY)
|
||||
return isTheme(value) ? value : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function applyTheme(theme: Theme, root: HTMLElement = document.documentElement) {
|
||||
root.classList.toggle('dark', theme === 'dark')
|
||||
root.dataset.theme = theme
|
||||
root.style.colorScheme = theme
|
||||
}
|
||||
|
||||
export function initializeTheme(root: HTMLElement = document.documentElement): Theme {
|
||||
const theme = readStoredTheme() ?? DEFAULT_THEME
|
||||
applyTheme(theme, root)
|
||||
return theme
|
||||
}
|
||||
|
||||
export function saveTheme(theme: Theme, storage: ThemeStorage | undefined = getBrowserStorage()) {
|
||||
if (!storage) return
|
||||
|
||||
try {
|
||||
storage.setItem(THEME_STORAGE_KEY, theme)
|
||||
} catch {
|
||||
// Storage can be unavailable in private browsing or hardened browser contexts.
|
||||
}
|
||||
}
|
||||
|
||||
function getBrowserStorage(): Storage | undefined {
|
||||
if (typeof window === 'undefined') return undefined
|
||||
|
||||
try {
|
||||
return window.localStorage
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@ import { cn } from '@/shared/lib/utils'
|
|||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
export const INPUT_BASE_CLASS_NAME =
|
||||
'flex h-11 w-full rounded-lg border bg-white px-4 py-2 text-sm text-foreground ring-offset-background transition-all duration-200 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:border-primary/50 disabled:cursor-not-allowed disabled:opacity-50'
|
||||
'flex h-11 w-full rounded-lg border bg-background px-4 py-2 text-sm text-foreground ring-offset-background transition-all duration-200 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:border-primary/50 disabled:cursor-not-allowed disabled:opacity-50'
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, style, ...props }, ref) => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue