diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000000000000000000000000000000000000..585fdd0cb72afc640d1e9b9b5b45b2910346539d
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,10 @@
+node_modules
+.pnpm-store
+.turbo
+.next
+out
+dist
+.git
+.gitignore
+.env*
+Dockerfile
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000000000000000000000000000000000000..92ab8bfe840c173f17f759781354d26c9cd0f407
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,9 @@
+root = true
+
+[*]
+indent_style = tab
+indent_size = 4
+end_of_line = lf
+charset = utf-8
+trim_trailing_whitespace = false
+insert_final_newline = true
diff --git a/.env.local.example b/.env.local.example
new file mode 100644
index 0000000000000000000000000000000000000000..6f7c4312b64a47d73dd52226a25d74c837d1869a
--- /dev/null
+++ b/.env.local.example
@@ -0,0 +1,11 @@
+# Nothing is required for now.
+
+# Pending: enable HF dataset creation/writes when blockers are cleared.
+# HF_TOKEN=
+# WRAPPED_DATASET_ID=
+# WRAPPED_DATASET_WRITE=true
+
+# Optional local defaults (uncomment if you need them)
+# NEXT_PUBLIC_SITE_URL="http://localhost:3000"
+# NEXT_PUBLIC_WRAPPED_DEFAULT_HANDLE=""
+# NEXT_PUBLIC_WRAPPED_DEFAULT_SUBJECT_TYPE="auto"
\ No newline at end of file
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000000000000000000000000000000000000..01c9a9169e2db48d50dfce8b13efd3081ced1c55
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,6 @@
+*.png filter=lfs diff=lfs merge=lfs -text
+*.jpg filter=lfs diff=lfs merge=lfs -text
+*.jpeg filter=lfs diff=lfs merge=lfs -text
+*.gif filter=lfs diff=lfs merge=lfs -text
+*.svg filter=lfs diff=lfs merge=lfs -text
+*.ico filter=lfs diff=lfs merge=lfs -text
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000000000000000000000000000000000000..4babf984b299e5547fce16772eef66fb5a4119d8
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,15 @@
+version: 2
+updates:
+ - package-ecosystem: "npm"
+ directory: "/"
+ open-pull-requests-limit: 2
+ schedule:
+ interval: "daily"
+ ignore:
+ - dependency-name: "cropperjs"
+ versions: [">1.6.2"]
+ groups:
+ production-dependencies:
+ dependency-type: "production"
+ development-dependencies:
+ dependency-type: "development"
diff --git a/.github/workflows/validate-prs.yml b/.github/workflows/validate-prs.yml
new file mode 100644
index 0000000000000000000000000000000000000000..c97e5527ac1b09c500703387f535cea055581f0f
--- /dev/null
+++ b/.github/workflows/validate-prs.yml
@@ -0,0 +1,41 @@
+name: Validate PRs
+
+on:
+ pull_request:
+ branches: [main]
+
+env:
+ DATABASE_URL: ${{ secrets.DATABASE_URL }}
+
+jobs:
+ lint:
+ name: Lint code
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Setup Biome
+ uses: biomejs/setup-biome@v2
+ with:
+ version: latest
+ - name: Run Biome
+ run: biome ci .
+ e2e:
+ name: Run e2e tests
+ timeout-minutes: 60
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: lts/*
+ - uses: pnpm/action-setup@v4
+ - name: Install dependencies
+ run: pnpm install && pnpm --filter database generate
+ - name: Run Playwright tests
+ run: pnpm --filter web e2e:ci
+ - uses: actions/upload-artifact@v4
+ if: ${{ !cancelled() }}
+ with:
+ name: playwright-report
+ path: apps/web/playwright-report/
+ retention-days: 30
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..6c8da9394b310592504ecc05d7aa0cd94988a5d4
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,45 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+node_modules
+.pnp
+.pnp.js
+
+# testing
+coverage
+
+# next.js
+.next/
+out/
+build
+.swc/
+
+# misc
+.DS_Store
+*.pem
+
+# debug
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+# local env files
+.env
+.env.local
+.env.development.local
+.env.test.local
+.env.production.local
+
+# turbo
+.turbo
+
+# ui
+dist/
+
+# typescript
+*.tsbuildinfo
+
+# other
+.react-email/
+.content-collections/
+.prisma-zod-generator-manifest.json
\ No newline at end of file
diff --git a/.npmrc b/.npmrc
new file mode 100644
index 0000000000000000000000000000000000000000..f0c0c3dfe413097e66feca0013b227f5e26d10b1
--- /dev/null
+++ b/.npmrc
@@ -0,0 +1 @@
+public-hoist-pattern[]=*prisma*
diff --git a/.vscode/extensions.json b/.vscode/extensions.json
new file mode 100644
index 0000000000000000000000000000000000000000..2f18a360539c1845c7dd5962b69459dd6bed2795
--- /dev/null
+++ b/.vscode/extensions.json
@@ -0,0 +1,7 @@
+{
+ "recommendations": [
+ "lokalise.i18n-ally",
+ "bradlc.vscode-tailwindcss",
+ "biomejs.biome"
+ ]
+}
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000000000000000000000000000000000000..1f6031ededb254f29b46b0ddf0d53d1c8141ff10
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,31 @@
+{
+ "editor.defaultFormatter": "biomejs.biome",
+ "editor.formatOnSave": true,
+ "editor.formatOnPaste": true,
+ "tailwindCSS.experimental.classRegex": [
+ ["cn\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"]
+ ],
+ "editor.codeActionsOnSave": {
+ "source.fixAll.biome": "explicit",
+ "source.organizeImports.biome": "explicit"
+ },
+ "typescript.preferences.importModuleSpecifier": "non-relative",
+ "typescript.tsdk": "node_modules/typescript/lib",
+ "i18n-ally.localesPaths": ["packages/i18n/translations"],
+ "i18n-ally.keystyle": "nested",
+ "i18n-ally.enabledFrameworks": ["next-intl"],
+ "i18n-ally.keysInUse": ["mail.organizationInvitation.headline"],
+ "i18n-ally.tabStyle": "tab",
+ "[typescript]": {
+ "editor.defaultFormatter": "biomejs.biome"
+ },
+ "[typescriptreact]": {
+ "editor.defaultFormatter": "biomejs.biome"
+ },
+ "[json]": {
+ "editor.defaultFormatter": "biomejs.biome"
+ },
+ "[prisma]": {
+ "editor.defaultFormatter": "Prisma.prisma"
+ }
+}
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000000000000000000000000000000000000..559269f7aeb18f7ea8199f26628f3a7c9d679c31
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,38 @@
+FROM node:20-alpine
+
+ENV NODE_ENV=production
+WORKDIR /app
+
+# Install pnpm
+RUN npm install -g pnpm@10.14.0
+
+# Copy lockfiles and workspace manifests for better install caching
+COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json tsconfig.json .npmrc* ./
+COPY apps/web/package.json apps/web/package.json
+COPY packages/wrapped/package.json packages/wrapped/package.json
+COPY packages/utils/package.json packages/utils/package.json
+COPY packages/i18n/package.json packages/i18n/package.json
+COPY config/package.json config/package.json
+COPY tooling/typescript/package.json tooling/typescript/package.json
+COPY tooling/tailwind/package.json tooling/tailwind/package.json
+COPY tooling/scripts/package.json tooling/scripts/package.json
+COPY apps/web/tsconfig.json apps/web/tsconfig.json
+COPY packages/wrapped/tsconfig.json packages/wrapped/tsconfig.json
+COPY packages/utils/tsconfig.json packages/utils/tsconfig.json
+COPY packages/i18n/tsconfig.json packages/i18n/tsconfig.json
+COPY config/tsconfig.json config/tsconfig.json
+
+# Install dependencies
+RUN pnpm install --frozen-lockfile
+
+# Copy the rest of the monorepo
+COPY . .
+
+# Build only the web app
+RUN pnpm turbo run build --filter @repo/web
+
+# Hugging Face Spaces expects the app on port 7860
+EXPOSE 7860
+
+# Run Next on the expected host/port from the web app directory.
+CMD ["sh", "-c", "cd apps/web && PORT=${PORT:-7860} HOSTNAME=0.0.0.0 pnpm start --hostname 0.0.0.0 --port ${PORT:-7860}"]
diff --git a/README.md b/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..060836880c73e80b114a7fd716bbb9abfa3abea0
--- /dev/null
+++ b/README.md
@@ -0,0 +1,13 @@
+---
+title: hf-wrapped
+emoji: 🤗
+colorFrom: yellow
+colorTo: blue
+sdk: docker
+pinned: false
+app_port: 7860
+---
+
+# hf-wrapped
+
+Hugging Face Wrapped 2025 — Docker Space deployment.
\ No newline at end of file
diff --git a/apps/web/.gitignore b/apps/web/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..ebaca495994d0a3ec9e5243df359c4683401a6b4
--- /dev/null
+++ b/apps/web/.gitignore
@@ -0,0 +1,39 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+/.pnp
+.pnp.js
+
+# testing
+/coverage
+/playwright-report
+/test-results
+
+# next.js
+/.next/
+/out/
+
+# production
+/build
+
+# misc
+.DS_Store
+*.pem
+
+# debug
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+# local env files
+.env*.local
+
+# vercel
+.vercel
+
+# typescript
+*.tsbuildinfo
+next-env.d.ts
+
+.content-collections
diff --git a/apps/web/app/(marketing)/[locale]/(home)/page.tsx b/apps/web/app/(marketing)/[locale]/(home)/page.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..319baf1546ba266ca931875047c9cb6d768d7700
--- /dev/null
+++ b/apps/web/app/(marketing)/[locale]/(home)/page.tsx
@@ -0,0 +1,25 @@
+import { Hero } from "@marketing/home/components/Hero";
+import { setRequestLocale } from "next-intl/server";
+
+export default async function Home({
+ params,
+}: {
+ params: Promise<{ locale: string }>;
+}) {
+ const { locale } = await params;
+ setRequestLocale(locale);
+
+ return (
+
+
+
+
+ );
+}
diff --git a/apps/web/app/(marketing)/[locale]/[...rest]/page.tsx b/apps/web/app/(marketing)/[locale]/[...rest]/page.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..4583936cab7b17211e691fee9949e11ce7401d62
--- /dev/null
+++ b/apps/web/app/(marketing)/[locale]/[...rest]/page.tsx
@@ -0,0 +1,5 @@
+import { notFound } from "next/navigation";
+
+export default function CatchAll() {
+ notFound();
+}
diff --git a/apps/web/app/(marketing)/[locale]/layout.tsx b/apps/web/app/(marketing)/[locale]/layout.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..eeb6d06b46f682fd0f56f8bac09ec13c625343f9
--- /dev/null
+++ b/apps/web/app/(marketing)/[locale]/layout.tsx
@@ -0,0 +1,39 @@
+import { Footer } from "@marketing/shared/components/Footer";
+import { NavBar } from "@marketing/shared/components/NavBar";
+import { config } from "@repo/config";
+import { Document } from "@shared/components/Document";
+import { notFound } from "next/navigation";
+import { NextIntlClientProvider } from "next-intl";
+import { getMessages, setRequestLocale } from "next-intl/server";
+import type { PropsWithChildren } from "react";
+
+const locales = Object.keys(config.i18n.locales);
+
+export function generateStaticParams() {
+ return locales.map((locale) => ({ locale }));
+}
+
+export default async function MarketingLayout({
+ children,
+ params,
+}: PropsWithChildren<{ params: Promise<{ locale: string }> }>) {
+ const { locale } = await params;
+
+ setRequestLocale(locale);
+
+ if (!locales.includes(locale as any)) {
+ notFound();
+ }
+
+ const messages = await getMessages();
+
+ return (
+
+
+
+ {children}
+
+
+
+ );
+}
diff --git a/apps/web/app/(marketing)/[locale]/not-found.tsx b/apps/web/app/(marketing)/[locale]/not-found.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..3fb3d88169237dd0bd1ef98b18291c958cd4fe72
--- /dev/null
+++ b/apps/web/app/(marketing)/[locale]/not-found.tsx
@@ -0,0 +1,5 @@
+import { NotFound } from "@marketing/shared/components/NotFound";
+
+export default async function NotFoundPage() {
+ return ;
+}
diff --git a/apps/web/app/api/wrapped/route.ts b/apps/web/app/api/wrapped/route.ts
new file mode 100644
index 0000000000000000000000000000000000000000..c74ffa5e0d557c4c89df134e74d851fb8e2ef5d8
--- /dev/null
+++ b/apps/web/app/api/wrapped/route.ts
@@ -0,0 +1,76 @@
+import { generateWrapped } from "@repo/wrapped";
+import { NextResponse } from "next/server";
+import { z } from "zod";
+
+const requestSchema = z.object({
+ handle: z.string().trim().min(2).max(80),
+ subjectType: z.enum(["user", "organization", "auto"]).optional(),
+ year: z.number().int().min(2000).max(2100).optional(),
+ allowRefresh: z.boolean().optional(),
+});
+
+const rateLimitEnabled = process.env.WRAPPED_RATE_LIMIT_ENABLED === "true";
+const windowMs =
+ Number.parseInt(process.env.WRAPPED_RATE_LIMIT_WINDOW_MS ?? "60000", 10) ||
+ 60000;
+const maxRequests =
+ Number.parseInt(process.env.WRAPPED_RATE_LIMIT_MAX ?? "30", 10) || 30;
+const hits = new Map();
+
+function track(ip: string) {
+ const now = Date.now();
+ const entries =
+ hits.get(ip)?.filter((timestamp) => now - timestamp < windowMs) ?? [];
+ entries.push(now);
+ hits.set(ip, entries);
+ return entries.length;
+}
+
+export async function POST(req: Request) {
+ const ip =
+ req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
+ req.headers.get("x-real-ip") ??
+ "unknown";
+
+ if (rateLimitEnabled && track(ip) > maxRequests) {
+ return NextResponse.json(
+ { error: "Rate limit exceeded" },
+ { status: 429 },
+ );
+ }
+
+ let body: unknown;
+ try {
+ body = await req.json();
+ } catch {
+ return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
+ }
+
+ const parsed = requestSchema.safeParse(body);
+ if (!parsed.success) {
+ return NextResponse.json(
+ { error: "Invalid input", details: parsed.error.format() },
+ { status: 400 },
+ );
+ }
+
+ const payload = parsed.data;
+ const year = payload.year ?? new Date().getUTCFullYear();
+
+ try {
+ const result = await generateWrapped({
+ ...payload,
+ year,
+ });
+ return NextResponse.json(result, { status: 200 });
+ } catch (error) {
+ console.error("Wrapped generation failed:", error);
+ const message =
+ (error as Error).message ?? "Failed to generate wrapped";
+ // If handle is not found, return 404 for clarity
+ if (message.toLowerCase().includes("handle not found")) {
+ return NextResponse.json({ error: message }, { status: 404 });
+ }
+ return NextResponse.json({ error: message }, { status: 500 });
+ }
+}
diff --git a/apps/web/app/favicon.ico b/apps/web/app/favicon.ico
new file mode 100644
index 0000000000000000000000000000000000000000..af9f84acc680232eee53a107e5b3b705531b4dc4
--- /dev/null
+++ b/apps/web/app/favicon.ico
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:2b32eefed23493b634091fff5e30454b0939a78b412190e4cb86fa7f3f86aa8b
+size 6434
diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css
new file mode 100644
index 0000000000000000000000000000000000000000..23568f3e08c1eafde0ca3de54f226eefa63f6c7a
--- /dev/null
+++ b/apps/web/app/globals.css
@@ -0,0 +1,67 @@
+@import "tailwindcss";
+@import "fumadocs-ui/css/neutral.css";
+@import "fumadocs-ui/css/preset.css";
+@import "@repo/tailwind-config/theme.css";
+@import "@repo/tailwind-config/tailwind-animate.css";
+
+@source "../node_modules/fumadocs-ui/dist/**/*.js";
+
+@variant dark (&:where(.dark, .dark *));
+
+/* Basement scrollytelling demo fonts */
+@font-face {
+ font-family: "BasementGrotesque";
+ src:
+ url("/scrolly-assets/fonts/BasementGrotesque-Regular.woff2")
+ format("woff2"),
+ url("/scrolly-assets/fonts/BasementGrotesque-Regular.woff2")
+ format("woff2");
+ font-style: normal;
+ font-weight: 400;
+ font-display: swap;
+}
+
+@font-face {
+ font-family: "BasementGrotesque";
+ src:
+ url("/scrolly-assets/fonts/BasementGrotesque-Black.woff2")
+ format("woff2"),
+ url("/scrolly-assets/fonts/BasementGrotesque-BlackExpanded.woff2")
+ format("woff2");
+ font-style: normal;
+ font-weight: 900;
+ font-display: swap;
+}
+
+pre.shiki {
+ @apply mb-4 rounded-lg p-6;
+}
+
+#nd-sidebar {
+ @apply bg-card! top-[4.5rem] md:h-[calc(100dvh-4.5rem)]!;
+
+ button[data-search-full] {
+ @apply bg-transparent;
+ }
+}
+
+#nd-page .prose {
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ a {
+ @apply no-underline!;
+ }
+ }
+}
+
+div[role="tablist"].bg-secondary {
+ @apply bg-muted!;
+}
+
+input[cmdk-input] {
+ @apply border-none focus-visible:ring-0;
+}
diff --git a/apps/web/app/icon.png b/apps/web/app/icon.png
new file mode 100644
index 0000000000000000000000000000000000000000..af9f84acc680232eee53a107e5b3b705531b4dc4
--- /dev/null
+++ b/apps/web/app/icon.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:2b32eefed23493b634091fff5e30454b0939a78b412190e4cb86fa7f3f86aa8b
+size 6434
diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..c257c6e59d8b42b7d71fd9d8f48528f585ed0d65
--- /dev/null
+++ b/apps/web/app/layout.tsx
@@ -0,0 +1,16 @@
+import type { Metadata } from "next";
+import type { PropsWithChildren } from "react";
+import "./globals.css";
+import { config } from "@repo/config";
+
+export const metadata: Metadata = {
+ title: {
+ absolute: config.appName,
+ default: config.appName,
+ template: `%s | ${config.appName}`,
+ },
+};
+
+export default function RootLayout({ children }: PropsWithChildren) {
+ return children;
+}
diff --git a/apps/web/app/robots.ts b/apps/web/app/robots.ts
new file mode 100644
index 0000000000000000000000000000000000000000..8f9225aec85dc62e27ccadba2fd6699b49b1de72
--- /dev/null
+++ b/apps/web/app/robots.ts
@@ -0,0 +1,10 @@
+import type { MetadataRoute } from "next";
+
+export default function robots(): MetadataRoute.Robots {
+ return {
+ rules: {
+ userAgent: "*",
+ allow: "/",
+ },
+ };
+}
diff --git a/apps/web/app/sitemap.ts b/apps/web/app/sitemap.ts
new file mode 100644
index 0000000000000000000000000000000000000000..cfcbbeabfbcd5d682fdf2640ef296bca56d17520
--- /dev/null
+++ b/apps/web/app/sitemap.ts
@@ -0,0 +1,15 @@
+import { config } from "@repo/config";
+import { getBaseUrl } from "@repo/utils";
+import type { MetadataRoute } from "next";
+
+const baseUrl = getBaseUrl();
+const locales = config.i18n.enabled
+ ? Object.keys(config.i18n.locales)
+ : [config.i18n.defaultLocale];
+
+export default function sitemap(): MetadataRoute.Sitemap {
+ return locales.map((locale) => ({
+ url: new URL(`/${locale}`, baseUrl).href,
+ lastModified: new Date(),
+ }));
+}
diff --git a/apps/web/biome.json b/apps/web/biome.json
new file mode 100644
index 0000000000000000000000000000000000000000..b7f695badf2e273b52e96e618fba9dc6d644c720
--- /dev/null
+++ b/apps/web/biome.json
@@ -0,0 +1,20 @@
+{
+ "root": false,
+ "extends": "//",
+ "linter": {
+ "rules": {
+ "style": {
+ "noParameterAssign": "error",
+ "useAsConstAssertion": "error",
+ "useDefaultParameterLast": "error",
+ "useEnumInitializers": "error",
+ "useSelfClosingElements": "error",
+ "useSingleVarDeclarator": "error",
+ "noUnusedTemplateLiteral": "error",
+ "useNumberNamespace": "error",
+ "noInferrableTypes": "error",
+ "noUselessElse": "error"
+ }
+ }
+ }
+}
diff --git a/apps/web/components.json b/apps/web/components.json
new file mode 100644
index 0000000000000000000000000000000000000000..91256c3317bc36f193df415d0ce80d1ac512bd51
--- /dev/null
+++ b/apps/web/components.json
@@ -0,0 +1,17 @@
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "default",
+ "rsc": true,
+ "tsx": true,
+ "tailwind": {
+ "css": "styles/globals.css",
+ "baseColor": "slate",
+ "cssVariables": true,
+ "prefix": ""
+ },
+ "aliases": {
+ "components": "@ui/components",
+ "utils": "@ui/lib",
+ "ui": "@ui/components"
+ }
+}
diff --git a/apps/web/global.d.ts b/apps/web/global.d.ts
new file mode 100644
index 0000000000000000000000000000000000000000..290c13bcffeb4bb8cabb9135097fa6186c6687ad
--- /dev/null
+++ b/apps/web/global.d.ts
@@ -0,0 +1,16 @@
+import type { Messages } from "@repo/i18n";
+import type { JSX as Jsx } from "react/jsx-runtime";
+
+// temporary fix for mdx types
+// TODO: remove once mdx has fully compatibility with react 19
+declare global {
+ namespace JSX {
+ type ElementClass = Jsx.ElementClass;
+ type Element = Jsx.Element;
+ type IntrinsicElements = Jsx.IntrinsicElements;
+ }
+}
+
+declare global {
+ interface IntlMessages extends Messages {}
+}
diff --git a/apps/web/modules/i18n/lib/locale-cookie.ts b/apps/web/modules/i18n/lib/locale-cookie.ts
new file mode 100644
index 0000000000000000000000000000000000000000..b2d046bdbb487d9601b51ecfaf95714f99bb5e35
--- /dev/null
+++ b/apps/web/modules/i18n/lib/locale-cookie.ts
@@ -0,0 +1,14 @@
+import "server-only";
+
+import { config } from "@repo/config";
+import type { Locale } from "@repo/i18n";
+import { cookies } from "next/headers";
+
+export async function getUserLocale() {
+ const cookie = (await cookies()).get(config.i18n.localeCookieName);
+ return cookie?.value ?? config.i18n.defaultLocale;
+}
+
+export async function setLocaleCookie(locale: Locale) {
+ (await cookies()).set(config.i18n.localeCookieName, locale);
+}
diff --git a/apps/web/modules/i18n/lib/update-locale.ts b/apps/web/modules/i18n/lib/update-locale.ts
new file mode 100644
index 0000000000000000000000000000000000000000..5b0bfe3db12c3313e23eabb65ee24566be8bef09
--- /dev/null
+++ b/apps/web/modules/i18n/lib/update-locale.ts
@@ -0,0 +1,10 @@
+"use server";
+
+import { setLocaleCookie } from "@i18n/lib/locale-cookie";
+import type { Locale } from "@repo/i18n";
+import { revalidatePath } from "next/cache";
+
+export async function updateLocale(locale: Locale) {
+ await setLocaleCookie(locale);
+ revalidatePath("/");
+}
diff --git a/apps/web/modules/i18n/request.ts b/apps/web/modules/i18n/request.ts
new file mode 100644
index 0000000000000000000000000000000000000000..ac569cc4e05995dee8ec70b05d0e0fb7efa8de14
--- /dev/null
+++ b/apps/web/modules/i18n/request.ts
@@ -0,0 +1,22 @@
+import { getUserLocale } from "@i18n/lib/locale-cookie";
+import { routing } from "@i18n/routing";
+import { config } from "@repo/config";
+import { getMessagesForLocale } from "@repo/i18n";
+import { getRequestConfig } from "next-intl/server";
+
+export default getRequestConfig(async ({ requestLocale }) => {
+ let locale = await requestLocale;
+
+ if (!locale) {
+ locale = await getUserLocale();
+ }
+
+ if (!(routing.locales.includes(locale) && config.i18n.enabled)) {
+ locale = routing.defaultLocale;
+ }
+
+ return {
+ locale,
+ messages: await getMessagesForLocale(locale),
+ };
+});
diff --git a/apps/web/modules/i18n/routing.ts b/apps/web/modules/i18n/routing.ts
new file mode 100644
index 0000000000000000000000000000000000000000..ee40245b67553f08ecbad0f457cd397e2886e02f
--- /dev/null
+++ b/apps/web/modules/i18n/routing.ts
@@ -0,0 +1,20 @@
+import { config } from "@repo/config";
+import { createNavigation } from "next-intl/navigation";
+import { defineRouting } from "next-intl/routing";
+
+export const routing = defineRouting({
+ locales: Object.keys(config.i18n.locales),
+ defaultLocale: config.i18n.defaultLocale,
+ localeCookie: {
+ name: config.i18n.localeCookieName,
+ },
+ localePrefix: config.i18n.enabled ? "always" : "never",
+ localeDetection: config.i18n.enabled,
+});
+
+export const {
+ Link: LocaleLink,
+ redirect: localeRedirect,
+ usePathname: useLocalePathname,
+ useRouter: useLocaleRouter,
+} = createNavigation(routing);
diff --git a/apps/web/modules/marketing/home/components/Hero.tsx b/apps/web/modules/marketing/home/components/Hero.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..6fcbf2c58d61580e2163a317f72a6bc7bd7d2314
--- /dev/null
+++ b/apps/web/modules/marketing/home/components/Hero.tsx
@@ -0,0 +1,267 @@
+"use client";
+
+import { useGSAP } from "@gsap/react";
+import type { WrappedResult } from "@repo/wrapped";
+import { Button } from "@ui/components/button";
+import { Input } from "@ui/components/input";
+import gsap from "gsap";
+import { ArrowRightIcon, GithubIcon, Loader2Icon } from "lucide-react";
+import Image from "next/image";
+import { useEffect, useRef, useState, useTransition } from "react";
+import { toast } from "sonner";
+import { StoryScroller } from "./StoryScroller";
+
+const defaultHandle = process.env.NEXT_PUBLIC_WRAPPED_DEFAULT_HANDLE ?? "";
+const defaultSubjectType =
+ process.env.NEXT_PUBLIC_WRAPPED_DEFAULT_SUBJECT_TYPE ?? "auto";
+
+const demoWrapped: WrappedResult = {
+ profile: {
+ handle: "hf-demo",
+ displayName: "HF Demo",
+ subjectType: "user",
+ },
+ year: 2025,
+ activity: {
+ models: [],
+ datasets: [],
+ spaces: [],
+ papers: [],
+ totalDownloads: 120_000,
+ totalLikes: 800,
+ totalRepos: 6,
+ topTags: ["text-generation", "vision", "audio"],
+ busiestMonth: "June",
+ },
+ archetype: "Model Maestro",
+ badges: ["Top 1M+ downloads", "Community favorite", "Peak month: June"],
+ slides: [
+ {
+ id: "intro",
+ kind: "intro",
+ title: "Your 2025 Hugging Face Wrapped",
+ subtitle: "hf-demo",
+ metrics: [
+ { label: "Repositories", value: "6", accent: "primary" },
+ { label: "Downloads", value: "120k" },
+ ],
+ highlights: ["text-generation", "vision", "audio"],
+ },
+ {
+ id: "models",
+ kind: "models",
+ title: "Top models",
+ subtitle: "Most downloaded",
+ metrics: [
+ { label: "hf-demo/sdxl", value: "55k downloads" },
+ { label: "hf-demo/tts", value: "38k downloads" },
+ ],
+ },
+ {
+ id: "archetype",
+ kind: "archetype",
+ title: "Archetype",
+ subtitle: "Model Maestro",
+ metrics: [
+ { label: "Likes", value: "800" },
+ { label: "Busiest month", value: "June" },
+ ],
+ highlights: ["Community favorite", "Peak month: June"],
+ },
+ ],
+ cached: false,
+ generatedAt: new Date().toISOString(),
+ source: "live",
+};
+
+export function Hero() {
+ const [handle, setHandle] = useState(defaultHandle);
+ const [wrapped, setWrapped] = useState(demoWrapped);
+ const [isPending, startTransition] = useTransition();
+ const [hasSubmitted, setHasSubmitted] = useState(false);
+ const heroRef = useRef(null);
+ const panelRef = useRef(null);
+
+ useGSAP(() => {
+ if (heroRef.current) {
+ gsap.to(heroRef.current, {
+ backgroundPosition: "110% 90%",
+ scale: 1.005,
+ duration: 14,
+ repeat: -1,
+ yoyo: true,
+ ease: "sine.inOut",
+ });
+ }
+ if (panelRef.current) {
+ gsap.from(panelRef.current, {
+ opacity: 0,
+ y: 30,
+ scale: 0.98,
+ duration: 0.6,
+ ease: "power3.out",
+ });
+ }
+ });
+
+ async function onSubmit(event: React.FormEvent) {
+ event.preventDefault();
+ if (!handle.trim()) {
+ toast.error("Enter a Hugging Face handle to generate the story.");
+ return;
+ }
+
+ startTransition(async () => {
+ try {
+ const response = await fetch("/api/wrapped", {
+ method: "POST",
+ body: JSON.stringify({
+ handle: handle.trim(),
+ year: 2025,
+ subjectType: "auto",
+ allowRefresh: true,
+ }),
+ });
+
+ const payload = (await response.json()) as
+ | { error?: string }
+ | WrappedResult;
+
+ if (!response.ok || "error" in payload) {
+ throw new Error(
+ (payload as { error?: string }).error ??
+ "Failed to generate wrapped",
+ );
+ }
+
+ setWrapped(payload as WrappedResult);
+ setHasSubmitted(true);
+ } catch (error) {
+ toast.error((error as Error).message);
+ }
+ });
+ }
+
+ useEffect(() => {
+ if (!defaultHandle) {
+ return;
+ }
+ startTransition(async () => {
+ try {
+ const response = await fetch("/api/wrapped", {
+ method: "POST",
+ body: JSON.stringify({
+ handle: defaultHandle,
+ year: 2025,
+ subjectType: defaultSubjectType,
+ allowRefresh: false,
+ }),
+ });
+ const payload = (await response.json()) as
+ | { error?: string }
+ | WrappedResult;
+ if (!response.ok || "error" in payload) {
+ return;
+ }
+ setWrapped(payload as WrappedResult);
+ } catch {
+ // ignore prefetch errors
+ }
+ });
+ }, []);
+
+ return (
+
+
+ {!hasSubmitted ? (
+
+ ) : (
+
+
+
+ )}
+
+
+ );
+}
diff --git a/apps/web/modules/marketing/home/components/StoryScroller.tsx b/apps/web/modules/marketing/home/components/StoryScroller.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..0658db322b530c1511c5f9c2e079e1569b4465c0
--- /dev/null
+++ b/apps/web/modules/marketing/home/components/StoryScroller.tsx
@@ -0,0 +1,1894 @@
+"use client";
+
+import * as Scrollytelling from "@bsmnt/scrollytelling";
+import { useGSAP } from "@gsap/react";
+import type { StorySlide, WrappedResult } from "@repo/wrapped";
+import { Button } from "@ui/components/button";
+import { cn } from "@ui/lib";
+import gsap from "gsap";
+import { ScrollTrigger } from "gsap/all";
+import { DownloadIcon, ShareIcon } from "lucide-react";
+import Image from "next/image";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+
+const BASE_SIZE = 1080;
+const BASE_PADDING = 16;
+
+function getTimeline({
+ start,
+ end,
+ overlap = 0.3,
+ chunks,
+}: {
+ start: number;
+ end: number;
+ overlap?: number;
+ chunks: number;
+}) {
+ const duration = end - start;
+ const chunk = duration / chunks;
+ const raw = Array.from({ length: chunks }).map((_, i) => ({
+ start: start + i * chunk,
+ end: start + (i + 1) * chunk,
+ }));
+ if (overlap <= 0) {
+ return raw;
+ }
+
+ const overlapDuration = duration * overlap;
+ const per = overlapDuration / raw.length;
+ const adjusted = raw.map((slot, i) => ({
+ start: slot.start - per * i,
+ end: slot.end - per * i,
+ }));
+ const first = adjusted[0]?.start ?? start;
+ const last = adjusted[adjusted.length - 1]?.end ?? end;
+ const scale = duration / (last - first || duration);
+
+ return adjusted.map((slot) => ({
+ start: Math.max(start, start + (slot.start - first) * scale),
+ end: Math.min(end, start + (slot.end - first) * scale),
+ }));
+}
+
+type MediaPalette = {
+ gradient: string;
+ accent: string;
+};
+
+const archetypeHighlights: Partial> = {
+ "Model Maestro": ["Innovative", "Precise", "Impactful"],
+ "Dataset Architect": ["Structured", "Reliable", "Curious"],
+ "Space Storyteller": ["Interactive", "Engaging", "Creative"],
+ "Research Curator": ["Analytical", "Insightful", "Rigorous"],
+ "HF Explorer": ["Versatile", "Adaptive", "Curious"],
+};
+
+const palettes: Record = {
+ intro: {
+ gradient:
+ "radial-gradient(circle at 18% 24%, rgba(59,130,246,0.36), transparent 32%), radial-gradient(circle at 78% 18%, rgba(236,72,153,0.32), transparent 34%), linear-gradient(135deg, #0b1224, #0b0f1a)",
+ accent: "from-sky-400 to-fuchsia-400",
+ },
+ summary: {
+ gradient:
+ "radial-gradient(circle at 15% 20%, rgba(34,197,94,0.34), transparent 32%), radial-gradient(circle at 80% 10%, rgba(59,130,246,0.30), transparent 36%), linear-gradient(145deg, #0a1818, #0d1220)",
+ accent: "from-emerald-400 to-sky-400",
+ },
+ models: {
+ gradient:
+ "radial-gradient(circle at 25% 25%, rgba(99,102,241,0.32), transparent 32%), radial-gradient(circle at 80% 0%, rgba(236,72,153,0.30), transparent 34%), linear-gradient(135deg, #0b0f1f, #0d1024)",
+ accent: "from-indigo-400 to-pink-400",
+ },
+ datasets: {
+ gradient:
+ "radial-gradient(circle at 15% 35%, rgba(34,211,238,0.30), transparent 32%), radial-gradient(circle at 85% 20%, rgba(59,130,246,0.24), transparent 34%), linear-gradient(135deg, #081926, #0b1624)",
+ accent: "from-cyan-400 to-sky-400",
+ },
+ spaces: {
+ gradient:
+ "radial-gradient(circle at 20% 25%, rgba(248,113,113,0.28), transparent 32%), radial-gradient(circle at 70% 10%, rgba(251,191,36,0.26), transparent 34%), linear-gradient(135deg, #1a0f0f, #1d0f16)",
+ accent: "from-amber-400 to-rose-400",
+ },
+ papers: {
+ gradient:
+ "radial-gradient(circle at 18% 35%, rgba(94,234,212,0.28), transparent 34%), radial-gradient(circle at 70% 15%, rgba(168,85,247,0.28), transparent 36%), linear-gradient(135deg, #0d1b1f, #0f1826)",
+ accent: "from-teal-400 to-purple-400",
+ },
+ badges: {
+ gradient:
+ "radial-gradient(circle at 22% 28%, rgba(250,204,21,0.32), transparent 32%), radial-gradient(circle at 78% 14%, rgba(59,130,246,0.30), transparent 36%), linear-gradient(135deg, #14110f, #0f1621)",
+ accent: "from-yellow-300 to-sky-400",
+ },
+ archetype: {
+ gradient:
+ "radial-gradient(circle at 20% 30%, rgba(236,72,153,0.30), transparent 34%), radial-gradient(circle at 80% 12%, rgba(99,102,241,0.28), transparent 36%), linear-gradient(135deg, #0f0f1a, #0d1224)",
+ accent: "from-pink-400 to-indigo-400",
+ },
+ cta: {
+ gradient:
+ "radial-gradient(circle at 12% 24%, rgba(34,197,94,0.30), transparent 32%), radial-gradient(circle at 76% 12%, rgba(59,130,246,0.30), transparent 36%), linear-gradient(135deg, #0f1512, #0e1620)",
+ accent: "from-emerald-400 to-sky-400",
+ },
+ share: {
+ gradient:
+ "radial-gradient(circle at 20% 25%, rgba(96,165,250,0.30), transparent 30%), radial-gradient(circle at 78% 18%, rgba(52,211,153,0.32), transparent 36%), linear-gradient(135deg, #0c101a, #0a0f1c)",
+ accent: "from-sky-400 to-emerald-300",
+ },
+};
+
+function sanitizeHighlights(tags: string[]): string[] {
+ const banned = ["gradio", "en", "region:us", "region:eu", "demo"];
+ const filtered = tags.filter((tag) => !banned.includes(tag.toLowerCase()));
+ return filtered.length > 0 ? filtered : ["Keep exploring", "Stay curious"];
+}
+
+type ArchetypeKey =
+ | "Model Maestro"
+ | "Dataset Architect"
+ | "Space Storyteller"
+ | "Research Curator"
+ | "HF Explorer";
+
+const archetypeImage: Record = {
+ "Model Maestro": "/images/huggies/NEW_modelmaestro.png",
+ "Dataset Architect": "/images/huggies/NEW_datasetarchitect.png",
+ "Space Storyteller": "/images/huggies/NEW_spacestoryteller.png",
+ "Research Curator": "/images/huggies/NEW_researchcurator.png",
+ "HF Explorer": "/images/huggies/Huggy Hi.png",
+};
+
+const gifMap: Partial> = {
+ intro: ["/images/huggies/Huggy Pop.gif"],
+ summary: ["/images/huggies/Vibing Huggy.gif"],
+ models: ["/images/huggies/Doodle Huggy.gif"],
+ spaces: [],
+ datasets: [],
+ papers: [],
+ cta: ["/images/huggies/Huggy Pop.gif"],
+ share: [],
+};
+
+const badgeImage: Record = {
+ "Model Powerhouse": "/images/huggies/Optimum Huggy.png",
+ "Community Favorite": "/images/huggies/NEW_communityfavorite.png",
+ "Research Beacon": "/images/huggies/NEW_researchbeacon.png",
+ "Spaces Trailblazer": "/images/huggies/Rocket Huggy.png",
+ "Data Shaper": "/images/huggies/Manager Huggy.png",
+ "Model Builder": "/images/huggies/Transformer20Huggy.png",
+ "HF Explorer": "/images/huggies/Huggy Hi.png",
+};
+
+const archetypeAccents: Record = {
+ "Model Maestro": "from-indigo-400 to-sky-400",
+ "Dataset Architect": "from-[#e85048] to-[#ff9a7d]",
+ "Space Storyteller": "from-sky-300 to-cyan-400",
+ "Research Curator": "from-purple-500 to-violet-400",
+ "HF Explorer": "from-emerald-400 to-lime-300",
+};
+
+function imageForSlide(
+ slide: StorySlide,
+ wrapped: WrappedResult,
+ badge: string,
+): { src?: string; isGif: boolean } {
+ if (slide.kind !== "badges" && slide.kind !== "archetype") {
+ const gifPool = gifMap[slide.kind];
+ if (gifPool && gifPool.length > 0) {
+ const gif = gifPool[0];
+ return { src: gif, isGif: true };
+ }
+ }
+ switch (slide.kind) {
+ case "intro":
+ return { src: "/images/huggies/Huggy Hi.png", isGif: false };
+ case "summary":
+ return { src: "/images/huggies/X-ray Huggy.png", isGif: false };
+ case "models":
+ return {
+ src: "/images/huggies/Transformer20Huggy.png",
+ isGif: false,
+ };
+ case "datasets":
+ return { src: "/images/huggies/Growing20Huggy.png", isGif: false };
+ case "spaces":
+ return { src: "/images/huggies/Rocket Huggy.png", isGif: false };
+ case "papers":
+ return { src: "/images/huggies/Paper Huggy.png", isGif: false };
+ case "archetype": {
+ const key =
+ (wrapped.archetype as ArchetypeKey) in archetypeImage
+ ? (wrapped.archetype as ArchetypeKey)
+ : ("HF Explorer" as ArchetypeKey);
+ return { src: archetypeImage[key], isGif: false };
+ }
+ case "badges":
+ return {
+ src: badgeImage[badge] ?? "/images/huggies/Huggy Hi.png",
+ isGif: false,
+ };
+ case "cta":
+ return { src: "/images/huggies/Huggy Sunny.png", isGif: false };
+ case "share": {
+ const byArchetype =
+ (wrapped.archetype as ArchetypeKey) in archetypeImage
+ ? archetypeImage[wrapped.archetype as ArchetypeKey]
+ : undefined;
+ return {
+ src: byArchetype ?? "/images/huggies/Huggy Sunny.png",
+ isGif: false,
+ };
+ }
+ default:
+ return { src: undefined, isGif: false };
+ }
+}
+
+function truncateHandle(handle: string, max = 25): string {
+ if (handle.length <= max) {
+ return handle;
+ }
+ return `${handle.slice(0, max - 1)}…`;
+}
+
+function ellipsize(value: string, max = 40): string {
+ if (value.length <= max) {
+ return value;
+ }
+ return `${value.slice(0, max - 3)}...`;
+}
+
+function pickBadge(activity: WrappedResult["activity"]): string {
+ if (activity.totalDownloads > 1_000_000) {
+ return "Model Powerhouse";
+ }
+ if (activity.totalLikes > 5_000) {
+ return "Community Favorite";
+ }
+ if (activity.papers.length >= 2) {
+ return "Research Beacon";
+ }
+ if (activity.spaces.length >= 3) {
+ return "Spaces Trailblazer";
+ }
+ if (activity.datasets.length >= 5) {
+ return "Data Shaper";
+ }
+ if (activity.models.length >= 3) {
+ return "Model Builder";
+ }
+ return "HF Explorer";
+}
+
+function badgeReason(badge: string): string {
+ switch (badge) {
+ case "Model Powerhouse":
+ return "1M+ downloads across your work";
+ case "Community Favorite":
+ return "5k+ likes from the community";
+ case "Research Beacon":
+ return "Shared multiple research papers";
+ case "Spaces Trailblazer":
+ return "Built 3+ interactive spaces";
+ case "Data Shaper":
+ return "Published 5+ datasets";
+ case "Model Builder":
+ return "Created 3+ models";
+ case "HF Explorer":
+ default:
+ return "Exploring across repos and topics";
+ }
+}
+
+function buildBadgeMetrics(
+ badge: string,
+ wrapped: WrappedResult,
+ fmt: Intl.NumberFormat,
+): { label: string; value: string }[] {
+ const activity = wrapped.activity;
+
+ switch (badge) {
+ case "Model Powerhouse":
+ return [
+ {
+ label: "Downloads",
+ value: fmt.format(activity.totalDownloads),
+ },
+ {
+ label: "Models",
+ value: fmt.format(activity.models.length || 1),
+ },
+ ];
+ case "Community Favorite":
+ return [
+ {
+ label: "Likes",
+ value: fmt.format(activity.totalLikes),
+ },
+ {
+ label: "Repos",
+ value: fmt.format(activity.totalRepos),
+ },
+ ];
+ case "Research Beacon":
+ return [
+ {
+ label: "Papers",
+ value: fmt.format(activity.papers.length),
+ },
+ {
+ label: "Repos",
+ value: fmt.format(activity.totalRepos),
+ },
+ ];
+ case "Spaces Trailblazer":
+ return [
+ {
+ label: "Spaces",
+ value: fmt.format(activity.spaces.length),
+ },
+ {
+ label: "Likes",
+ value: fmt.format(activity.totalLikes),
+ },
+ ];
+ case "Data Shaper":
+ return [
+ {
+ label: "Datasets",
+ value: fmt.format(activity.datasets.length),
+ },
+ {
+ label: "Downloads",
+ value: fmt.format(activity.totalDownloads),
+ },
+ ];
+ case "Model Builder":
+ return [
+ {
+ label: "Models",
+ value: fmt.format(activity.models.length),
+ },
+ {
+ label: "Downloads",
+ value: fmt.format(activity.totalDownloads),
+ },
+ ];
+ case "HF Explorer":
+ default:
+ return [
+ {
+ label: "Repos",
+ value: fmt.format(activity.totalRepos),
+ },
+ {
+ label: "Downloads",
+ value: fmt.format(activity.totalDownloads),
+ },
+ ];
+ }
+}
+
+function buildSlides(wrapped: WrappedResult): StorySlide[] {
+ const fmt = new Intl.NumberFormat("en-US", { notation: "compact" });
+ const badge = pickBadge(wrapped.activity);
+ const topModels = wrapped.activity.models.slice(0, 3);
+ const topDatasets = wrapped.activity.datasets.slice(0, 3);
+ const topSpaces = wrapped.activity.spaces.slice(0, 3);
+ const topPapers = wrapped.activity.papers.slice(0, 2);
+
+ return [
+ {
+ id: "intro",
+ kind: "intro",
+ title: `Your ${wrapped.year} Hugging Face Wrapped`,
+ subtitle: wrapped.profile.displayName ?? wrapped.profile.handle,
+ metrics: [
+ {
+ label: "Repositories",
+ value: wrapped.activity.totalRepos.toString(),
+ },
+ {
+ label: "Downloads",
+ value: fmt.format(wrapped.activity.totalDownloads),
+ },
+ ],
+ highlights: wrapped.activity.topTags.slice(0, 3),
+ },
+ {
+ id: "summary",
+ kind: "summary",
+ title: "Activity pulse",
+ subtitle: "Models, datasets, spaces, papers",
+ metrics: [
+ {
+ label: "Models",
+ value: wrapped.activity.models.length.toString(),
+ },
+ {
+ label: "Datasets",
+ value: wrapped.activity.datasets.length.toString(),
+ },
+ {
+ label: "Spaces",
+ value: wrapped.activity.spaces.length.toString(),
+ },
+ {
+ label: "Papers",
+ value: wrapped.activity.papers.length.toString(),
+ },
+ ],
+ highlights: [
+ wrapped.activity.busiestMonth
+ ? `Busiest month: ${wrapped.activity.busiestMonth}`
+ : "Consistent all year",
+ ],
+ },
+ ...(topModels.length
+ ? [
+ {
+ id: "models",
+ kind: "models",
+ title: "Models that led",
+ subtitle: "Most downloaded & loved",
+ metrics: topModels.map((repo) => ({
+ label: repo.name,
+ value: `${fmt.format(repo.downloads ?? 0)} downloads`,
+ })),
+ highlights: wrapped.activity.topTags.slice(0, 2),
+ } satisfies StorySlide,
+ ]
+ : []),
+ ...(topDatasets.length
+ ? [
+ {
+ id: "datasets",
+ kind: "datasets",
+ title: "Datasets that fueled",
+ subtitle: "Community favorites",
+ metrics: topDatasets.map((repo) => ({
+ label: repo.name,
+ value: `${fmt.format(repo.downloads ?? 0)} pulls`,
+ })),
+ highlights: wrapped.activity.topTags.slice(0, 2),
+ } satisfies StorySlide,
+ ]
+ : []),
+ ...(topSpaces.length
+ ? [
+ {
+ id: "spaces",
+ kind: "spaces",
+ title: "Spaces that told the story",
+ subtitle: "Interactive apps that resonated",
+ metrics: topSpaces.map((repo) => ({
+ label: repo.name,
+ value: `${fmt.format(repo.likes ?? 0)} likes`,
+ })),
+ highlights: wrapped.activity.topTags.slice(0, 2),
+ } satisfies StorySlide,
+ ]
+ : []),
+ ...(topPapers.length
+ ? [
+ {
+ id: "papers",
+ kind: "papers",
+ title: "Research you shared",
+ subtitle: "Papers and findings",
+ metrics: topPapers.map((paper) => ({
+ label: paper.title,
+ value: paper.publishedAt
+ ? new Date(paper.publishedAt)
+ .getFullYear()
+ .toString()
+ : "Published",
+ })),
+ } satisfies StorySlide,
+ ]
+ : []),
+ {
+ id: "archetype",
+ kind: "archetype",
+ title: "Your archetype",
+ subtitle: wrapped.archetype,
+ metrics: [
+ {
+ label: "Downloads",
+ value: fmt.format(wrapped.activity.totalDownloads),
+ },
+ {
+ label: "Likes",
+ value: fmt.format(wrapped.activity.totalLikes),
+ },
+ ],
+ highlights: sanitizeHighlights(
+ archetypeHighlights[wrapped.archetype as ArchetypeKey] ??
+ (wrapped.activity.topTags ?? []).slice(0, 3),
+ ),
+ },
+ {
+ id: "badges",
+ kind: "badges",
+ title: "Your badge this year",
+ subtitle: badge,
+ metrics: buildBadgeMetrics(badge, wrapped, fmt),
+ highlights: [badgeReason(badge)],
+ },
+ {
+ id: "share",
+ kind: "share",
+ title: `@${truncateHandle(wrapped.profile.handle)}`,
+ subtitle: `Your Hugging Face 🤗 in ${wrapped.year} `,
+ metrics: [
+ {
+ label: "Badge",
+ value: badge,
+ },
+ {
+ label: "Archetype",
+ value: wrapped.archetype,
+ },
+ {
+ label:
+ wrapped.activity.papers.length >
+ Math.max(
+ wrapped.activity.models.length,
+ wrapped.activity.datasets.length,
+ wrapped.activity.spaces.length,
+ )
+ ? "Papers"
+ : "Repos",
+ value:
+ wrapped.activity.papers.length >
+ Math.max(
+ wrapped.activity.models.length,
+ wrapped.activity.datasets.length,
+ wrapped.activity.spaces.length,
+ )
+ ? fmt.format(wrapped.activity.papers.length)
+ : fmt.format(wrapped.activity.totalRepos),
+ },
+ {
+ label: "Downloads",
+ value: fmt.format(wrapped.activity.totalDownloads),
+ },
+ {
+ label: "Likes",
+ value: fmt.format(wrapped.activity.totalLikes),
+ },
+ {
+ label: "Top model",
+ value:
+ wrapped.activity.models[0]?.name ??
+ "No model yet — create one!",
+ },
+ {
+ label: "Top dataset",
+ value:
+ wrapped.activity.datasets[0]?.name ??
+ "No dataset yet — publish one!",
+ },
+ {
+ label: "Top space",
+ value:
+ wrapped.activity.spaces[0]?.name ??
+ "No space yet — launch one!",
+ },
+ ],
+ highlights: [
+ ...(wrapped.activity.topTags.slice(0, 1) ?? []),
+ "huggingface.co/spaces/hf-wrapped/2025",
+ ],
+ },
+ ];
+}
+
+export function StoryScroller({ wrapped }: { wrapped: WrappedResult }) {
+ const slides = useMemo(() => buildSlides(wrapped), [wrapped]);
+ const panelsRef = useRef