{
+ 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);
+ }
+ });
+});