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 @@
+
+ +
+ + + 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('home.recentlyIndexed') }} -

-
- - {{ t('home.browseAll') }} - + +
+
+

{{ t('shell.meter.ratio') }}

+

{{ ratioLabel }}

+

+ ↑ {{ formatSize(account.uploaded) }} + ↓ {{ formatSize(account.downloaded) }} +

-
-
- -
+
+

{{ t('dashboard.seeding') }}

+

{{ account.seeding }}

+

+ {{ t('dashboard.leeching', { n: account.leeching }, account.leeching) }} +

-
- - -
-
-

-
+

{{ t('dashboard.points') }}

+

{{ Math.floor(account.points).toLocaleString(locale) }}

+

{{ t('dashboard.spendPoints') }}

+ + +

{{ t('dashboard.hnr') }}

+

{{ account.hnr }}

+

+ {{ account.hnr > 0 ? t('dashboard.hnrWarning') : t('dashboard.hnrNone') }} +

+
+

+ + +
+
+ +
+ +
+
+

{{ t('dashboard.latest') }}

+ {{ t('dashboard.seeAll') }} +
+ + + + + + + + + + + + + + + + + + + +
{{ t('dashboard.columns.name') }} + {{ t('dashboard.columns.seeders') }} + + {{ t('dashboard.columns.leechers') }} +
+ + {{ torrent.name }} + + {{ torrent.category }} + {{ torrent.seeders }}{{ torrent.leechers }}
+
+ {{ t('dashboard.noTorrents') }} + +
+
+ +
+ +
+
+

{{ t('dashboard.bounties') }}

+ {{ t('dashboard.seeAll') }} +
+
    +
  • + + {{ bounty.title }} + {{ t('bonus.points', { n: Math.floor(bounty.totalPoints).toLocaleString(locale) }) }} + +
  • +
+

+ {{ t('dashboard.noBounties') }} + {{ t('dashboard.requestTorrent') }} +

+
+ + +
+
+

{{ t('dashboard.forum') }}

+ {{ t('dashboard.seeAll') }} +
+
    +
  • + + {{ topic.title }} + + {{ [topic.category, topic.author, formatAge(String(topic.updatedAt))].filter(Boolean).join(', ') }} + + +
  • +
+

+ {{ t('dashboard.noTopics') }} + {{ t('dashboard.startTopic') }} +

+
+ +
- - diff --git a/app/pages/torrents/[hash].vue b/app/pages/torrents/[hash].vue index 5c164f8c..4576c3ef 100644 --- a/app/pages/torrents/[hash].vue +++ b/app/pages/torrents/[hash].vue @@ -248,7 +248,7 @@
{ + const { user } = await requireAuthSession(event); + + const [account, activity, hnr, bonus, welcome, latest, bounties, topics] = + await Promise.all([ + db.query.users.findFirst({ + where: eq(schema.users.id, user.id), + columns: { bonusPoints: true, uploaded: true, downloaded: true, createdAt: true }, + }), + getUserActivity(user.id).catch(() => ({ seeding: 0, leeching: 0 })), + getUserHnrCount(user.id), + getBonusSettings(), + getWelcomeMessage(), + db.query.torrents.findMany({ + where: and(eq(schema.torrents.isActive, true), eq(schema.torrents.isApproved, true)), + columns: { infoHash: true, name: true, size: true, createdAt: true }, + with: { category: { columns: { name: true } } }, + orderBy: [desc(schema.torrents.createdAt)], + limit: 8, + }), + db.query.bounties.findMany({ + where: eq(schema.bounties.status, 'open'), + columns: { id: true, title: true, totalPoints: true }, + orderBy: [desc(schema.bounties.totalPoints)], + limit: 5, + }), + db.query.forumTopics.findMany({ + columns: { id: true, title: true, updatedAt: true }, + with: { + author: { columns: { username: true } }, + category: { columns: { name: true } }, + }, + orderBy: [desc(schema.forumTopics.updatedAt)], + limit: 5, + }), + ]); + + // Without Redis the page still renders, with empty swarm counts + const stats = await getStatsMany(latest.map((t) => t.infoHash)).catch( + () => new Map() + ); + + return { + account: { + uploaded: account?.uploaded ?? 0, + downloaded: account?.downloaded ?? 0, + memberSince: account?.createdAt ?? null, + points: bonus.enabled ? (account?.bonusPoints ?? 0) : null, + seeding: activity.seeding, + leeching: activity.leeching, + hnr, + }, + // Admin-written note (Admin > Branding), rendered as rich text + welcomeMessage: welcome.trim() ? sanitizeRichText(welcome) : null, + latest: latest.map((t) => ({ + ...t, + category: t.category?.name ?? null, + seeders: stats.get(t.infoHash)?.seeders ?? 0, + leechers: stats.get(t.infoHash)?.leechers ?? 0, + })), + bounties: bonus.enabled ? bounties : [], + topics: topics.map((t) => ({ + id: t.id, + title: t.title, + updatedAt: t.updatedAt, + author: t.author?.username ?? null, + category: t.category?.name ?? null, + })), + }; +}); diff --git a/server/redis/userActivity.ts b/server/redis/userActivity.ts new file mode 100644 index 00000000..7355eae6 --- /dev/null +++ b/server/redis/userActivity.ts @@ -0,0 +1,53 @@ +/** + * Per-user swarm activity: which torrents a user is currently seeding or + * leeching, as sorted sets scored by last announce time. Entries older than + * the peer TTL are ignored and pruned. + */ + +import { redis } from './client'; +import { PEER_TTL } from './peerCount'; + +const SEEDING_KEY = (userId: string) => `user:${userId}:seeding`; +const LEECHING_KEY = (userId: string) => `user:${userId}:leeching`; + +export async function recordUserPeer( + userId: string, + infoHash: string, + seeding: boolean +): Promise { + const now = Date.now(); + const [current, other] = seeding + ? [SEEDING_KEY(userId), LEECHING_KEY(userId)] + : [LEECHING_KEY(userId), SEEDING_KEY(userId)]; + await redis + .multi() + .zadd(current, now, infoHash) + .zrem(other, infoHash) + .expire(current, PEER_TTL) + .exec(); +} + +export async function removeUserPeer(userId: string, infoHash: string): Promise { + await redis + .multi() + .zrem(SEEDING_KEY(userId), infoHash) + .zrem(LEECHING_KEY(userId), infoHash) + .exec(); +} + +export async function getUserActivity( + userId: string +): Promise<{ seeding: number; leeching: number }> { + const cutoff = Date.now() - PEER_TTL * 1000; + const result = await redis + .multi() + .zremrangebyscore(SEEDING_KEY(userId), 0, cutoff) + .zremrangebyscore(LEECHING_KEY(userId), 0, cutoff) + .zcard(SEEDING_KEY(userId)) + .zcard(LEECHING_KEY(userId)) + .exec(); + return { + seeding: Number(result?.[2]?.[1] ?? 0), + leeching: Number(result?.[3]?.[1] ?? 0), + }; +} diff --git a/server/tracker/handlers.ts b/server/tracker/handlers.ts index 635774a3..8bb38698 100644 --- a/server/tracker/handlers.ts +++ b/server/tracker/handlers.ts @@ -14,9 +14,14 @@ import { createHnrEntry, updateSeedTime } from '../utils/hnr'; import { createHash } from 'crypto'; import { computeCredit, getMaxRateBytes } from './credit'; import { computeSeedingBonus } from './bonusAccrual'; +import { recordUserPeer, removeUserPeer } from '../redis/userActivity'; import { getTrackerTorrent, getTrackerUser, markSnatched } from './lookups'; import { getFreeleechState } from '../utils/settings'; +function logActivityError(err: unknown) { + console.error('[Activity] User peer tracking failed:', err); +} + function hashOwner(passkey: string): string { return createHash('sha256').update(`peer-owner:${passkey}`).digest('hex').slice(0, 16); } @@ -183,6 +188,8 @@ export async function handleAnnounce(params: { if (event === 'stopped') { // Remove peer from swarm await removePeer(infoHash, peerId); + // Dashboard counter only: never fail the announce over it + if (user) await removeUserPeer(user.id, infoHash).catch(logActivityError); return; } @@ -196,6 +203,7 @@ export async function handleAnnounce(params: { left: params.left, isSeeder: seeding, }); + if (user) await recordUserPeer(user.id, infoHash, seeding).catch(logActivityError); // Track completed downloads if (event === 'completed') { diff --git a/tailwind.config.ts b/tailwind.config.ts index ac52b439..83a993d0 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -1,5 +1,9 @@ import type { Config } from 'tailwindcss' +// Colors are CSS variables (RGB channels) defined in app/assets/css/main.css, +// so the light and dark themes swap them without touching components. +const token = (name: string) => `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); + } + }); +});