diff --git a/app/app.vue b/app/app.vue index f8eacfa7..33fb6efa 100644 --- a/app/app.vue +++ b/app/app.vue @@ -3,3 +3,8 @@ + + diff --git a/app/assets/css/main.css b/app/assets/css/main.css index 1a30b290..07016375 100644 --- a/app/assets/css/main.css +++ b/app/assets/css/main.css @@ -1,22 +1,94 @@ -@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap'); +/* Self-hosted fonts (the CSP only allows same-origin fonts) */ +@import '@fontsource/ibm-plex-sans/400.css'; +@import '@fontsource/ibm-plex-sans/500.css'; +@import '@fontsource/ibm-plex-sans/600.css'; +@import '@fontsource/ibm-plex-mono/400.css'; +@import '@fontsource/ibm-plex-mono/500.css'; @tailwind base; @tailwind components; @tailwind utilities; +/* + * Theme tokens as RGB channels (see tailwind.config.ts). + * Light by default, dark when the system prefers it, and an explicit + * data-theme on (user choice) wins over the system. + */ @layer base { + :root { + color-scheme: light; + --bg: 243 244 246; + --surface: 255 255 255; + --raised: 247 248 250; + --hover: 236 238 241; + --line: 220 223 228; + --line-strong: 195 200 208; + --ink: 26 28 32; + --ink-2: 79 85 94; + --ink-3: 107 113 123; + --seed: 29 133 86; + --leech: 168 107 12; + --free: 91 71 214; + --danger: 194 58 51; + --contrast: 26 28 32; + --contrast-inv: 255 255 255; + } + + @media (prefers-color-scheme: dark) { + :root:not([data-theme='light']) { + color-scheme: dark; + --bg: 23 24 27; + --surface: 31 33 37; + --raised: 40 42 47; + --hover: 46 48 54; + --line: 51 54 60; + --line-strong: 70 74 82; + --ink: 236 234 229; + --ink-2: 167 166 161; + --ink-3: 132 133 127; + --seed: 76 183 130; + --leech: 226 162 62; + --free: 155 138 247; + --danger: 230 98 91; + --contrast: 245 244 240; + --contrast-inv: 23 24 27; + } + } + + :root[data-theme='dark'] { + color-scheme: dark; + --bg: 23 24 27; + --surface: 31 33 37; + --raised: 40 42 47; + --hover: 46 48 54; + --line: 51 54 60; + --line-strong: 70 74 82; + --ink: 236 234 229; + --ink-2: 167 166 161; + --ink-3: 132 133 127; + --seed: 76 183 130; + --leech: 226 162 62; + --free: 155 138 247; + --danger: 230 98 91; + --contrast: 245 244 240; + --contrast-inv: 23 24 27; + } + html { @apply antialiased; + font-feature-settings: 'ss02'; } body { - @apply bg-bg-primary text-text-primary font-sans selection:bg-white selection:text-black; - background-image: radial-gradient( - circle at 2px 2px, - #1a1a1a 1px, - transparent 0 - ); - background-size: 40px 40px; + @apply bg-bg-primary text-text-primary font-sans text-sm leading-6; + } + + ::selection { + @apply bg-free/25 text-text-primary; + } + + :focus-visible { + @apply outline-none ring-2 ring-free/60 ring-offset-1 ring-offset-bg-primary; } /* Custom scrollbar */ @@ -25,7 +97,7 @@ } ::-webkit-scrollbar-track { - @apply bg-bg-secondary; + @apply bg-transparent; } ::-webkit-scrollbar-thumb { @@ -35,80 +107,98 @@ ::-webkit-scrollbar-thumb:hover { @apply bg-border-hover; } + + @media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + transition-duration: 0.01ms !important; + } + } } @layer components { - /* Card component */ + /* Panels: flat surface, one radius, no glow */ .card { - @apply bg-bg-secondary/80 backdrop-blur-sm border border-border rounded-md transition-all duration-200 hover:border-border-hover; + @apply bg-bg-secondary border border-border rounded-md; } .card-header { - @apply px-3 py-2 border-b border-border flex items-center justify-between; + @apply px-4 py-2.5 border-b border-border flex items-center justify-between gap-3; } .card-body { - @apply p-3; + @apply p-4; } - /* Button styles */ + /* Buttons */ .btn { - @apply px-3 py-1.5 rounded text-sm font-medium transition-all duration-200 active:scale-[0.98] disabled:opacity-50 disabled:pointer-events-none; + @apply inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded text-sm font-medium transition-colors disabled:opacity-50 disabled:pointer-events-none; } .btn-primary { - @apply bg-white text-black hover:bg-gray-200 shadow-[0_0_15px_rgba(255,255,255,0.1)]; + @apply bg-white text-black hover:bg-white/85; } .btn-secondary { @apply bg-bg-tertiary text-text-primary border border-border hover:bg-bg-hover hover:border-border-hover; } - /* Input styles */ + .btn-ghost { + @apply text-text-secondary hover:text-text-primary hover:bg-bg-hover; + } + + /* Inputs */ .input { - @apply bg-bg-tertiary/50 border border-border rounded px-3 py-1.5 text-sm text-text-primary placeholder-text-muted focus:outline-none focus:border-white/30 focus:bg-bg-tertiary transition-all; + @apply bg-bg-tertiary border border-border rounded px-3 py-1.5 text-sm text-text-primary placeholder-text-muted transition-colors hover:border-border-hover focus:outline-none focus:border-free/60 focus:ring-2 focus:ring-free/20; } - /* Table styles */ + /* Tables */ .data-table { - @apply w-full text-xs border-separate border-spacing-0; + @apply w-full text-sm border-separate border-spacing-0; } .data-table th { - @apply text-left text-text-muted font-semibold px-3 py-2 border-b border-border bg-bg-secondary/50 uppercase tracking-wider; + @apply text-left text-xs font-medium text-text-muted px-3 py-2 border-b border-border; } .data-table td { - @apply px-3 py-2 border-b border-border/50; + @apply px-3 py-2 border-b border-border/60; } .data-table tr:last-child td { @apply border-b-0; } - .data-table tr:hover td { - @apply bg-white/[0.02]; + .data-table tbody tr:hover td { + @apply bg-bg-hover/60; } - /* Stat badge */ + /* Swarm counters */ .stat-badge { - @apply inline-flex items-center gap-1 px-1.5 py-0.5 rounded-sm text-[10px] font-mono font-bold uppercase; + @apply inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-mono tabular-nums; } .stat-seeders { - @apply text-success bg-success/5 border border-success/20; + @apply text-seed bg-seed/10; } .stat-leechers { - @apply text-warning bg-warning/5 border border-warning/20; + @apply text-leech bg-leech/10; } .btn-search { - @apply p-1.5 text-text-muted hover:text-white transition-colors; + @apply p-1.5 text-text-muted hover:text-text-primary transition-colors; } } @layer utilities { + /* Numbers that should line up: sizes, ratios, counts */ + .num { + @apply font-mono tabular-nums; + } + .truncate-hash { @apply font-mono text-2xs; max-width: 120px; diff --git a/app/components/EditTorrentModal.vue b/app/components/EditTorrentModal.vue index c4bc4ace..06277c88 100644 --- a/app/components/EditTorrentModal.vue +++ b/app/components/EditTorrentModal.vue @@ -2,7 +2,7 @@ + + + + {{ t(option.label) }} + + + + + diff --git a/app/components/UploadTorrentModal.vue b/app/components/UploadTorrentModal.vue index 5d328186..0d4a9513 100644 --- a/app/components/UploadTorrentModal.vue +++ b/app/components/UploadTorrentModal.vue @@ -2,7 +2,7 @@ diff --git a/app/components/admin/Branding.vue b/app/components/admin/Branding.vue index ad1d3c98..7cd2a847 100644 --- a/app/components/admin/Branding.vue +++ b/app/components/admin/Branding.vue @@ -330,7 +330,7 @@ class="w-full text-[10px] font-bold uppercase tracking-widest py-2.5 rounded transition-all disabled:opacity-50 flex items-center justify-center gap-2" :class=" saved - ? 'bg-success text-white' + ? 'bg-success text-paper' : 'bg-text-primary text-bg-primary hover:opacity-90' " > @@ -455,7 +455,7 @@ class="w-full text-[10px] font-bold uppercase tracking-widest py-2.5 rounded transition-all disabled:opacity-50 flex items-center justify-center gap-2" :class=" savedText - ? 'bg-success text-white' + ? 'bg-success text-paper' : 'bg-text-primary text-bg-primary hover:opacity-90' " > @@ -577,7 +577,7 @@ class="w-full text-[10px] font-bold uppercase tracking-widest py-2.5 rounded transition-all disabled:opacity-50 flex items-center justify-center gap-2" :class=" savedHomepage - ? 'bg-success text-white' + ? 'bg-success text-paper' : 'bg-text-primary text-bg-primary hover:opacity-90' " > diff --git a/app/components/admin/HnR.vue b/app/components/admin/HnR.vue index fb06a99e..c3199734 100644 --- a/app/components/admin/HnR.vue +++ b/app/components/admin/HnR.vue @@ -11,7 +11,7 @@ {{ hnrCount }} HnR diff --git a/app/components/admin/HomepageContent.vue b/app/components/admin/HomepageContent.vue index 5f1e377b..4bc1a138 100644 --- a/app/components/admin/HomepageContent.vue +++ b/app/components/admin/HomepageContent.vue @@ -103,7 +103,7 @@ class="w-full text-[10px] font-bold uppercase tracking-widest py-2.5 rounded transition-all disabled:opacity-50 flex items-center justify-center gap-2" :class=" saved - ? 'bg-success text-white' + ? 'bg-success text-paper' : 'bg-text-primary text-bg-primary hover:opacity-90' " > diff --git a/app/components/admin/Panic.vue b/app/components/admin/Panic.vue index 71560ec3..eaadc9dc 100644 --- a/app/components/admin/Panic.vue +++ b/app/components/admin/Panic.vue @@ -21,7 +21,7 @@ {{ t('admin.panic.encryptButton') }} @@ -48,7 +48,7 @@ {{ t('admin.panic.restoreButton') }} @@ -60,7 +60,7 @@ diff --git a/app/components/admin/Reports.vue b/app/components/admin/Reports.vue index 1335bca3..c7f3c6fc 100644 --- a/app/components/admin/Reports.vue +++ b/app/components/admin/Reports.vue @@ -11,7 +11,7 @@ {{ pendingCount }} diff --git a/app/components/admin/TorznabRateLimiting.vue b/app/components/admin/TorznabRateLimiting.vue index c7c42e8b..54641a14 100644 --- a/app/components/admin/TorznabRateLimiting.vue +++ b/app/components/admin/TorznabRateLimiting.vue @@ -132,7 +132,7 @@ class="flex items-center justify-between border-t border-border pt-4" > - + {{ t('admin.torznabRateLimit.immediateNote') }} - + @@ -240,7 +240,7 @@ v-if="confirmAction" class="fixed inset-0 z-50 flex items-center justify-center p-4" > - + diff --git a/app/components/shell/AppSidebar.vue b/app/components/shell/AppSidebar.vue new file mode 100644 index 00000000..f415faa4 --- /dev/null +++ b/app/components/shell/AppSidebar.vue @@ -0,0 +1,149 @@ + + + + + + + + diff --git a/app/components/shell/CommandPalette.vue b/app/components/shell/CommandPalette.vue new file mode 100644 index 00000000..7dff34ce --- /dev/null +++ b/app/components/shell/CommandPalette.vue @@ -0,0 +1,224 @@ + + + + + + + + + Esc + + + + + + {{ section.title }} + + + + {{ item.label }} + {{ item.meta }} + + + {{ t('shell.search.loading') }} + + {{ t('shell.search.empty', { q: query.trim() }) }} + + + + + ↑↓ + {{ t('shell.search.hint') }} + + + + + + + + diff --git a/app/components/shell/TransferMeter.vue b/app/components/shell/TransferMeter.vue new file mode 100644 index 00000000..17a06740 --- /dev/null +++ b/app/components/shell/TransferMeter.vue @@ -0,0 +1,64 @@ + + + + + + + {{ formatSize(uploaded) }} + + + {{ formatSize(downloaded) }} + + + + + + + + + + + {{ t('shell.meter.ratio') }} + {{ ratioLabel }} + + + + + + + + diff --git a/app/components/shell/UserMenu.vue b/app/components/shell/UserMenu.vue new file mode 100644 index 00000000..00733fed --- /dev/null +++ b/app/components/shell/UserMenu.vue @@ -0,0 +1,170 @@ + + + + + + + + {{ user?.username }} + + + + + + + {{ user?.username }} + + {{ user?.isAdmin ? t('common.admin') : t('common.moderator') }} + + + + + + {{ t('shell.user.passkey') }} + + {{ passkey ?? '…' }} + + + + + + + + + {{ t('shell.nav.shop') }} + {{ t('shell.user.points', { n: Math.floor(points).toLocaleString(locale) }) }} + + + {{ t('shell.nav.profile') }} + + + + + + {{ t('shell.user.theme') }} + + + + {{ t('shell.user.language') }} + + + + + + {{ t('shell.user.signOut') }} + + + + + + + diff --git a/app/composables/useTheme.ts b/app/composables/useTheme.ts new file mode 100644 index 00000000..8885ca48 --- /dev/null +++ b/app/composables/useTheme.ts @@ -0,0 +1,47 @@ +/** + * Color theme: follows the system by default, or a user choice kept in a + * cookie. Applied as data-theme on during SSR, so there is no flash. + */ +export type ThemePreference = 'system' | 'light' | 'dark'; + +const THEME_COOKIE = 'trackarr_theme'; + +const THEMES: readonly string[] = ['system', 'light', 'dark']; + +export function isThemePreference(value: unknown): value is ThemePreference { + return typeof value === 'string' && THEMES.includes(value); +} + +export function useTheme() { + const cookie = useCookie(THEME_COOKIE, { + default: () => 'system', + maxAge: 60 * 60 * 24 * 365, + sameSite: 'lax', + path: '/', + }); + // A stale or edited cookie falls back to the system theme + const preference = computed({ + get: () => (isThemePreference(cookie.value) ? cookie.value : 'system'), + set: (value) => (cookie.value = value), + }); + + function setTheme(value: ThemePreference) { + preference.value = value; + } + + return { preference, setTheme }; +} + +/** + * Call once from the root layout/app to bind the preference to + */ +export function useThemeAttribute() { + const { preference } = useTheme(); + useHead({ + htmlAttrs: { + 'data-theme': computed(() => + preference.value === 'system' ? undefined : preference.value + ), + }, + }); +} diff --git a/app/layouts/default.vue b/app/layouts/default.vue index d88c5564..d39b9e5d 100644 --- a/app/layouts/default.vue +++ b/app/layouts/default.vue @@ -1,346 +1,104 @@ - - - + - - - - - - - + {{ t('shell.skip') }} + + + + + + + + + + + + + + + + {{ t('shell.search.trigger') }} + {{ t('shell.search.placeholder') }} + + {{ shortcut }} + + + + - - - - - - - - - {{ t(link.labelKey) }} - - - - - - - - - - - {{ - formatSize(user.uploaded) - }} - - - - {{ - formatSize(user.downloaded) - }} - - - - {{ t('common.ratio') }} - - {{ calculateRatio(user.uploaded, user.downloaded) }} - - - - - - - - - - - - - - - - - {{ user?.username }} - - + + {{ freeleechLabel }} + + - - - - - - {{ user?.username }} - - - - {{ t('common.admin') }} - - - {{ t('common.moderator') }} - - - - - - - {{ t('layout.passkey') }} - - {{ passkey ?? '…' }} - - - - - {{ t('bonus.menu.shop') }} - - - {{ t('bonus.points', { n: Math.floor(bonusPoints).toLocaleString(locale) }) }} - - - - - - - - {{ t('common.uploaded') }} - - - {{ formatSize(user?.uploaded || 0) }} - - - - - {{ t('common.downloaded') }} - - - {{ formatSize(user?.downloaded || 0) }} - - - - - {{ t('common.ratio') }} - - - {{ calculateRatio(user?.uploaded, user?.downloaded) }} - - - - - - - - {{ t('layout.signOut') }} - - - - - + + + - - + - - - + + - - - - {{ announcement.message }} - + + + {{ announcement.message }} - + - - - - - - - - - - {{ - freeleech.until - ? t('freeleech.bannerUntil', { - date: new Date(freeleech.until).toLocaleString(locale, { - dateStyle: 'medium', - timeStyle: 'short', - }), - }) - : t('freeleech.banner') - }} - - - - + - - - - + + + - - @@ -81,7 +81,7 @@ - + {{ t('auth.register.zkeTitle') }} {{ t('auth.register.zkeText') }} @@ -175,7 +175,7 @@ class="mb-4 p-3 bg-red-500/10 border border-red-500/20 rounded" > - + {{ t('auth.register.panicWarning') }} diff --git a/app/pages/forum/index.vue b/app/pages/forum/index.vue index 2c16ab62..6440bfcf 100644 --- a/app/pages/forum/index.vue +++ b/app/pages/forum/index.vue @@ -85,7 +85,7 @@ `rgb(var(--${name}) / )` + export default { content: [ './app/**/*.{vue,ts}', @@ -10,36 +14,51 @@ export default { theme: { extend: { colors: { - // Monochrome palette bg: { - primary: '#0a0a0a', - secondary: '#141414', - tertiary: '#1a1a1a', - hover: '#252525' + primary: token('bg'), + secondary: token('surface'), + tertiary: token('raised'), + hover: token('hover') }, text: { - primary: '#fafafa', - secondary: '#a1a1a1', - muted: '#666666' + primary: token('ink'), + secondary: token('ink-2'), + muted: token('ink-3') }, border: { - DEFAULT: '#2a2a2a', - hover: '#3a3a3a' + DEFAULT: token('line'), + hover: token('line-strong') }, + // Meaningful colors: upload/seed, download/leech, freeleech (the one accent) + seed: token('seed'), + leech: token('leech'), + free: token('free'), accent: { - DEFAULT: '#ffffff', - muted: '#888888' + DEFAULT: token('free'), + muted: token('ink-3') }, - success: '#22c55e', - warning: '#eab308', - error: '#ef4444' + success: token('seed'), + warning: token('leech'), + error: token('danger'), + danger: token('danger'), + // "white"/"black" are contrast inks: they flip with the theme, so the + // existing white-on-black controls stay legible in light mode too + white: token('contrast'), + black: token('contrast-inv'), + // Fixed colors that must not flip: overlays and shadows stay dark, + // text on colored fills and uploaded logos sit on real white + scrim: 'rgb(0 0 0 / )', + paper: 'rgb(255 255 255 / )' }, fontFamily: { - sans: ['Inter', 'system-ui', 'sans-serif'], - mono: ['JetBrains Mono', 'Fira Code', 'monospace'] + sans: ['"IBM Plex Sans"', 'system-ui', 'sans-serif'], + mono: ['"IBM Plex Mono"', 'ui-monospace', 'monospace'] }, fontSize: { - '2xs': ['0.65rem', { lineHeight: '0.85rem' }] + '2xs': ['0.6875rem', { lineHeight: '1rem' }] + }, + borderRadius: { + DEFAULT: '0.3125rem' } } }, diff --git a/tests/theme.test.ts b/tests/theme.test.ts new file mode 100644 index 00000000..ccd7656c --- /dev/null +++ b/tests/theme.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest'; +import { isThemePreference } from '../app/composables/useTheme'; + +describe('isThemePreference', () => { + it('accepts the three known values', () => { + expect(['system', 'light', 'dark'].every(isThemePreference)).toBe(true); + }); + + it('rejects anything else', () => { + for (const value of ['blue', '', 'Dark', undefined, null, 1]) { + expect(isThemePreference(value)).toBe(false); + } + }); +});
- + {{ t('admin.torznabRateLimit.immediateNote') }}
{{ user?.username }}
+ {{ user?.isAdmin ? t('common.admin') : t('common.moderator') }} +
{{ t('shell.user.passkey') }}
{{ passkey ?? '…' }}
- {{ user?.username }} -
- {{ t('layout.passkey') }} -
- {{ t('common.uploaded') }} -
- {{ formatSize(user?.uploaded || 0) }} -
- {{ t('common.downloaded') }} -
- {{ formatSize(user?.downloaded || 0) }} -
- {{ t('common.ratio') }} -
- {{ calculateRatio(user?.uploaded, user?.downloaded) }} -
- {{ announcement.message }} -
{{ announcement.message }}
- {{ - freeleech.until - ? t('freeleech.bannerUntil', { - date: new Date(freeleech.until).toLocaleString(locale, { - dateStyle: 'medium', - timeStyle: 'short', - }), - }) - : t('freeleech.banner') - }} -
- + {{ t('auth.register.zkeTitle') }} {{ t('auth.register.zkeText') }}
- + {{ t('auth.register.panicWarning') }}