diff --git a/.dockerignore b/.dockerignore index 72785d04..bad19657 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,8 +2,28 @@ **/dist **/build **/.svelte-kit -**/coverage -.git **/.env **/.env.* !**/.env.example +*.log +npm-debug.log* + +.vscode +*.swp +*.swo + +.git +.gitignore + +**/Dockerfile* +docker-compose*.yml + +*.md +!README.md +LICENSE* +.DS_Store +*.tsbuildinfo + +**/coverage +**/test-results +**/playwright-report diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..33fd6576 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,10 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 2 + diff --git a/.github/CODEOWNERS.md b/.github/CODEOWNERS.md new file mode 100644 index 00000000..c96205ff --- /dev/null +++ b/.github/CODEOWNERS.md @@ -0,0 +1,3 @@ +# CODEOWNERS + +- @Logan2234 diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..86e38c24 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,24 @@ +## Summary * + + + +## Related * + +- [ ] Quackback ticket: +- [ ] Issue link: +- [ ] n/a + +## Screenshots + + + +## Checklist + +- [ ] Tests added/updated for the behavior this changes (a bug fix includes a regression test that failed before it) +- [ ] New user-facing strings added to **both** `fr` and `en` message catalogs +- [ ] `pnpm build:package` run if `packages/shared` changed +- [ ] A Prisma migration is included if `schema.prisma` changed +- [ ] No `--no-verify` — hooks ran clean +- [ ] Manual testing performed diff --git a/.github/workflows/ghcr-cleanup.yml b/.github/workflows/ghcr-cleanup.yml new file mode 100644 index 00000000..8adf5515 --- /dev/null +++ b/.github/workflows/ghcr-cleanup.yml @@ -0,0 +1,34 @@ +name: GHCR cleanup + +on: + schedule: + # Weekly is plenty — untagged versions only pile up one push to main at a + # time (docker-push in ci.yml), never in bulk. + - cron: "12 4 * * 1" + workflow_dispatch: + +permissions: + contents: read + +jobs: + cleanup: + runs-on: ubuntu-latest + permissions: + packages: write + steps: + # Every push to main tags both loomkeep-api and loomkeep-web with + # `latest` and the commit's short SHA (see ci.yml's docker-push job) — + # nothing is ever pushed genuinely untagged by that job itself. What + # accumulates as "untagged" in the package view is Buildx's build + # provenance/SBOM attestation manifests, one extra per pushed image. + # GHCR has no built-in expiry for a personal-account package (that's an + # org-only setting), so without this, they'd sit there forever. This + # action understands the referrer/attestation relationship and only + # removes what nothing else points to — a plain "delete anything + # untagged" approach would be unsafe here. + - uses: dataaxiom/ghcr-cleanup-action@d52806a0dc70b430571a37da1fde39733ffd640f # v1.2.2 + with: + package: loomkeep-api,loomkeep-web + delete-untagged: true + delete-orphaned-images: true + registry-url: "https://ghcr.io" diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..8fdd954d --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..2a76c8c2 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,90 @@ +# Contributing to Loomkeep + +Thanks for taking the time to contribute! Loomkeep is maintained solo +([@Logan2234](https://github.com/Logan2234)) as a side project, so response +times can vary — but PRs and issues are genuinely welcome. + +## Before you file something + +- **Feature idea or a bug in the app itself** (UI, sync, watch tracking...)? + That goes on [feedback.loomkeep.app](https://feedback.loomkeep.app), not + GitHub Issues — the **Feature Requests** and **Bug Reports** boards there + are public and votable. +- **Bug in self-hosting/deployment** (Docker, migrations, reverse proxy)? + [Open a GitHub issue](https://github.com/Logan2234/loomkeep/issues/new/choose) + with the _Self-hosting / deployment bug_ template. +- **Anything about the codebase itself** (a contribution question, CI, docs)? + Same place, _Other_ template. +- **Security vulnerability?** Don't open a public issue — see + [SECURITY.md](SECURITY.md). + +## Project layout + +pnpm workspace, 100% TypeScript: + +| Path | What | +| ----------------- | ---------------------------------------------------------- | +| `apps/api` | NestJS + Prisma + PostgreSQL | +| `apps/web` | SvelteKit PWA | +| `packages/shared` | DTOs/enums shared by both, consumed from its built `dist/` | + +[`CLAUDE.md`](CLAUDE.md) is the deep-dive architecture doc (data model, +auth, feature flags, i18n, conventions) — worth a skim before a non-trivial +change, whether you're a human or an AI coding agent. + +## Local setup + +```sh +pnpm i +docker run -d --name loomkeep-dev-db -e POSTGRES_USER=loomkeep \ + -e POSTGRES_PASSWORD=loomkeep -e POSTGRES_DB=loomkeep \ + -p 5433:5432 postgres:18-alpine +cp .env.example .env +cp apps/api/.env.example apps/api/.env +pnpm --filter @loomkeep/api exec prisma migrate dev +pnpm generate +pnpm dev # api on :3000, web on :5173 +``` + +Full self-hosting instructions (Docker, add-ons, SSO...) are in the +[README](README.md) — that setup is for _running_ Loomkeep, this one is for +_working on it_. + +## Making a change + +1. Branch off `main`, `feat/`, `fix/`, `chore/` prefixes are required. +2. Match the existing style rather than introducing a new one: read the + surrounding code before writing yours, and check `apps/web/DESIGN.md` for + anything UI-facing. +3. Keep changes surgical — a bug fix doesn't need a drive-by refactor of + nearby code, and vice versa. If you spot something else worth fixing, + mention it in the PR description rather than folding it in. +4. Every non-trivial feature needs at least one test; a bug fix needs a + regression test that fails before the fix and passes after. +5. `pre-commit` (lint-staged) and `pre-push` (typecheck) hooks run + automatically — don't skip them (`--no-verify`). CI runs the full test + suite, e2e, and a few security/quality scans (CodeQL, Trivy, pa11y) on + every PR. + +### Commit messages + +Imperative, English, one emoji prefix per the summary line: + +| Emoji | Code | For | +| ----- | ------------ | --------------------------- | +| ✨ | `:sparkles:` | A new feature | +| 🐛 | `:bug:` | A bug fix | +| ♻️ | `:recycle:` | A refactor | +| 📝 | `:memo:` | Docs, comments, tests | +| ⚡ | `:zap:` | Performance, build, tooling | +| 🔖 | `:bookmark:` | A version bump | + +## Opening a pull request + +Target `main`. The PR template asks for the essentials — fill it in, it's +short on purpose. Draft PRs are fine if you want early feedback. + +## License + +AGPL-3.0. By contributing, you agree your changes are licensed under the +same terms as the rest of the project. diff --git a/SECURITY.md b/SECURITY.md index fb2c0ac6..11820448 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,8 +1,12 @@ # Security Policy -Loomkeep is a self-hosted, single-owner project — there's no hosted -multi-tenant instance to protect, but a vulnerability in the code still -matters for anyone self-hosting it. +Loomkeep ships two ways: self-hosted (your own PostgreSQL, your own data), +and as a hosted instance at [loomkeep.app](https://loomkeep.app) with real +user accounts — registration, auth, and personal data (watch history, +reviews, social graph) that a vulnerability could actually expose. Both +matter: a report against the hosted instance is treated like a real +multi-tenant incident, and a report against the self-hosted path is treated +as something every self-hoster's data depends on. ## Supported versions diff --git a/apps/api/package.json b/apps/api/package.json index dc6c117c..dd3f50af 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -53,10 +53,10 @@ "class-transformer": "^0.5.1", "class-validator": "^0.15.1", "dotenv": "^17.4.2", - "fastify": "^5.12.1", - "nestjs-pino": "^5.0.0", - "nodemailer": "^9.1.0", - "otpauth": "^9.5.1", + "fastify": "^5.12.3", + "nestjs-pino": "^5.1.0", + "nodemailer": "^10.0.0", + "otpauth": "^9.5.2", "pino-http": "^11.0.0", "prisma": "^7.10.0", "reflect-metadata": "^0.2.2", @@ -69,8 +69,8 @@ "@nestjs/cli": "^12.0.0", "@nestjs/schematics": "^12.0.0", "@nestjs/testing": "^12.0.1", - "@swc/core": "^1.16.1", - "@types/node": "^26.4.0", + "@swc/core": "^1.16.2", + "@types/node": "^26.4.1", "@types/nodemailer": "^8.0.1", "@types/supertest": "^7.2.1", "@types/web-push": "^3.6.4", diff --git a/apps/api/src/gamification/gamification-feature.guard.ts b/apps/api/src/gamification/gamification-feature.guard.ts new file mode 100644 index 00000000..20a8b429 --- /dev/null +++ b/apps/api/src/gamification/gamification-feature.guard.ts @@ -0,0 +1,31 @@ +import { ErrorCode } from "@loomkeep/shared"; +import { type CanActivate, HttpStatus, Injectable } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { AppException } from "../common/app.exception"; +import { FeatureFlagsService } from "../feature-flags/feature-flags.service"; +import { isGamificationEnabled } from "./gamification.config"; + +/** + * Gates a gamification-dependent endpoint behind the runtime + * GAMIFICATION_ENABLED flag. Exact copy of `SocialFeatureGuard`'s pattern: + * 404 (not 403) when disabled, so a deployment that turned gamification off + * doesn't even advertise the endpoint exists. + */ +@Injectable() +export class GamificationFeatureGuard implements CanActivate { + constructor( + private readonly config: ConfigService, + private readonly flags: FeatureFlagsService, + ) {} + + canActivate(): boolean { + if (!isGamificationEnabled(this.config, this.flags)) { + throw new AppException( + HttpStatus.NOT_FOUND, + ErrorCode.GamificationFeatureDisabled, + ); + } + + return true; + } +} diff --git a/apps/api/src/gamification/gamification.module.ts b/apps/api/src/gamification/gamification.module.ts index 4a0620ef..9c9a80f3 100644 --- a/apps/api/src/gamification/gamification.module.ts +++ b/apps/api/src/gamification/gamification.module.ts @@ -6,10 +6,11 @@ import { GamificationController } from "./gamification.controller"; import { XpService } from "./xp.service"; // G1: XP ledger + level curve. G2 adds the achievement engine (registry in -// achievements/registry.ts) and its /achievements endpoints. Still no -// leaderboard endpoint yet — that is [G7]. GET /gamification/me serves the -// viewer their own XP without going through the social profile, so levels -// keep working on a SOCIAL_ENABLED=false instance ("solo first"). +// achievements/registry.ts) and its /achievements endpoints. GET +// /gamification/me serves the viewer their own XP without going through the +// social profile, so levels keep working on a SOCIAL_ENABLED=false instance +// ("solo first"). The [G7] leaderboard is social-gated by nature, so it lives +// in SocialModule instead — see the comment there. @Module({ imports: [JobsModule], controllers: [AchievementsController, GamificationController], diff --git a/apps/api/src/gamification/level.util.spec.ts b/apps/api/src/gamification/level.util.spec.ts index 5d003d58..43af75d5 100644 --- a/apps/api/src/gamification/level.util.spec.ts +++ b/apps/api/src/gamification/level.util.spec.ts @@ -10,26 +10,26 @@ describe("xpForLevel", () => { expect(xpForLevel(1)).toBe(0); }); - it("matches the calibrated thresholds from the [G1] plan", () => { - expect(xpForLevel(2)).toBe(52); - expect(xpForLevel(5)).toBe(280); - expect(xpForLevel(10)).toBe(900); - expect(xpForLevel(20)).toBe(3040); - expect(xpForLevel(40)).toBe(10_920); - expect(xpForLevel(60)).toBe(23_600); - expect(xpForLevel(LEVEL_CAP_LEVEL)).toBe(41_080); - expect(xpForLevel(LEVEL_CAP_LEVEL + 1)).toBe(42_080); - expect(xpForLevel(100)).toBe(61_080); - expect(xpForLevel(430)).toBe(391_080); + it("matches the calibrated thresholds (raised once DOMAIN_STARTED alone was clearing level 2)", () => { + expect(xpForLevel(2)).toBe(112); + expect(xpForLevel(5)).toBe(520); + expect(xpForLevel(10)).toBe(1440); + expect(xpForLevel(20)).toBe(4180); + expect(xpForLevel(40)).toBe(13_260); + expect(xpForLevel(60)).toBe(27_140); + expect(xpForLevel(LEVEL_CAP_LEVEL)).toBe(40_700); + expect(xpForLevel(LEVEL_CAP_LEVEL + 1)).toBe(41_700); + expect(xpForLevel(100)).toBe(65_700); + expect(xpForLevel(430)).toBe(395_700); }); - it("is continuous across the cap boundary (79 → 80 → 81)", () => { - const cost79to80 = xpForLevel(80) - xpForLevel(79); - const cost80to81 = xpForLevel(81) - xpForLevel(80); - const cost81to82 = xpForLevel(82) - xpForLevel(81); - expect(cost79to80).toBe(988); // 40 + 12*79, still uncapped - expect(cost80to81).toBe(1000); // 40 + 12*80 == cap, exactly - expect(cost81to82).toBe(1000); // flat cap from here on + it("is continuous across the cap boundary (74 → 75 → 76)", () => { + const cost74to75 = xpForLevel(75) - xpForLevel(74); + const cost75to76 = xpForLevel(76) - xpForLevel(75); + const cost76to77 = xpForLevel(77) - xpForLevel(76); + expect(cost74to75).toBe(988); // 100 + 12*74, still uncapped + expect(cost75to76).toBe(1000); // 100 + 12*75 == cap, exactly + expect(cost76to77).toBe(1000); // flat cap from here on }); it("is strictly monotonic increasing", () => { @@ -45,13 +45,13 @@ describe("levelForXp", () => { }); it("matches the calibrated thresholds exactly at the boundary", () => { - expect(levelForXp(52)).toBe(2); - expect(levelForXp(51)).toBe(1); - expect(levelForXp(900)).toBe(10); - expect(levelForXp(41_080)).toBe(LEVEL_CAP_LEVEL); - expect(levelForXp(42_080)).toBe(LEVEL_CAP_LEVEL + 1); - expect(levelForXp(43_080)).toBe(LEVEL_CAP_LEVEL + 2); - expect(levelForXp(391_080)).toBe(430); + expect(levelForXp(112)).toBe(2); + expect(levelForXp(111)).toBe(1); + expect(levelForXp(1440)).toBe(10); + expect(levelForXp(40_700)).toBe(LEVEL_CAP_LEVEL); + expect(levelForXp(41_700)).toBe(LEVEL_CAP_LEVEL + 1); + expect(levelForXp(42_700)).toBe(LEVEL_CAP_LEVEL + 2); + expect(levelForXp(395_700)).toBe(430); }); it("handles a very large XP total past the cap", () => { @@ -76,21 +76,21 @@ describe("levelProgress", () => { expect(levelProgress(0)).toEqual({ level: 1, xpInLevel: 0, - xpToNext: 52, + xpToNext: 112, }); }); it("reports partial progress mid-level", () => { - // 30 XP into level 1 (which costs 52 to clear). + // 30 XP into level 1 (which costs 112 to clear). expect(levelProgress(30)).toEqual({ level: 1, xpInLevel: 30, - xpToNext: 22, + xpToNext: 82, }); }); it("is consistent across the cap boundary", () => { - const progress = levelProgress(41_080 + 500); + const progress = levelProgress(40_700 + 500); expect(progress.level).toBe(LEVEL_CAP_LEVEL); expect(progress.xpInLevel).toBe(500); expect(progress.xpToNext).toBe(500); diff --git a/apps/api/src/health/health.controller.ts b/apps/api/src/health/health.controller.ts index 6b6218b6..743177b2 100644 --- a/apps/api/src/health/health.controller.ts +++ b/apps/api/src/health/health.controller.ts @@ -1,25 +1,21 @@ import { Controller, Get } from "@nestjs/common"; import { - DiskHealthIndicator, HealthCheck, HealthCheckService, MemoryHealthIndicator, PrismaHealthIndicator, } from "@nestjs/terminus"; -import { join } from "node:path"; import { Public } from "../auth/decorators/public.decorator"; import { PrismaService } from "../prisma/prisma.service"; const MEMORY_THRESHOLD_BYTES = 512 * 1024 * 1024; -// Always on (not dev-gated): its purpose is the Docker/self-host healthcheck. @Public() @Controller("health") export class HealthController { constructor( private readonly health: HealthCheckService, private readonly prismaIndicator: PrismaHealthIndicator, - private readonly diskIndicator: DiskHealthIndicator, private readonly memoryIndicator: MemoryHealthIndicator, private readonly prisma: PrismaService, ) {} @@ -29,13 +25,6 @@ export class HealthController { check() { return this.health.check([ () => this.prismaIndicator.pingCheck("database", this.prisma), - // Same directory BackupService writes daily dumps to (see its own - // BACKUP_DIR fallback) — a full disk fails backups silently otherwise. - () => - this.diskIndicator.checkStorage("disk", { - path: process.env.BACKUP_DIR ?? join(process.cwd(), "backups"), - thresholdPercent: 0.9, - }), () => this.memoryIndicator.checkHeap("memory_heap", MEMORY_THRESHOLD_BYTES), () => this.memoryIndicator.checkRSS("memory_rss", MEMORY_THRESHOLD_BYTES), diff --git a/apps/api/src/social/follow.service.ts b/apps/api/src/social/follow.service.ts index 7771eec5..343591eb 100644 --- a/apps/api/src/social/follow.service.ts +++ b/apps/api/src/social/follow.service.ts @@ -14,6 +14,7 @@ import { NotificationService } from "../notifications/notification.service"; import { PrismaService } from "../prisma/prisma.service"; import { toUserSummaryDto } from "../users/avatar.util"; import { VisibilityService } from "./visibility.service"; +import { computeIsFriend } from "./visibility.util"; const USER_SUMMARY_SELECT = { id: true, @@ -370,4 +371,37 @@ export class FollowService { }); return rows.map((r) => toUserSummaryDto(r.followee)); } + + /** + * Every user id `userId` is a friend of, per `computeIsFriend` — a PRIVATE + * account followed (their acceptance already means friend-level), or a + * PUBLIC account followed back. Two queries, not one per candidate: the + * [G7] friends-scoped leaderboard is the first caller that needs the whole + * set at once rather than a single pairwise relation. + */ + async listFriendIds(userId: string): Promise { + const [followees, followers] = await Promise.all([ + this.prisma.follow.findMany({ + where: { followerId: userId, status: "ACCEPTED" }, + select: { + followeeId: true, + followee: { select: { profileAccess: true } }, + }, + }), + this.prisma.follow.findMany({ + where: { followeeId: userId, status: "ACCEPTED" }, + select: { followerId: true }, + }), + ]); + const followsMe = new Set(followers.map((f) => f.followerId)); + return followees + .filter((f) => + computeIsFriend( + f.followee.profileAccess, + true, + followsMe.has(f.followeeId), + ), + ) + .map((f) => f.followeeId); + } } diff --git a/apps/api/src/social/leaderboard/dto/leaderboard-entry-response.dto.ts b/apps/api/src/social/leaderboard/dto/leaderboard-entry-response.dto.ts new file mode 100644 index 00000000..4ca732dd --- /dev/null +++ b/apps/api/src/social/leaderboard/dto/leaderboard-entry-response.dto.ts @@ -0,0 +1,15 @@ +import type { LeaderboardEntryDto } from "@loomkeep/shared"; +import { ApiProperty } from "@nestjs/swagger"; + +export class LeaderboardEntryResponseDto implements LeaderboardEntryDto { + id!: string; + username!: string; + displayName!: string; + + @ApiProperty({ type: String, nullable: true }) + avatarUrl!: string | null; + + xp!: number; + rank!: number; + isViewer!: boolean; +} diff --git a/apps/api/src/social/leaderboard/dto/leaderboard-query.dto.ts b/apps/api/src/social/leaderboard/dto/leaderboard-query.dto.ts new file mode 100644 index 00000000..1bab9637 --- /dev/null +++ b/apps/api/src/social/leaderboard/dto/leaderboard-query.dto.ts @@ -0,0 +1,12 @@ +import type { LeaderboardPeriod, LeaderboardScope } from "@loomkeep/shared"; +import { IsIn, IsOptional } from "class-validator"; + +export class LeaderboardQueryDto { + @IsOptional() + @IsIn(["global", "friends"]) + scope?: LeaderboardScope; + + @IsOptional() + @IsIn(["month", "year"]) + period?: LeaderboardPeriod; +} diff --git a/apps/api/src/social/leaderboard/dto/leaderboard-response.dto.ts b/apps/api/src/social/leaderboard/dto/leaderboard-response.dto.ts new file mode 100644 index 00000000..5bd399e9 --- /dev/null +++ b/apps/api/src/social/leaderboard/dto/leaderboard-response.dto.ts @@ -0,0 +1,11 @@ +import type { LeaderboardDto } from "@loomkeep/shared"; +import { ApiProperty } from "@nestjs/swagger"; +import { LeaderboardEntryResponseDto } from "./leaderboard-entry-response.dto"; + +export class LeaderboardResponseDto implements LeaderboardDto { + @ApiProperty({ type: LeaderboardEntryResponseDto, isArray: true }) + entries!: LeaderboardEntryResponseDto[]; + + @ApiProperty({ type: LeaderboardEntryResponseDto, nullable: true }) + viewerOutsideTop!: LeaderboardEntryResponseDto | null; +} diff --git a/apps/api/src/social/leaderboard/leaderboard.controller.ts b/apps/api/src/social/leaderboard/leaderboard.controller.ts new file mode 100644 index 00000000..82592b6e --- /dev/null +++ b/apps/api/src/social/leaderboard/leaderboard.controller.ts @@ -0,0 +1,37 @@ +import type { LeaderboardDto } from "@loomkeep/shared"; +import { Controller, Get, Query, UseGuards } from "@nestjs/common"; +import { ApiOkResponse } from "@nestjs/swagger"; +import { + CurrentUser, + type JwtPayload, +} from "../../auth/decorators/current-user.decorator"; +import { GamificationFeatureGuard } from "../../gamification/gamification-feature.guard"; +import { SocialFeatureGuard } from "../social-feature.guard"; +import { LeaderboardQueryDto } from "./dto/leaderboard-query.dto"; +import { LeaderboardResponseDto } from "./dto/leaderboard-response.dto"; +import { LeaderboardService } from "./leaderboard.service"; + +/** + * [G7] Ranks by XP. Gated behind BOTH SocialFeatureGuard and + * GamificationFeatureGuard — a leaderboard needs XP to exist AND other users + * to rank against, so either flag being off 404s it, same as every other + * social surface. + */ +@Controller("leaderboard") +@UseGuards(SocialFeatureGuard, GamificationFeatureGuard) +export class LeaderboardController { + constructor(private readonly leaderboard: LeaderboardService) {} + + @Get() + @ApiOkResponse({ type: LeaderboardResponseDto }) + get( + @CurrentUser() user: JwtPayload, + @Query() query: LeaderboardQueryDto, + ): Promise { + return this.leaderboard.getLeaderboard( + user.sub, + query.scope ?? "global", + query.period ?? "month", + ); + } +} diff --git a/apps/api/src/social/leaderboard/leaderboard.service.spec.ts b/apps/api/src/social/leaderboard/leaderboard.service.spec.ts new file mode 100644 index 00000000..ad684df5 --- /dev/null +++ b/apps/api/src/social/leaderboard/leaderboard.service.spec.ts @@ -0,0 +1,226 @@ +import { ProfileAccess } from "@loomkeep/shared"; +import { describe, expect, it, vi } from "vitest"; +import type { PrismaService } from "../../prisma/prisma.service"; +import type { FollowService } from "../follow.service"; +import { LeaderboardService, periodRange } from "./leaderboard.service"; + +interface MockUser { + id: string; + username: string; + displayName: string; + avatarUpdatedAt: Date | null; + profileAccess: ProfileAccess; + createdAt: Date; +} + +function makeUser(over: Partial & { id: string }): MockUser { + return { + username: over.id, + displayName: over.id, + avatarUpdatedAt: null, + profileAccess: ProfileAccess.PUBLIC, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + ...over, + }; +} + +function makeService(opts: { + sums: { userId: string; _sum: { amount: number | null } }[]; + users: MockUser[]; + follows?: { followeeId: string }[]; + friendIds?: string[]; +}) { + const groupBy = vi.fn().mockResolvedValue(opts.sums); + const findManyUser = vi.fn().mockResolvedValue(opts.users); + const findManyFollow = vi.fn().mockResolvedValue(opts.follows ?? []); + + const prisma = { + xpEntry: { groupBy }, + user: { findMany: findManyUser }, + follow: { findMany: findManyFollow }, + } as unknown as PrismaService; + + const follow = { + listFriendIds: vi.fn().mockResolvedValue(opts.friendIds ?? []), + } as unknown as FollowService; + + return { + service: new LeaderboardService(prisma, follow), + groupBy, + findManyUser, + findManyFollow, + follow, + }; +} + +describe("LeaderboardService.getLeaderboard", () => { + it("shares a rank across a tie and skips ahead by the tie's size for the next distinct value", async () => { + const users = [ + makeUser({ id: "a", createdAt: new Date("2026-01-01") }), + makeUser({ id: "b", createdAt: new Date("2026-02-01") }), + makeUser({ id: "c", createdAt: new Date("2026-03-01") }), + makeUser({ id: "d", createdAt: new Date("2026-04-01") }), + ]; + const { service } = makeService({ + sums: [ + { userId: "a", _sum: { amount: 100 } }, + { userId: "b", _sum: { amount: 90 } }, + { userId: "c", _sum: { amount: 90 } }, + { userId: "d", _sum: { amount: 80 } }, + ], + users, + }); + + const { entries } = await service.getLeaderboard( + "nobody", + "global", + "month", + ); + const rankOf = (id: string) => entries.find((e) => e.id === id)?.rank; + + expect(rankOf("a")).toBe(1); + expect(rankOf("b")).toBe(2); + expect(rankOf("c")).toBe(2); + expect(rankOf("d")).toBe(4); + }); + + it("puts the viewer's row in viewerOutsideTop only past the Top 100 cutoff, not in entries", async () => { + const users = Array.from({ length: 101 }, (_, i) => + makeUser({ id: `u${i}` }), + ); + const sums = users.map((u, i) => ({ + userId: u.id, + // 101 distinct values, strictly descending, so every row gets its own rank. + _sum: { amount: 1000 - i }, + })); + const viewerId = "u100"; // last row: rank 101, outside the Top 100. + + const { service } = makeService({ sums, users }); + const result = await service.getLeaderboard(viewerId, "global", "month"); + + expect(result.entries).toHaveLength(100); + expect(result.entries.some((e) => e.id === viewerId)).toBe(false); + expect(result.viewerOutsideTop?.id).toBe(viewerId); + expect(result.viewerOutsideTop?.rank).toBe(101); + expect(result.viewerOutsideTop?.isViewer).toBe(true); + }); + + it("returns an empty board (not an error) when nobody has XP this period", async () => { + const { service } = makeService({ sums: [], users: [] }); + const result = await service.getLeaderboard("viewer", "global", "month"); + expect(result).toEqual({ entries: [], viewerOutsideTop: null }); + }); + + it("masks a PRIVATE row's avatar unless the viewer follows them, regardless of what they uploaded", async () => { + const users = [ + makeUser({ + id: "stranger", + profileAccess: ProfileAccess.PRIVATE, + avatarUpdatedAt: new Date("2026-01-01"), + }), + makeUser({ + id: "friend", + profileAccess: ProfileAccess.PRIVATE, + avatarUpdatedAt: new Date("2026-01-01"), + }), + makeUser({ + id: "public-user", + profileAccess: ProfileAccess.PUBLIC, + avatarUpdatedAt: new Date("2026-01-01"), + }), + ]; + const { service, findManyFollow } = makeService({ + sums: [ + { userId: "stranger", _sum: { amount: 300 } }, + { userId: "friend", _sum: { amount: 200 } }, + { userId: "public-user", _sum: { amount: 100 } }, + ], + users, + follows: [{ followeeId: "friend" }], + }); + + const { entries } = await service.getLeaderboard( + "viewer", + "global", + "month", + ); + const byId = (id: string) => entries.find((e) => e.id === id)!; + + expect(byId("stranger").avatarUrl).toBeNull(); + expect(byId("friend").avatarUrl).not.toBeNull(); + expect(byId("public-user").avatarUrl).not.toBeNull(); + // Only the PRIVATE rows are ever checked against Follow — never the + // viewer's own or an already-public row. + expect(findManyFollow).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + followeeId: { in: expect.arrayContaining(["stranger", "friend"]) }, + }), + }), + ); + }); + + it("excludes GHOST and hideProgression accounts from the query itself", async () => { + const { service, groupBy } = makeService({ sums: [], users: [] }); + await service.getLeaderboard("viewer", "global", "month"); + + expect(groupBy).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + user: { + profileAccess: { not: ProfileAccess.GHOST }, + hideProgression: false, + }, + }), + }), + ); + }); + + it("scopes the friends leaderboard to the viewer plus their friend ids", async () => { + const { service, groupBy, follow } = makeService({ + sums: [], + users: [], + friendIds: ["friend-1", "friend-2"], + }); + + await service.getLeaderboard("viewer", "friends", "year"); + + expect(follow.listFriendIds).toHaveBeenCalledWith("viewer"); + expect(groupBy).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + userId: { in: ["viewer", "friend-1", "friend-2"] }, + }), + }), + ); + }); +}); + +describe("periodRange", () => { + it("bounds a calendar month, exclusive of the next month's first instant", () => { + const { start, end } = periodRange( + "month", + new Date("2026-02-15T10:00:00Z"), + ); + expect(start.toISOString()).toBe("2026-02-01T00:00:00.000Z"); + expect(end.toISOString()).toBe("2026-03-01T00:00:00.000Z"); + }); + + it("rolls a December month over into January of the next year", () => { + const { start, end } = periodRange( + "month", + new Date("2026-12-15T10:00:00Z"), + ); + expect(start.toISOString()).toBe("2026-12-01T00:00:00.000Z"); + expect(end.toISOString()).toBe("2027-01-01T00:00:00.000Z"); + }); + + it("bounds a calendar year", () => { + const { start, end } = periodRange( + "year", + new Date("2026-07-01T00:00:00Z"), + ); + expect(start.toISOString()).toBe("2026-01-01T00:00:00.000Z"); + expect(end.toISOString()).toBe("2027-01-01T00:00:00.000Z"); + }); +}); diff --git a/apps/api/src/social/leaderboard/leaderboard.service.ts b/apps/api/src/social/leaderboard/leaderboard.service.ts new file mode 100644 index 00000000..47f205d3 --- /dev/null +++ b/apps/api/src/social/leaderboard/leaderboard.service.ts @@ -0,0 +1,199 @@ +import { + type LeaderboardDto, + type LeaderboardEntryDto, + type LeaderboardPeriod, + type LeaderboardScope, + ProfileAccess, +} from "@loomkeep/shared"; +import { Injectable } from "@nestjs/common"; +import { PrismaService } from "../../prisma/prisma.service"; +import { avatarUrl } from "../../users/avatar.util"; +import { FollowService } from "../follow.service"; + +// No pagination for the MVP (see the [G7] ticket) — the visible list is +// capped here, but a whole tied group at the boundary is kept together (see +// the `rank <= TOP_CUTOFF` filter below), so it can render slightly more +// than 100 rows in the rare case of a tie straddling the cutoff. +const TOP_CUTOFF = 100; + +interface RankedRow { + id: string; + xp: number; + rank: number; + user: { + username: string; + displayName: string; + avatarUpdatedAt: Date | null; + profileAccess: ProfileAccess; + }; +} + +@Injectable() +export class LeaderboardService { + constructor( + private readonly prisma: PrismaService, + private readonly follow: FollowService, + ) {} + + /** + * Ranks by XP summed over the given calendar period — recomputed live from + * the ledger every call (no snapshot table for the MVP, per the [G7] + * ticket). GHOST and `hideProgression` accounts never appear, in either + * scope: a leaderboard is exactly the "other viewers" a Figurant or a + * hidden-progression account already opted out of showing XP to. + * + * Ranking happens in plain JS rather than a SQL window function (no raw + * query exists anywhere else in this codebase): `groupBy` has no way to + * order by a joined column, so the tie-break (older account wins) has to + * happen after fetching. The candidate set is bounded by *distinct active + * users this period*, not by ledger row count, which is the population a + * self-hosted instance actually has — pulling it into Node to sort is the + * simplest correct thing, not a shortcut that stops working at scale here. + */ + async getLeaderboard( + viewerId: string, + scope: LeaderboardScope, + period: LeaderboardPeriod, + ): Promise { + const { start, end } = periodRange(period); + const candidateIds = + scope === "friends" + ? [viewerId, ...(await this.follow.listFriendIds(viewerId))] + : null; + + const sums = await this.prisma.xpEntry.groupBy({ + by: ["userId"], + where: { + createdAt: { gte: start, lt: end }, + ...(candidateIds ? { userId: { in: candidateIds } } : {}), + user: { + profileAccess: { not: ProfileAccess.GHOST }, + hideProgression: false, + }, + }, + _sum: { amount: true }, + }); + + if (sums.length === 0) { + return { entries: [], viewerOutsideTop: null }; + } + + const users = await this.prisma.user.findMany({ + where: { id: { in: sums.map((s) => s.userId) } }, + select: { + id: true, + username: true, + displayName: true, + avatarUpdatedAt: true, + profileAccess: true, + createdAt: true, + }, + }); + const userById = new Map(users.map((u) => [u.id, u])); + + const sorted = sums + .map((s) => ({ + id: s.userId, + xp: s._sum.amount ?? 0, + user: userById.get(s.userId)!, + })) + // Older account wins a tie — arbitrary but deterministic, and it's + // what decides which of two tied rows shows the shared rank number + // versus a dash (see LeaderboardEntryDto.rank). + .sort( + (a, b) => + b.xp - a.xp || + a.user.createdAt.getTime() - b.user.createdAt.getTime(), + ); + + let rank = 0; + let previousXp: number | null = null; + const ranked: RankedRow[] = sorted.map((row, index) => { + if (row.xp !== previousXp) rank = index + 1; + previousXp = row.xp; + return { id: row.id, xp: row.xp, rank, user: row.user }; + }); + + const revealedPrivateIds = await this.revealedPrivateIds(viewerId, ranked); + const toDto = (row: RankedRow) => + this.toEntryDto(row, viewerId, revealedPrivateIds); + + const entries = ranked.filter((r) => r.rank <= TOP_CUTOFF).map(toDto); + const viewerRow = ranked.find((r) => r.id === viewerId); + const viewerOutsideTop = + viewerRow && viewerRow.rank > TOP_CUTOFF ? toDto(viewerRow) : null; + + return { entries, viewerOutsideTop }; + } + + /** + * A PRIVATE row's real avatar only shows to a viewer who is friends with + * them (an accepted follow — see `computeIsFriend`'s PRIVATE branch); every + * other viewer gets `avatarUrl: null` regardless of whether they uploaded a + * photo, so the row looks exactly like any public account without one. + */ + private async revealedPrivateIds( + viewerId: string, + rows: RankedRow[], + ): Promise> { + const privateIds = rows + .filter( + (r) => + r.user.profileAccess === ProfileAccess.PRIVATE && r.id !== viewerId, + ) + .map((r) => r.id); + if (privateIds.length === 0) return new Set(); + + const follows = await this.prisma.follow.findMany({ + where: { + followerId: viewerId, + followeeId: { in: privateIds }, + status: "ACCEPTED", + }, + select: { followeeId: true }, + }); + return new Set(follows.map((f) => f.followeeId)); + } + + private toEntryDto( + row: RankedRow, + viewerId: string, + revealedPrivateIds: Set, + ): LeaderboardEntryDto { + const isViewer = row.id === viewerId; + const showRealAvatar = + isViewer || + row.user.profileAccess !== ProfileAccess.PRIVATE || + revealedPrivateIds.has(row.id); + + return { + id: row.id, + username: row.user.username, + displayName: row.user.displayName, + avatarUrl: showRealAvatar + ? avatarUrl({ id: row.id, avatarUpdatedAt: row.user.avatarUpdatedAt }) + : null, + xp: row.xp, + rank: row.rank, + isViewer, + }; + } +} + +/** Calendar month/year in UTC — exported for direct testing. */ +export function periodRange( + period: LeaderboardPeriod, + now: Date = new Date(), +): { start: Date; end: Date } { + if (period === "month") { + return { + start: new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)), + end: new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1)), + }; + } + + return { + start: new Date(Date.UTC(now.getUTCFullYear(), 0, 1)), + end: new Date(Date.UTC(now.getUTCFullYear() + 1, 0, 1)), + }; +} diff --git a/apps/api/src/social/social.module.ts b/apps/api/src/social/social.module.ts index 0186a7cf..e62db4a9 100644 --- a/apps/api/src/social/social.module.ts +++ b/apps/api/src/social/social.module.ts @@ -3,6 +3,8 @@ import { GamificationModule } from "../gamification/gamification.module"; import { NotificationModule } from "../notifications/notification.module"; import { ActivityService } from "./activity.service"; import { FollowService } from "./follow.service"; +import { LeaderboardController } from "./leaderboard/leaderboard.controller"; +import { LeaderboardService } from "./leaderboard/leaderboard.service"; import { PrivacyController } from "./privacy.controller"; import { PrivacyService } from "./privacy.service"; import { ProfileService } from "./profile.service"; @@ -10,16 +12,21 @@ import { SocialController } from "./social.controller"; import { VisibilityService } from "./visibility.service"; // P4 social graph, profiles, search and privacy. Every route is gated behind -// SOCIAL_ENABLED via SocialFeatureGuard on the controllers. +// SOCIAL_ENABLED via SocialFeatureGuard on the controllers. The [G7] +// leaderboard lives here rather than in GamificationModule: it's inherently +// social-gated and leans on FollowService for the friends scope — putting it +// in Gamification would need Gamification to import Social, which already +// imports Gamification (AchievementService), a real circular dependency. @Module({ imports: [NotificationModule, GamificationModule], - controllers: [SocialController, PrivacyController], + controllers: [SocialController, PrivacyController, LeaderboardController], providers: [ VisibilityService, FollowService, ProfileService, PrivacyService, ActivityService, + LeaderboardService, ], exports: [VisibilityService, ActivityService, FollowService, ProfileService], }) diff --git a/apps/web/messages/en/errors.json b/apps/web/messages/en/errors.json index f9ce0cca..ca855153 100644 --- a/apps/web/messages/en/errors.json +++ b/apps/web/messages/en/errors.json @@ -5,22 +5,22 @@ "apierr_auth_email_already_exists": "An account already exists with this email address.", "apierr_auth_registration_disabled": "Registration is currently closed.", "apierr_auth_anti_bot_verification_failed": "Anti-bot verification failed. Please try again.", - "apierr_auth_password_breached": "This password has appeared in a known data breach — please choose a different one.", + "apierr_auth_password_breached": "This password has appeared in a known data breach, please choose a different one.", "apierr_auth_invalid_verification_token": "This verification link is invalid or has expired.", "apierr_auth_already_verified": "This account is already verified.", "apierr_auth_invalid_credentials": "Incorrect credentials.", "apierr_auth_invalid_mfa_challenge": "This verification session is invalid or has expired. Please log in again.", - "apierr_auth_mfa_too_many_attempts": "Too many attempts — please log in again.", + "apierr_auth_mfa_too_many_attempts": "Too many attempts, please log in again.", "apierr_auth_mfa_invalid_code": "Invalid code.", - "apierr_auth_invalid_refresh_token": "Your session has expired — please log in again.", + "apierr_auth_invalid_refresh_token": "Your session has expired, please log in again.", "apierr_auth_invalid_reset_token": "This reset link is invalid or has expired.", "apierr_auth_mfa_totp_not_in_progress": "No TOTP setup in progress.", "apierr_auth_current_password_incorrect": "The current password is incorrect.", "apierr_auth_missing_access_token": "Authentication required.", - "apierr_auth_invalid_access_token": "Your session has expired — please log in again.", + "apierr_auth_invalid_access_token": "Your session has expired, please log in again.", "apierr_auth_missing_except_param": "Invalid request.", "apierr_admin_cache_item_not_found": "This item could not be found in the cache.", - "apierr_admin_cache_resync_failed": "Resync failed — the source may be unreachable.", + "apierr_admin_cache_resync_failed": "Resync failed, the source may be unreachable.", "apierr_library_episode_not_aired": "This episode hasn't aired yet.", "apierr_user_avatar_too_large": "The image is too large.", "apierr_user_avatar_invalid_type": "The file doesn't match the expected image type.", @@ -32,7 +32,7 @@ "apierr_library_no_watch_to_undo": "No watch to undo for this episode.", "apierr_library_entry_not_found": "This item could not be found in your library.", "apierr_library_entry_forbidden": "This item belongs to another account.", - "apierr_library_replay_not_movie": "Only movies can have replays — series/anime rewatches are tracked per episode.", + "apierr_library_replay_not_movie": "Only movies can have replays, series/anime rewatches are tracked per episode.", "apierr_library_replay_not_found": "This replay could not be found.", "apierr_library_replay_forbidden": "This replay belongs to another account.", "apierr_catalog_unknown_media_type": "Unknown media type.", @@ -40,7 +40,7 @@ "apierr_catalog_media_type_required": "The media type is required.", "apierr_catalog_item_not_found": "This item could not be found in the catalogue.", "apierr_catalog_person_not_found": "This person could not be found in the catalogue.", - "apierr_catalog_provider_unavailable": "The catalogue is temporarily unavailable — try again in a moment.", + "apierr_catalog_provider_unavailable": "The catalogue is temporarily unavailable, try again in a moment.", "apierr_catalog_search_query_required": "A search term is required.", "apierr_import_malformed_export": "The exported file couldn't be read or is malformed.", "apierr_import_job_not_found": "This import could not be found.", @@ -49,26 +49,27 @@ "apierr_import_job_no_analysis": "This import hasn't been analyzed yet.", "apierr_import_job_already_running": "Another import is already running. Wait for it to finish before starting a new one.", "apierr_import_unknown_source": "Unknown import source.", - "apierr_import_free_quota_exceeded": "One free import per domain — go premium to re-import in this domain.", - "apierr_import_source_unavailable": "The import source is temporarily unavailable — try again in a moment.", - "apierr_import_steam_profile_not_found": "Steam profile not found — check the id or profile URL.", - "apierr_import_steam_library_private": "Steam library inaccessible — the profile and game details must be public.", + "apierr_import_free_quota_exceeded": "One free import per domain, go premium to re-import in this domain.", + "apierr_import_source_unavailable": "The import source is temporarily unavailable, try again in a moment.", + "apierr_import_steam_profile_not_found": "Steam profile not found, check the id or profile URL.", + "apierr_import_steam_library_private": "Steam library inaccessible, the profile and game details must be public.", "apierr_import_archive_empty": "The uploaded archive is empty.", "apierr_import_archive_unreadable": "The archive couldn't be read.", "apierr_import_archive_missing_files": "The archive is missing required files.", - "apierr_import_archive_malformed": "The archive couldn't be read — one of its files is malformed.", + "apierr_import_archive_malformed": "The archive couldn't be read, one of its files is malformed.", "apierr_gamification_achievement_not_found": "This achievement could not be found.", + "apierr_gamification_feature_disabled": "This feature isn't available on this instance.", "apierr_validation_invalid_param": "Invalid parameter.", "apierr_validation_failed": "The form contains errors.", "apierr_internal_error": "Something went wrong.", - "apierr_network_offline": "Couldn't reach the server — check your connection.", + "apierr_network_offline": "Couldn't reach the server, check your connection.", "apierr_status_400": "This request couldn't be processed.", - "apierr_status_401": "Your session has expired — please log in again.", + "apierr_status_401": "Your session has expired, please log in again.", "apierr_status_403": "You don't have access to this resource.", "apierr_status_404": "This couldn't be found.", "apierr_status_409": "This action conflicts with the current state.", - "apierr_status_429": "Too many requests — try again in a moment.", - "apierr_status_429_retry": "Too many requests — try again in {seconds}s.", + "apierr_status_429": "Too many requests, try again in a moment.", + "apierr_status_429_retry": "Too many requests, try again in {seconds}s.", "apierr_status_500": "Something went wrong on the server.", "apierr_admin_cache_item_referenced": "This cache item is still referenced and can't be deleted.", "apierr_admin_cache_item_has_content": "This cache item still has content and can't be deleted.", @@ -95,7 +96,7 @@ "apierr_lists_item_not_found": "This item could not be found in the list.", "apierr_lists_item_already_exists": "This item is already in the list.", "apierr_lists_reorder_mismatch": "The submitted order doesn't match the list's current items.", - "apierr_lists_stale": "This list changed in the meantime — reload the page.", + "apierr_lists_stale": "This list changed in the meantime, reload the page.", "apierr_lists_member_user_not_found": "This user could not be found.", "apierr_lists_cannot_add_self": "You can't add yourself to this list.", "apierr_lists_member_already_editor": "This user can already edit this list.", diff --git a/apps/web/messages/en/gamification.json b/apps/web/messages/en/gamification.json index b4a29e8f..9646c8aa 100644 --- a/apps/web/messages/en/gamification.json +++ b/apps/web/messages/en/gamification.json @@ -154,7 +154,7 @@ "gamification_curious_cat_description": "Clicked the version number.", "gamification_page_title": "Achievements", - "gamification_page_subtitle": "Everything there is to earn, and what you have left.", + "gamification_page_subtitle": "Discover all the achievements to unlock and track your progress.", "gamification_my_achievements": "My achievements", "gamification_hero_total": "/ {total} achievements", @@ -195,5 +195,17 @@ "gamification_bubble_level_title": "Level {level}", "gamification_bubble_dismiss": "Dismiss", "gamification_disabled": "Progression is disabled on this instance.", - "gamification_empty": "No achievements to show." + "gamification_empty": "No achievements to show.", + + "gamification_leaderboard_title": "Leaderboard", + "gamification_leaderboard_subtitle": "Rankings by level.", + "gamification_leaderboard_tab_global": "Global", + "gamification_leaderboard_tab_friends": "Friends", + "gamification_leaderboard_period_month": "This month", + "gamification_leaderboard_period_year": "This year", + "gamification_leaderboard_xp_unit": "xp", + "gamification_leaderboard_you": "You", + "gamification_leaderboard_disabled": "The leaderboard isn't available on this instance.", + "gamification_leaderboard_empty": "Nobody has earned XP this period yet.", + "gamification_view_leaderboard": "View leaderboard" } diff --git a/apps/web/messages/en/other.json b/apps/web/messages/en/other.json index 89e6f382..7e053520 100644 --- a/apps/web/messages/en/other.json +++ b/apps/web/messages/en/other.json @@ -17,6 +17,8 @@ "admin_job_reports_digest": "Report digest", "admin_job_backup": "Automatic backup", "admin_job_inactive_accounts": "Inactive accounts (reminder + deletion)", + "admin_job_gamification_reconcile": "XP reconciliation (integrity check)", + "admin_job_gamification_achievements_sweep": "Achievements sweep (safety net)", "admin_job_hourly": "Every hour", "admin_job_every_hours": "Every {hours} hours", "admin_job_daily_at": "Every day at {time}", @@ -231,7 +233,7 @@ "profile_activity_days": "{days} days", "profile_activity_summary_suffix": "in the last 3 months", "profile_activity_first_trace": "· first seen on {date}", - "profile_activity_view_stats": "See all activity in stats →", + "profile_activity_view_stats": "See all activity in stats", "profile_connections_followers_title": "Followers", "profile_connections_following_title": "Following", "profile_connections_empty_followers": "Nobody follows this profile yet.", @@ -241,6 +243,7 @@ "nav_section_library": "My library", "nav_section_tracking": "Tracking", "nav_feed": "Feed", + "nav_leaderboard": "Leaderboard", "nav_profile": "My profile", "auth_login_title": "Log in", "auth_login_tagline": "Your private screening room.", @@ -389,7 +392,7 @@ "add_to_list_button": "Add to a list", "avatar_alt": "Avatar for {seed}", "home_activity_title": "Recent activity", - "home_activity_view_feed": "View feed →", + "home_activity_view_feed": "View feed", "profile_reviews_title": "My reviews", "activity_added": "added", "activity_started": "started", @@ -968,7 +971,7 @@ "admin_users_empty_filter": "No accounts match this filter.", "admin_users_empty": "No registered accounts.", "admin_users_enlarge_avatar": "Enlarge avatar", - "admin_users_public_profile": "View public profile →", + "admin_users_public_profile": "View public profile", "admin_users_inactivity_reminder": "Inactivity reminder sent on", "admin_users_inactivity_deletion": "— automatic deletion if the account remains inactive (LK-C06).", "admin_users_identity": "Identity", diff --git a/apps/web/messages/fr/errors.json b/apps/web/messages/fr/errors.json index 0f477bbf..2ace4778 100644 --- a/apps/web/messages/fr/errors.json +++ b/apps/web/messages/fr/errors.json @@ -5,12 +5,12 @@ "apierr_auth_email_already_exists": "Un compte existe déjà avec cette adresse e-mail.", "apierr_auth_registration_disabled": "Les inscriptions sont actuellement fermées.", "apierr_auth_anti_bot_verification_failed": "La vérification anti-robot a échoué. Réessaie.", - "apierr_auth_password_breached": "Ce mot de passe a été trouvé dans une fuite de données connue — choisis-en un autre.", + "apierr_auth_password_breached": "Ce mot de passe a été trouvé dans une fuite de données connue, choisis-en un autre.", "apierr_auth_invalid_verification_token": "Ce lien de vérification est invalide ou a expiré.", "apierr_auth_already_verified": "Ce compte est déjà vérifié.", "apierr_auth_invalid_credentials": "Identifiants incorrects.", "apierr_auth_invalid_mfa_challenge": "Cette session de vérification est invalide ou a expiré. Reconnecte-toi.", - "apierr_auth_mfa_too_many_attempts": "Trop de tentatives — reconnecte-toi.", + "apierr_auth_mfa_too_many_attempts": "Trop de tentatives, reconnecte-toi.", "apierr_auth_mfa_invalid_code": "Code invalide.", "apierr_auth_invalid_refresh_token": "Ta session a expiré, reconnecte-toi.", "apierr_auth_invalid_reset_token": "Ce lien de réinitialisation est invalide ou a expiré.", @@ -32,7 +32,7 @@ "apierr_library_no_watch_to_undo": "Aucun visionnage à annuler pour cet épisode.", "apierr_library_entry_not_found": "Cet élément est introuvable dans ta bibliothèque.", "apierr_library_entry_forbidden": "Cet élément appartient à un autre compte.", - "apierr_library_replay_not_movie": "Seuls les films peuvent être revus — les séries et animes se suivent épisode par épisode.", + "apierr_library_replay_not_movie": "Seuls les films peuvent être revus, les séries et animes se suivent épisode par épisode.", "apierr_library_replay_not_found": "Ce revisionnage est introuvable.", "apierr_library_replay_forbidden": "Ce revisionnage appartient à un autre compte.", "apierr_catalog_unknown_media_type": "Type de média inconnu.", @@ -49,19 +49,20 @@ "apierr_import_job_no_analysis": "Cet import n'a pas encore été analysé.", "apierr_import_job_already_running": "Un autre import est déjà en cours. Attendez sa fin avant d'en démarrer un nouveau.", "apierr_import_unknown_source": "Source d'import inconnue.", - "apierr_import_free_quota_exceeded": "Un import gratuit par domaine — passe premium pour réimporter dans ce domaine.", + "apierr_import_free_quota_exceeded": "Un import gratuit par domaine, passe premium pour réimporter dans ce domaine.", "apierr_import_source_unavailable": "La source d'import est momentanément indisponible, réessaie dans un instant.", - "apierr_import_steam_profile_not_found": "Profil Steam introuvable — vérifie l'identifiant ou l'URL du profil.", - "apierr_import_steam_library_private": "Bibliothèque Steam inaccessible — le profil et les détails des jeux doivent être publics.", + "apierr_import_steam_profile_not_found": "Profil Steam introuvable, vérifie l'identifiant ou l'URL du profil.", + "apierr_import_steam_library_private": "Bibliothèque Steam inaccessible, le profil et les détails des jeux doivent être publics.", "apierr_import_archive_empty": "L'archive envoyée est vide.", "apierr_import_archive_unreadable": "L'archive est illisible.", "apierr_import_archive_missing_files": "Il manque des fichiers requis dans l'archive.", - "apierr_import_archive_malformed": "L'archive est illisible — un de ses fichiers est mal formé.", + "apierr_import_archive_malformed": "L'archive est illisible, un de ses fichiers est mal formé.", "apierr_gamification_achievement_not_found": "Ce succès est introuvable.", + "apierr_gamification_feature_disabled": "Cette fonctionnalité n'est pas disponible sur cette instance.", "apierr_validation_invalid_param": "Paramètre invalide.", "apierr_validation_failed": "Le formulaire contient des erreurs.", "apierr_internal_error": "Une erreur inattendue s'est produite.", - "apierr_network_offline": "Impossible de contacter le serveur — vérifie ta connexion.", + "apierr_network_offline": "Impossible de contacter le serveur, vérifie ta connexion.", "apierr_status_400": "La demande n'a pas pu être traitée.", "apierr_status_401": "Ta session a expiré, reconnecte-toi.", "apierr_status_403": "Tu n'as pas accès à cette ressource.", diff --git a/apps/web/messages/fr/gamification.json b/apps/web/messages/fr/gamification.json index 6f143906..06cdb1e0 100644 --- a/apps/web/messages/fr/gamification.json +++ b/apps/web/messages/fr/gamification.json @@ -154,7 +154,7 @@ "gamification_curious_cat_description": "A cliqué sur le numéro de version.", "gamification_page_title": "Succès", - "gamification_page_subtitle": "Tout ce qu'il y a à décrocher, et ce qu'il vous en reste.", + "gamification_page_subtitle": "Découvrez tous les succès à débloquer et suivez votre progression.", "gamification_my_achievements": "Mes succès", "gamification_hero_total": "/ {total} succès", @@ -195,5 +195,17 @@ "gamification_bubble_level_title": "Niveau {level}", "gamification_bubble_dismiss": "Masquer", "gamification_disabled": "La progression est désactivée sur cette instance.", - "gamification_empty": "Aucun succès à afficher." + "gamification_empty": "Aucun succès à afficher.", + + "gamification_leaderboard_title": "Classement", + "gamification_leaderboard_subtitle": "Classement par niveau.", + "gamification_leaderboard_tab_global": "Global", + "gamification_leaderboard_tab_friends": "Amis", + "gamification_leaderboard_period_month": "Ce mois-ci", + "gamification_leaderboard_period_year": "Cette année", + "gamification_leaderboard_xp_unit": "xp", + "gamification_leaderboard_you": "Toi", + "gamification_leaderboard_disabled": "Le classement n'est pas disponible sur cette instance.", + "gamification_leaderboard_empty": "Personne n'a encore gagné d'XP sur cette période.", + "gamification_view_leaderboard": "Voir le classement" } diff --git a/apps/web/messages/fr/other.json b/apps/web/messages/fr/other.json index dff5e506..2c11aee8 100644 --- a/apps/web/messages/fr/other.json +++ b/apps/web/messages/fr/other.json @@ -18,6 +18,8 @@ "admin_job_reports_digest": "Digest des signalements", "admin_job_backup": "Sauvegarde automatique", "admin_job_inactive_accounts": "Comptes inactifs (relance + suppression)", + "admin_job_gamification_reconcile": "Réconciliation XP (contrôle d'intégrité)", + "admin_job_gamification_achievements_sweep": "Balayage des succès (filet de sécurité)", "admin_job_hourly": "Toutes les heures", "admin_job_every_hours": "Toutes les {hours} heures", "admin_job_daily_at": "Tous les jours à {time}", @@ -231,7 +233,7 @@ "profile_activity_days": "{days} jours", "profile_activity_summary_suffix": "ces 3 derniers mois", "profile_activity_first_trace": "· première trace le {date}", - "profile_activity_view_stats": "Voir toute l'activité dans les statistiques →", + "profile_activity_view_stats": "Voir toute l'activité dans les statistiques", "profile_connections_followers_title": "Abonnés", "profile_connections_following_title": "Abonnements", "profile_connections_empty_followers": "Personne ne suit ce profil pour l'instant.", @@ -241,6 +243,7 @@ "nav_section_library": "Ma bibliothèque", "nav_section_tracking": "Suivi", "nav_feed": "Activité", + "nav_leaderboard": "Classement", "nav_profile": "Mon profil", "auth_login_title": "Connexion", "auth_login_tagline": "Ta salle de projection privée.", @@ -389,7 +392,7 @@ "add_to_list_button": "Ajouter à une liste", "avatar_alt": "Avatar de {seed}", "home_activity_title": "Dernières activités", - "home_activity_view_feed": "Voir l'activité →", + "home_activity_view_feed": "Voir l'activité", "profile_reviews_title": "Mes reviews", "activity_added": "a ajouté", "activity_started": "a commencé", @@ -968,7 +971,7 @@ "admin_users_empty_filter": "Aucun compte ne correspond à ce filtre.", "admin_users_empty": "Aucun compte enregistré.", "admin_users_enlarge_avatar": "Voir l'avatar en grand", - "admin_users_public_profile": "Voir le profil public →", + "admin_users_public_profile": "Voir le profil public", "admin_users_inactivity_reminder": "Relance inactivité envoyée le", "admin_users_inactivity_deletion": "— suppression automatique si le compte reste inactif (LK-C06).", "admin_users_identity": "Identité", diff --git a/apps/web/src/lib/api/errors.ts b/apps/web/src/lib/api/errors.ts index ac8a38c0..045a14b7 100644 --- a/apps/web/src/lib/api/errors.ts +++ b/apps/web/src/lib/api/errors.ts @@ -195,6 +195,8 @@ const MESSAGES = { [ErrorCode.NetworkOffline]: () => m.apierr_network_offline(), [ErrorCode.GamificationAchievementNotFound]: () => m.apierr_gamification_achievement_not_found(), + [ErrorCode.GamificationFeatureDisabled]: () => + m.apierr_gamification_feature_disabled(), } satisfies Record string>; /** diff --git a/apps/web/src/lib/api/gamification.ts b/apps/web/src/lib/api/gamification.ts index a76668a6..6089e8fb 100644 --- a/apps/web/src/lib/api/gamification.ts +++ b/apps/web/src/lib/api/gamification.ts @@ -1,4 +1,9 @@ -import type { AchievementDto } from "@loomkeep/shared"; +import type { + AchievementDto, + LeaderboardDto, + LeaderboardPeriod, + LeaderboardScope, +} from "@loomkeep/shared"; import { typedRequest } from "./generated/typed-request"; export function getAchievements() { @@ -23,3 +28,12 @@ export function markAchievementDisplayed(id: string) { export function getMyProgression() { return typedRequest("/gamification/me"); } + +export function getLeaderboard( + scope: LeaderboardScope, + period: LeaderboardPeriod, +) { + return typedRequest("/leaderboard", { + query: { scope, period }, + }) as Promise; +} diff --git a/apps/web/src/lib/api/keys.ts b/apps/web/src/lib/api/keys.ts index 6c02e5d7..e2d53a23 100644 --- a/apps/web/src/lib/api/keys.ts +++ b/apps/web/src/lib/api/keys.ts @@ -14,6 +14,8 @@ export const keys = { achievements: () => ["gamification", "achievements"] as const, pending: () => ["gamification", "pending"] as const, progression: () => ["gamification", "progression"] as const, + leaderboard: (scope: string, period: string) => + ["gamification", "leaderboard", scope, period] as const, }, books: { detail: (source: string, sourceId: string) => diff --git a/apps/web/src/lib/components/HomeActivityPreview.svelte b/apps/web/src/lib/components/HomeActivityPreview.svelte index fb8ae785..71e4d78c 100644 --- a/apps/web/src/lib/components/HomeActivityPreview.svelte +++ b/apps/web/src/lib/components/HomeActivityPreview.svelte @@ -3,6 +3,7 @@ import { keys } from "$lib/api/keys"; import { createApiQuery } from "$lib/api/query.svelte"; import ActivityItem from "$lib/components/ActivityItem.svelte"; + import Icon from "$lib/components/Icon.svelte"; import { appConfig } from "$lib/config.svelte"; import { m } from "$lib/paraglide/messages.js"; @@ -33,8 +34,11 @@

{m.home_activity_title()}

- + {m.home_activity_view_feed()} +
    diff --git a/apps/web/src/lib/components/Icon.svelte b/apps/web/src/lib/components/Icon.svelte index 57e37f41..fad38af3 100644 --- a/apps/web/src/lib/components/Icon.svelte +++ b/apps/web/src/lib/components/Icon.svelte @@ -244,9 +244,13 @@ {:else if name === "circle-arrow"} + {:else if name === "arrow-right"} + {:else if name === "pumpkin"} + {:else if name === "crown"} + {/if} diff --git a/apps/web/src/lib/components/ImportWizard.svelte b/apps/web/src/lib/components/ImportWizard.svelte index 40815621..6b814bc5 100644 --- a/apps/web/src/lib/components/ImportWizard.svelte +++ b/apps/web/src/lib/components/ImportWizard.svelte @@ -49,6 +49,7 @@ importReportLabel, importReportSubtitle, } from "./import-presentation"; + import PageHeader from "./PageHeader.svelte"; // `intro` holds the source-specific export instructions (markup, hence a // snippet rather than a config field); everything else comes from `source`. @@ -423,17 +424,9 @@
    -
    - - - -

    - {m.import_title({ source: descriptor.label })} -

    -
    + {#if error} {error} diff --git a/apps/web/src/lib/components/LevelCard.svelte b/apps/web/src/lib/components/LevelCard.svelte index b306e5a1..94f89305 100644 --- a/apps/web/src/lib/components/LevelCard.svelte +++ b/apps/web/src/lib/components/LevelCard.svelte @@ -1,4 +1,7 @@
    + class="border-border bg-surface relative rounded-2xl border px-6 py-5.5 shadow-[0_1px_2px_rgba(28,23,18,.06),0_8px_24px_rgba(28,23,18,.05)] dark:shadow-[0_1px_2px_rgba(0,0,0,.4),0_12px_32px_rgba(0,0,0,.35)]"> + {#if leaderboardHref && isFeatureNew("leaderboard")} + + + {/if}

    @@ -90,4 +106,15 @@

    {/each}
    + {#if leaderboardHref} + + + + {m.gamification_view_leaderboard()} + + + + {/if}
    diff --git a/apps/web/src/lib/components/PageHeader.svelte b/apps/web/src/lib/components/PageHeader.svelte index 64d25902..75bcf0f6 100644 --- a/apps/web/src/lib/components/PageHeader.svelte +++ b/apps/web/src/lib/components/PageHeader.svelte @@ -8,6 +8,7 @@ type IconName = ComponentProps["name"]; import { m } from "$lib/paraglide/messages.js"; + import NewBadge from "./NewBadge.svelte"; let { icon, @@ -15,6 +16,7 @@ subtitle, actions, back, + isNew = false, class: cls = "mb-8", }: { icon?: IconName; @@ -24,6 +26,8 @@ /** Where the "<" leads. Set on pages reached from another one (the * profile hub, a settings sub-page), omitted on nav destinations. */ back?: string; + /** Shows a "Nouveau" pill next to the title — see feature-badges.ts. */ + isNew?: boolean; class?: string; } = $props(); @@ -42,6 +46,7 @@ {/if} {#if icon}{/if} {title} + {#if isNew}{/if} {#if subtitle}

    {subtitle}

    diff --git a/apps/web/src/lib/components/profile/ProfileView.svelte b/apps/web/src/lib/components/profile/ProfileView.svelte index c0d216bd..5eff261c 100644 --- a/apps/web/src/lib/components/profile/ProfileView.svelte +++ b/apps/web/src/lib/components/profile/ProfileView.svelte @@ -488,7 +488,11 @@ {#if appConfig.gamificationEnabled && profile.xp !== null}
    - +
    {/if} @@ -730,8 +734,11 @@

    + class="btn-text text-accent hover:text-accent group mt-0.5"> {m.profile_activity_view_stats()} + diff --git a/apps/web/src/lib/components/sidebars/DesktopSidebar.svelte b/apps/web/src/lib/components/sidebars/DesktopSidebar.svelte index 033b259a..f11c9cc8 100644 --- a/apps/web/src/lib/components/sidebars/DesktopSidebar.svelte +++ b/apps/web/src/lib/components/sidebars/DesktopSidebar.svelte @@ -245,7 +245,7 @@ {/if} - {#each section.items.filter((item) => (!item.domain || isDomainEnabled(item.domain)) && (!item.social || appConfig.socialEnabled)) as item (item.href)} + {#each section.items.filter((item) => (!item.domain || isDomainEnabled(item.domain)) && (!item.social || appConfig.socialEnabled) && (!item.gamification || appConfig.gamificationEnabled)) as item (item.href)} {#if item.comingSoon}
    (!item.domain || isDomainEnabled(item.domain)) && (!item.social || appConfig.socialEnabled) && + (!item.gamification || appConfig.gamificationEnabled) && !item.comingSoon, ), })).filter((section) => section.items.length > 0), diff --git a/apps/web/src/lib/components/sidebars/ProgrammeBoardMobileBar.svelte b/apps/web/src/lib/components/sidebars/ProgrammeBoardMobileBar.svelte index 330c59ed..992c25cd 100644 --- a/apps/web/src/lib/components/sidebars/ProgrammeBoardMobileBar.svelte +++ b/apps/web/src/lib/components/sidebars/ProgrammeBoardMobileBar.svelte @@ -17,6 +17,7 @@ (item) => (!item.domain || isDomainEnabled(item.domain)) && (!item.social || appConfig.socialEnabled) && + (!item.gamification || appConfig.gamificationEnabled) && !item.comingSoon, ), ); diff --git a/apps/web/src/lib/components/sidebars/ProjectorDockDesktop.svelte b/apps/web/src/lib/components/sidebars/ProjectorDockDesktop.svelte index d9f1e586..86d4e11e 100644 --- a/apps/web/src/lib/components/sidebars/ProjectorDockDesktop.svelte +++ b/apps/web/src/lib/components/sidebars/ProjectorDockDesktop.svelte @@ -39,6 +39,7 @@ (item) => (!item.domain || isDomainEnabled(item.domain)) && (!item.social || appConfig.socialEnabled) && + (!item.gamification || appConfig.gamificationEnabled) && !item.comingSoon, ), ), diff --git a/apps/web/src/lib/constants/admin-presentation.ts b/apps/web/src/lib/constants/admin-presentation.ts index 6045a0e2..13d2651d 100644 --- a/apps/web/src/lib/constants/admin-presentation.ts +++ b/apps/web/src/lib/constants/admin-presentation.ts @@ -43,6 +43,9 @@ const JOB_LABELS = { "reports.digest": () => m.admin_job_reports_digest(), "backup.run": () => m.admin_job_backup(), "users.inactiveAccountsScan": () => m.admin_job_inactive_accounts(), + "gamification.reconcile": () => m.admin_job_gamification_reconcile(), + "gamification.achievementsSweep": () => + m.admin_job_gamification_achievements_sweep(), }; export function adminJobLabel(key: string): string { @@ -65,6 +68,10 @@ export function adminJobSchedule(key: string): string | null { return m.admin_job_daily_at({ time: "03:00" }); case "users.inactiveAccountsScan": return m.admin_job_daily_at({ time: "05:00" }); + case "gamification.reconcile": + return m.admin_job_daily_at({ time: "04:00" }); + case "gamification.achievementsSweep": + return m.admin_job_daily_at({ time: "05:00" }); default: return null; } diff --git a/apps/web/src/lib/feature-badges.ts b/apps/web/src/lib/feature-badges.ts index 33f906a0..2e98d10c 100644 --- a/apps/web/src/lib/feature-badges.ts +++ b/apps/web/src/lib/feature-badges.ts @@ -21,6 +21,7 @@ const SHIPPED: Record = { "nav-styles": "2026-08-26", mfa: "2026-08-26", achievements: "2026-09-03", + leaderboard: "2026-09-05", }; export function isFeatureNew(key: keyof typeof SHIPPED): boolean { diff --git a/apps/web/src/lib/navigation.ts b/apps/web/src/lib/navigation.ts index 59166083..a70befba 100644 --- a/apps/web/src/lib/navigation.ts +++ b/apps/web/src/lib/navigation.ts @@ -15,6 +15,8 @@ interface NavItem { comingSoon?: boolean; /** Masqué tant que la dimension sociale (P4) n'est pas activée sur le déploiement. */ social?: boolean; + /** Masqué tant que GAMIFICATION_ENABLED n'est pas activé sur le déploiement. */ + gamification?: boolean; /** Key into feature-badges.ts — shows a "Nouveau" dot while its window is open. */ newBadgeKey?: string; } @@ -107,6 +109,15 @@ export const NAVIGATION: NavSection[] = [ icon: "stats", match: (p) => p.startsWith("/app/stats"), }, + { + href: "/app/leaderboard", + label: m.nav_leaderboard(), + icon: "crown", + social: true, + gamification: true, + match: (p) => p.startsWith("/app/leaderboard"), + newBadgeKey: "leaderboard", + }, { href: "/app/feed", label: m.nav_feed(), @@ -142,6 +153,7 @@ export type MobileNavId = | "boardgames" | "calendar" | "stats" + | "leaderboard" | "feed" | "profile" | "settings" @@ -161,6 +173,8 @@ export interface MobileDestination { adminOnly?: boolean; /** Hidden until the social features (P4) are enabled on the deployment. */ social?: boolean; + /** Hidden until GAMIFICATION_ENABLED is enabled on the deployment. */ + gamification?: boolean; /** Key into feature-badges.ts — shows a "Nouveau" dot while its window is open. */ newBadgeKey?: string; } @@ -254,6 +268,16 @@ const MOBILE_DESTINATIONS: Record = { icon: "stats", match: (p) => p.startsWith("/app/stats"), }, + leaderboard: { + id: "leaderboard", + href: "/app/leaderboard", + label: m.nav_leaderboard(), + icon: "crown", + social: true, + gamification: true, + match: (p) => p.startsWith("/app/leaderboard"), + newBadgeKey: "leaderboard", + }, feed: { id: "feed", href: "/app/feed", @@ -294,7 +318,7 @@ const MENU_GROUPS: { label: string; ids: MobileNavId[] }[] = [ }, { label: m.nav_section_tracking(), - ids: ["calendar", "stats", "feed"], + ids: ["calendar", "stats", "leaderboard", "feed"], }, { label: m.common_account(), @@ -332,12 +356,15 @@ interface MobileGateOptions { isAdmin: boolean; /** Whether social features are enabled on this deployment (default false). */ socialEnabled?: boolean; + /** Whether gamification is enabled on this deployment (default false). */ + gamificationEnabled?: boolean; } const isVisible = (d: MobileDestination, opts: MobileGateOptions): boolean => (!d.domain || opts.isDomainEnabled(d.domain)) && (!d.adminOnly || opts.isAdmin) && - (!d.social || !!opts.socialEnabled); + (!d.social || !!opts.socialEnabled) && + (!d.gamification || !!opts.gamificationEnabled); // Resolve the ordered bottom-bar ids into visible destinations, dropping any // gated out by the user's enabled domains / admin role. Coming-soon entries diff --git a/apps/web/src/lib/types/icon-name.ts b/apps/web/src/lib/types/icon-name.ts index d43e7cef..8147212f 100644 --- a/apps/web/src/lib/types/icon-name.ts +++ b/apps/web/src/lib/types/icon-name.ts @@ -68,4 +68,8 @@ export type IconName = | "footprint" | "shooting-star" | "circle-arrow" - | "pumpkin"; + | "arrow-right" + | "pumpkin" + // [G7] leaderboard — deliberately distinct from "trophy" (achievements) + // and "stats" (three ascending bars): a crown reads as "ranked" on sight. + | "crown"; diff --git a/apps/web/src/routes/(verification)/register/check-email/+page.svelte b/apps/web/src/routes/(verification)/register/check-email/+page.svelte index 41b5c369..0ce63c30 100644 --- a/apps/web/src/routes/(verification)/register/check-email/+page.svelte +++ b/apps/web/src/routes/(verification)/register/check-email/+page.svelte @@ -70,8 +70,8 @@
    - + class="bg-accent/10 text-accent mb-5 inline-flex items-center justify-center rounded-full p-2"> +

    diff --git a/apps/web/src/routes/app/+page.svelte b/apps/web/src/routes/app/+page.svelte index ffa6c947..7b672f63 100644 --- a/apps/web/src/routes/app/+page.svelte +++ b/apps/web/src/routes/app/+page.svelte @@ -247,7 +247,12 @@ {m.common_Media()} · {m.home_media_to_watch()}

    - {m.common_see_more()} → + + {m.common_see_more()} + +
    {#if toWatch.length > 0} @@ -315,7 +320,12 @@ {m.common_Games()} · {m.home_games_playing()} - {m.common_see()} → + + {m.common_see()} + +
    {#if playingGames.length > 0} e.id}> @@ -354,7 +364,12 @@ {m.common_Books()} · {m.home_books_reading()} - {m.common_see()} → + + {m.common_see()} + +
    {#if readingBooks.length > 0}
    {#if toListenAlbums.length > 0} e.id}> @@ -459,8 +479,12 @@ {m.home_this_week()} - {m.common_calendar()} → + + {m.common_calendar()} + + {#if week.length > 0}
      diff --git a/apps/web/src/routes/app/achievements/+page.svelte b/apps/web/src/routes/app/achievements/+page.svelte index bec63d72..7f49c735 100644 --- a/apps/web/src/routes/app/achievements/+page.svelte +++ b/apps/web/src/routes/app/achievements/+page.svelte @@ -10,6 +10,7 @@ import EmptyState from "$lib/components/EmptyState.svelte"; import PageHeader from "$lib/components/PageHeader.svelte"; import { appConfig } from "$lib/config.svelte"; + import { isFeatureNew } from "$lib/feature-badges"; import { formatNumber } from "$lib/format"; import { m } from "$lib/paraglide/messages.js"; import { @@ -63,7 +64,8 @@ icon="trophy" back="/app/profile" title={m.gamification_page_title()} - subtitle={m.gamification_page_subtitle()} /> + subtitle={m.gamification_page_subtitle()} + isNew={isFeatureNew("achievements")} /> {#if !appConfig.gamificationEnabled} {m.gamification_disabled()} diff --git a/apps/web/src/routes/app/admin/users/+page.svelte b/apps/web/src/routes/app/admin/users/+page.svelte index 51575126..8ba6e82a 100644 --- a/apps/web/src/routes/app/admin/users/+page.svelte +++ b/apps/web/src/routes/app/admin/users/+page.svelte @@ -457,8 +457,11 @@

      + class="btn-text text-accent group mt-0.5"> {m.admin_users_public_profile()} + diff --git a/apps/web/src/routes/app/leaderboard/+page.svelte b/apps/web/src/routes/app/leaderboard/+page.svelte new file mode 100644 index 00000000..3914149d --- /dev/null +++ b/apps/web/src/routes/app/leaderboard/+page.svelte @@ -0,0 +1,197 @@ + + +
      + + + {#if !enabled} + {m.gamification_leaderboard_disabled()} + {:else} +
      + {#each [{ value: "global" as const, label: m.gamification_leaderboard_tab_global() }, { value: "friends" as const, label: m.gamification_leaderboard_tab_friends() }] as tab (tab.value)} + + {/each} +
      + +
      + (period = v)} /> +
      + + {#if leaderboardQuery.error} + {leaderboardQuery.error} + {:else if leaderboardQuery.loading} +
      + {#each Array.from({ length: 7 }, (_, i) => i) as slot (slot)} +
      + {/each} +
      + {:else if entries.length === 0 && !viewerOutsideTop} + {m.gamification_leaderboard_empty()} + {:else} + + + {#if viewerOutsideTop} +
      + + {viewerOutsideTop.rank} + + + + + {m.gamification_leaderboard_you()} + + + {viewerOutsideTop.displayName} + + + + + {formatNumber(viewerOutsideTop.xp)} + + + {m.gamification_leaderboard_xp_unit()} + + +
      + {/if} + {/if} + {/if} +
      diff --git a/apps/web/src/routes/app/media/[type]/[id]/components/EpisodesSection.svelte b/apps/web/src/routes/app/media/[type]/[id]/components/EpisodesSection.svelte index 19c45579..9f425269 100644 --- a/apps/web/src/routes/app/media/[type]/[id]/components/EpisodesSection.svelte +++ b/apps/web/src/routes/app/media/[type]/[id]/components/EpisodesSection.svelte @@ -262,7 +262,7 @@ role="button" tabindex="0" aria-expanded={expanded} - class="bg-surface-2 font-display cursor-pointer rounded-[inherit] px-4 py-2.5 font-semibold [&::-webkit-details-marker]:hidden {expanded + class="font-display relative cursor-pointer rounded-[inherit] px-4 pt-2.5 pb-3 font-semibold [&::-webkit-details-marker]:hidden {expanded ? 'border-border rounded-b-none border-b' : ''}" onclick={() => toggleSeason(season.number)} @@ -370,9 +370,10 @@ )} + rounded={false} + class="absolute inset-x-0 bottom-0" /> {#if justFinished === season.number}