daqc commited on
Commit
ad19202
·
0 Parent(s):

Initial commit

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +10 -0
  2. .editorconfig +9 -0
  3. .env.local.example +11 -0
  4. .gitattributes +6 -0
  5. .github/dependabot.yml +15 -0
  6. .github/workflows/validate-prs.yml +41 -0
  7. .gitignore +45 -0
  8. .npmrc +1 -0
  9. .vscode/extensions.json +7 -0
  10. .vscode/settings.json +31 -0
  11. Dockerfile +38 -0
  12. README.md +13 -0
  13. apps/web/.gitignore +39 -0
  14. apps/web/app/(marketing)/[locale]/(home)/page.tsx +25 -0
  15. apps/web/app/(marketing)/[locale]/[...rest]/page.tsx +5 -0
  16. apps/web/app/(marketing)/[locale]/layout.tsx +39 -0
  17. apps/web/app/(marketing)/[locale]/not-found.tsx +5 -0
  18. apps/web/app/api/wrapped/route.ts +76 -0
  19. apps/web/app/favicon.ico +3 -0
  20. apps/web/app/globals.css +67 -0
  21. apps/web/app/icon.png +3 -0
  22. apps/web/app/layout.tsx +16 -0
  23. apps/web/app/robots.ts +10 -0
  24. apps/web/app/sitemap.ts +15 -0
  25. apps/web/biome.json +20 -0
  26. apps/web/components.json +17 -0
  27. apps/web/global.d.ts +16 -0
  28. apps/web/modules/i18n/lib/locale-cookie.ts +14 -0
  29. apps/web/modules/i18n/lib/update-locale.ts +10 -0
  30. apps/web/modules/i18n/request.ts +22 -0
  31. apps/web/modules/i18n/routing.ts +20 -0
  32. apps/web/modules/marketing/home/components/Hero.tsx +267 -0
  33. apps/web/modules/marketing/home/components/StoryScroller.tsx +1894 -0
  34. apps/web/modules/marketing/shared/components/Footer.tsx +18 -0
  35. apps/web/modules/marketing/shared/components/NavBar.tsx +156 -0
  36. apps/web/modules/marketing/shared/components/NotFound.tsx +20 -0
  37. apps/web/modules/shared/components/ClientProviders.tsx +31 -0
  38. apps/web/modules/shared/components/ColorModeToggle.tsx +82 -0
  39. apps/web/modules/shared/components/Document.tsx +34 -0
  40. apps/web/modules/shared/components/LocaleSwitch.tsx +72 -0
  41. apps/web/modules/shared/components/Logo.tsx +41 -0
  42. apps/web/modules/shared/components/Spinner.tsx +10 -0
  43. apps/web/modules/ui/components/avatar.tsx +43 -0
  44. apps/web/modules/ui/components/button.tsx +67 -0
  45. apps/web/modules/ui/components/dropdown-menu.tsx +191 -0
  46. apps/web/modules/ui/components/input.tsx +19 -0
  47. apps/web/modules/ui/components/sheet.tsx +131 -0
  48. apps/web/modules/ui/components/toast.tsx +33 -0
  49. apps/web/modules/ui/lib/index.ts +7 -0
  50. apps/web/next.config.ts +27 -0
.dockerignore ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ node_modules
2
+ .pnpm-store
3
+ .turbo
4
+ .next
5
+ out
6
+ dist
7
+ .git
8
+ .gitignore
9
+ .env*
10
+ Dockerfile
.editorconfig ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ root = true
2
+
3
+ [*]
4
+ indent_style = tab
5
+ indent_size = 4
6
+ end_of_line = lf
7
+ charset = utf-8
8
+ trim_trailing_whitespace = false
9
+ insert_final_newline = true
.env.local.example ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Nothing is required for now.
2
+
3
+ # Pending: enable HF dataset creation/writes when blockers are cleared.
4
+ # HF_TOKEN=
5
+ # WRAPPED_DATASET_ID=
6
+ # WRAPPED_DATASET_WRITE=true
7
+
8
+ # Optional local defaults (uncomment if you need them)
9
+ # NEXT_PUBLIC_SITE_URL="http://localhost:3000"
10
+ # NEXT_PUBLIC_WRAPPED_DEFAULT_HANDLE=""
11
+ # NEXT_PUBLIC_WRAPPED_DEFAULT_SUBJECT_TYPE="auto"
.gitattributes ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ *.png filter=lfs diff=lfs merge=lfs -text
2
+ *.jpg filter=lfs diff=lfs merge=lfs -text
3
+ *.jpeg filter=lfs diff=lfs merge=lfs -text
4
+ *.gif filter=lfs diff=lfs merge=lfs -text
5
+ *.svg filter=lfs diff=lfs merge=lfs -text
6
+ *.ico filter=lfs diff=lfs merge=lfs -text
.github/dependabot.yml ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ version: 2
2
+ updates:
3
+ - package-ecosystem: "npm"
4
+ directory: "/"
5
+ open-pull-requests-limit: 2
6
+ schedule:
7
+ interval: "daily"
8
+ ignore:
9
+ - dependency-name: "cropperjs"
10
+ versions: [">1.6.2"]
11
+ groups:
12
+ production-dependencies:
13
+ dependency-type: "production"
14
+ development-dependencies:
15
+ dependency-type: "development"
.github/workflows/validate-prs.yml ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Validate PRs
2
+
3
+ on:
4
+ pull_request:
5
+ branches: [main]
6
+
7
+ env:
8
+ DATABASE_URL: ${{ secrets.DATABASE_URL }}
9
+
10
+ jobs:
11
+ lint:
12
+ name: Lint code
13
+ runs-on: ubuntu-latest
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+ - name: Setup Biome
17
+ uses: biomejs/setup-biome@v2
18
+ with:
19
+ version: latest
20
+ - name: Run Biome
21
+ run: biome ci .
22
+ e2e:
23
+ name: Run e2e tests
24
+ timeout-minutes: 60
25
+ runs-on: ubuntu-latest
26
+ steps:
27
+ - uses: actions/checkout@v4
28
+ - uses: actions/setup-node@v4
29
+ with:
30
+ node-version: lts/*
31
+ - uses: pnpm/action-setup@v4
32
+ - name: Install dependencies
33
+ run: pnpm install && pnpm --filter database generate
34
+ - name: Run Playwright tests
35
+ run: pnpm --filter web e2e:ci
36
+ - uses: actions/upload-artifact@v4
37
+ if: ${{ !cancelled() }}
38
+ with:
39
+ name: playwright-report
40
+ path: apps/web/playwright-report/
41
+ retention-days: 30
.gitignore ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2
+
3
+ # dependencies
4
+ node_modules
5
+ .pnp
6
+ .pnp.js
7
+
8
+ # testing
9
+ coverage
10
+
11
+ # next.js
12
+ .next/
13
+ out/
14
+ build
15
+ .swc/
16
+
17
+ # misc
18
+ .DS_Store
19
+ *.pem
20
+
21
+ # debug
22
+ npm-debug.log*
23
+ yarn-debug.log*
24
+ yarn-error.log*
25
+
26
+ # local env files
27
+ .env
28
+ .env.local
29
+ .env.development.local
30
+ .env.test.local
31
+ .env.production.local
32
+
33
+ # turbo
34
+ .turbo
35
+
36
+ # ui
37
+ dist/
38
+
39
+ # typescript
40
+ *.tsbuildinfo
41
+
42
+ # other
43
+ .react-email/
44
+ .content-collections/
45
+ .prisma-zod-generator-manifest.json
.npmrc ADDED
@@ -0,0 +1 @@
 
 
1
+ public-hoist-pattern[]=*prisma*
.vscode/extensions.json ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ {
2
+ "recommendations": [
3
+ "lokalise.i18n-ally",
4
+ "bradlc.vscode-tailwindcss",
5
+ "biomejs.biome"
6
+ ]
7
+ }
.vscode/settings.json ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "editor.defaultFormatter": "biomejs.biome",
3
+ "editor.formatOnSave": true,
4
+ "editor.formatOnPaste": true,
5
+ "tailwindCSS.experimental.classRegex": [
6
+ ["cn\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"]
7
+ ],
8
+ "editor.codeActionsOnSave": {
9
+ "source.fixAll.biome": "explicit",
10
+ "source.organizeImports.biome": "explicit"
11
+ },
12
+ "typescript.preferences.importModuleSpecifier": "non-relative",
13
+ "typescript.tsdk": "node_modules/typescript/lib",
14
+ "i18n-ally.localesPaths": ["packages/i18n/translations"],
15
+ "i18n-ally.keystyle": "nested",
16
+ "i18n-ally.enabledFrameworks": ["next-intl"],
17
+ "i18n-ally.keysInUse": ["mail.organizationInvitation.headline"],
18
+ "i18n-ally.tabStyle": "tab",
19
+ "[typescript]": {
20
+ "editor.defaultFormatter": "biomejs.biome"
21
+ },
22
+ "[typescriptreact]": {
23
+ "editor.defaultFormatter": "biomejs.biome"
24
+ },
25
+ "[json]": {
26
+ "editor.defaultFormatter": "biomejs.biome"
27
+ },
28
+ "[prisma]": {
29
+ "editor.defaultFormatter": "Prisma.prisma"
30
+ }
31
+ }
Dockerfile ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM node:20-alpine
2
+
3
+ ENV NODE_ENV=production
4
+ WORKDIR /app
5
+
6
+ # Install pnpm
7
+ RUN npm install -g [email protected]
8
+
9
+ # Copy lockfiles and workspace manifests for better install caching
10
+ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json tsconfig.json .npmrc* ./
11
+ COPY apps/web/package.json apps/web/package.json
12
+ COPY packages/wrapped/package.json packages/wrapped/package.json
13
+ COPY packages/utils/package.json packages/utils/package.json
14
+ COPY packages/i18n/package.json packages/i18n/package.json
15
+ COPY config/package.json config/package.json
16
+ COPY tooling/typescript/package.json tooling/typescript/package.json
17
+ COPY tooling/tailwind/package.json tooling/tailwind/package.json
18
+ COPY tooling/scripts/package.json tooling/scripts/package.json
19
+ COPY apps/web/tsconfig.json apps/web/tsconfig.json
20
+ COPY packages/wrapped/tsconfig.json packages/wrapped/tsconfig.json
21
+ COPY packages/utils/tsconfig.json packages/utils/tsconfig.json
22
+ COPY packages/i18n/tsconfig.json packages/i18n/tsconfig.json
23
+ COPY config/tsconfig.json config/tsconfig.json
24
+
25
+ # Install dependencies
26
+ RUN pnpm install --frozen-lockfile
27
+
28
+ # Copy the rest of the monorepo
29
+ COPY . .
30
+
31
+ # Build only the web app
32
+ RUN pnpm turbo run build --filter @repo/web
33
+
34
+ # Hugging Face Spaces expects the app on port 7860
35
+ EXPOSE 7860
36
+
37
+ # Run Next on the expected host/port from the web app directory.
38
+ CMD ["sh", "-c", "cd apps/web && PORT=${PORT:-7860} HOSTNAME=0.0.0.0 pnpm start --hostname 0.0.0.0 --port ${PORT:-7860}"]
README.md ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: hf-wrapped
3
+ emoji: 🤗
4
+ colorFrom: yellow
5
+ colorTo: blue
6
+ sdk: docker
7
+ pinned: false
8
+ app_port: 7860
9
+ ---
10
+
11
+ # hf-wrapped
12
+
13
+ Hugging Face Wrapped 2025 — Docker Space deployment.
apps/web/.gitignore ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2
+
3
+ # dependencies
4
+ /node_modules
5
+ /.pnp
6
+ .pnp.js
7
+
8
+ # testing
9
+ /coverage
10
+ /playwright-report
11
+ /test-results
12
+
13
+ # next.js
14
+ /.next/
15
+ /out/
16
+
17
+ # production
18
+ /build
19
+
20
+ # misc
21
+ .DS_Store
22
+ *.pem
23
+
24
+ # debug
25
+ npm-debug.log*
26
+ yarn-debug.log*
27
+ yarn-error.log*
28
+
29
+ # local env files
30
+ .env*.local
31
+
32
+ # vercel
33
+ .vercel
34
+
35
+ # typescript
36
+ *.tsbuildinfo
37
+ next-env.d.ts
38
+
39
+ .content-collections
apps/web/app/(marketing)/[locale]/(home)/page.tsx ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Hero } from "@marketing/home/components/Hero";
2
+ import { setRequestLocale } from "next-intl/server";
3
+
4
+ export default async function Home({
5
+ params,
6
+ }: {
7
+ params: Promise<{ locale: string }>;
8
+ }) {
9
+ const { locale } = await params;
10
+ setRequestLocale(locale);
11
+
12
+ return (
13
+ <div className="flex min-h-screen flex-col overflow-hidden bg-background">
14
+ <Hero />
15
+ <style>
16
+ {`
17
+ nav[data-test="navigation"],
18
+ footer {
19
+ display: none !important;
20
+ }
21
+ `}
22
+ </style>
23
+ </div>
24
+ );
25
+ }
apps/web/app/(marketing)/[locale]/[...rest]/page.tsx ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ import { notFound } from "next/navigation";
2
+
3
+ export default function CatchAll() {
4
+ notFound();
5
+ }
apps/web/app/(marketing)/[locale]/layout.tsx ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Footer } from "@marketing/shared/components/Footer";
2
+ import { NavBar } from "@marketing/shared/components/NavBar";
3
+ import { config } from "@repo/config";
4
+ import { Document } from "@shared/components/Document";
5
+ import { notFound } from "next/navigation";
6
+ import { NextIntlClientProvider } from "next-intl";
7
+ import { getMessages, setRequestLocale } from "next-intl/server";
8
+ import type { PropsWithChildren } from "react";
9
+
10
+ const locales = Object.keys(config.i18n.locales);
11
+
12
+ export function generateStaticParams() {
13
+ return locales.map((locale) => ({ locale }));
14
+ }
15
+
16
+ export default async function MarketingLayout({
17
+ children,
18
+ params,
19
+ }: PropsWithChildren<{ params: Promise<{ locale: string }> }>) {
20
+ const { locale } = await params;
21
+
22
+ setRequestLocale(locale);
23
+
24
+ if (!locales.includes(locale as any)) {
25
+ notFound();
26
+ }
27
+
28
+ const messages = await getMessages();
29
+
30
+ return (
31
+ <Document locale={locale}>
32
+ <NextIntlClientProvider locale={locale} messages={messages}>
33
+ <NavBar />
34
+ <main className="min-h-[calc(100vh-64px)]">{children}</main>
35
+ <Footer />
36
+ </NextIntlClientProvider>
37
+ </Document>
38
+ );
39
+ }
apps/web/app/(marketing)/[locale]/not-found.tsx ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ import { NotFound } from "@marketing/shared/components/NotFound";
2
+
3
+ export default async function NotFoundPage() {
4
+ return <NotFound />;
5
+ }
apps/web/app/api/wrapped/route.ts ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { generateWrapped } from "@repo/wrapped";
2
+ import { NextResponse } from "next/server";
3
+ import { z } from "zod";
4
+
5
+ const requestSchema = z.object({
6
+ handle: z.string().trim().min(2).max(80),
7
+ subjectType: z.enum(["user", "organization", "auto"]).optional(),
8
+ year: z.number().int().min(2000).max(2100).optional(),
9
+ allowRefresh: z.boolean().optional(),
10
+ });
11
+
12
+ const rateLimitEnabled = process.env.WRAPPED_RATE_LIMIT_ENABLED === "true";
13
+ const windowMs =
14
+ Number.parseInt(process.env.WRAPPED_RATE_LIMIT_WINDOW_MS ?? "60000", 10) ||
15
+ 60000;
16
+ const maxRequests =
17
+ Number.parseInt(process.env.WRAPPED_RATE_LIMIT_MAX ?? "30", 10) || 30;
18
+ const hits = new Map<string, number[]>();
19
+
20
+ function track(ip: string) {
21
+ const now = Date.now();
22
+ const entries =
23
+ hits.get(ip)?.filter((timestamp) => now - timestamp < windowMs) ?? [];
24
+ entries.push(now);
25
+ hits.set(ip, entries);
26
+ return entries.length;
27
+ }
28
+
29
+ export async function POST(req: Request) {
30
+ const ip =
31
+ req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
32
+ req.headers.get("x-real-ip") ??
33
+ "unknown";
34
+
35
+ if (rateLimitEnabled && track(ip) > maxRequests) {
36
+ return NextResponse.json(
37
+ { error: "Rate limit exceeded" },
38
+ { status: 429 },
39
+ );
40
+ }
41
+
42
+ let body: unknown;
43
+ try {
44
+ body = await req.json();
45
+ } catch {
46
+ return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
47
+ }
48
+
49
+ const parsed = requestSchema.safeParse(body);
50
+ if (!parsed.success) {
51
+ return NextResponse.json(
52
+ { error: "Invalid input", details: parsed.error.format() },
53
+ { status: 400 },
54
+ );
55
+ }
56
+
57
+ const payload = parsed.data;
58
+ const year = payload.year ?? new Date().getUTCFullYear();
59
+
60
+ try {
61
+ const result = await generateWrapped({
62
+ ...payload,
63
+ year,
64
+ });
65
+ return NextResponse.json(result, { status: 200 });
66
+ } catch (error) {
67
+ console.error("Wrapped generation failed:", error);
68
+ const message =
69
+ (error as Error).message ?? "Failed to generate wrapped";
70
+ // If handle is not found, return 404 for clarity
71
+ if (message.toLowerCase().includes("handle not found")) {
72
+ return NextResponse.json({ error: message }, { status: 404 });
73
+ }
74
+ return NextResponse.json({ error: message }, { status: 500 });
75
+ }
76
+ }
apps/web/app/favicon.ico ADDED

Git LFS Details

  • SHA256: 2b32eefed23493b634091fff5e30454b0939a78b412190e4cb86fa7f3f86aa8b
  • Pointer size: 129 Bytes
  • Size of remote file: 6.43 kB
apps/web/app/globals.css ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @import "tailwindcss";
2
+ @import "fumadocs-ui/css/neutral.css";
3
+ @import "fumadocs-ui/css/preset.css";
4
+ @import "@repo/tailwind-config/theme.css";
5
+ @import "@repo/tailwind-config/tailwind-animate.css";
6
+
7
+ @source "../node_modules/fumadocs-ui/dist/**/*.js";
8
+
9
+ @variant dark (&:where(.dark, .dark *));
10
+
11
+ /* Basement scrollytelling demo fonts */
12
+ @font-face {
13
+ font-family: "BasementGrotesque";
14
+ src:
15
+ url("/scrolly-assets/fonts/BasementGrotesque-Regular.woff2")
16
+ format("woff2"),
17
+ url("/scrolly-assets/fonts/BasementGrotesque-Regular.woff2")
18
+ format("woff2");
19
+ font-style: normal;
20
+ font-weight: 400;
21
+ font-display: swap;
22
+ }
23
+
24
+ @font-face {
25
+ font-family: "BasementGrotesque";
26
+ src:
27
+ url("/scrolly-assets/fonts/BasementGrotesque-Black.woff2")
28
+ format("woff2"),
29
+ url("/scrolly-assets/fonts/BasementGrotesque-BlackExpanded.woff2")
30
+ format("woff2");
31
+ font-style: normal;
32
+ font-weight: 900;
33
+ font-display: swap;
34
+ }
35
+
36
+ pre.shiki {
37
+ @apply mb-4 rounded-lg p-6;
38
+ }
39
+
40
+ #nd-sidebar {
41
+ @apply bg-card! top-[4.5rem] md:h-[calc(100dvh-4.5rem)]!;
42
+
43
+ button[data-search-full] {
44
+ @apply bg-transparent;
45
+ }
46
+ }
47
+
48
+ #nd-page .prose {
49
+ h1,
50
+ h2,
51
+ h3,
52
+ h4,
53
+ h5,
54
+ h6 {
55
+ a {
56
+ @apply no-underline!;
57
+ }
58
+ }
59
+ }
60
+
61
+ div[role="tablist"].bg-secondary {
62
+ @apply bg-muted!;
63
+ }
64
+
65
+ input[cmdk-input] {
66
+ @apply border-none focus-visible:ring-0;
67
+ }
apps/web/app/icon.png ADDED

Git LFS Details

  • SHA256: 2b32eefed23493b634091fff5e30454b0939a78b412190e4cb86fa7f3f86aa8b
  • Pointer size: 129 Bytes
  • Size of remote file: 6.43 kB
apps/web/app/layout.tsx ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Metadata } from "next";
2
+ import type { PropsWithChildren } from "react";
3
+ import "./globals.css";
4
+ import { config } from "@repo/config";
5
+
6
+ export const metadata: Metadata = {
7
+ title: {
8
+ absolute: config.appName,
9
+ default: config.appName,
10
+ template: `%s | ${config.appName}`,
11
+ },
12
+ };
13
+
14
+ export default function RootLayout({ children }: PropsWithChildren) {
15
+ return children;
16
+ }
apps/web/app/robots.ts ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { MetadataRoute } from "next";
2
+
3
+ export default function robots(): MetadataRoute.Robots {
4
+ return {
5
+ rules: {
6
+ userAgent: "*",
7
+ allow: "/",
8
+ },
9
+ };
10
+ }
apps/web/app/sitemap.ts ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { config } from "@repo/config";
2
+ import { getBaseUrl } from "@repo/utils";
3
+ import type { MetadataRoute } from "next";
4
+
5
+ const baseUrl = getBaseUrl();
6
+ const locales = config.i18n.enabled
7
+ ? Object.keys(config.i18n.locales)
8
+ : [config.i18n.defaultLocale];
9
+
10
+ export default function sitemap(): MetadataRoute.Sitemap {
11
+ return locales.map((locale) => ({
12
+ url: new URL(`/${locale}`, baseUrl).href,
13
+ lastModified: new Date(),
14
+ }));
15
+ }
apps/web/biome.json ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "root": false,
3
+ "extends": "//",
4
+ "linter": {
5
+ "rules": {
6
+ "style": {
7
+ "noParameterAssign": "error",
8
+ "useAsConstAssertion": "error",
9
+ "useDefaultParameterLast": "error",
10
+ "useEnumInitializers": "error",
11
+ "useSelfClosingElements": "error",
12
+ "useSingleVarDeclarator": "error",
13
+ "noUnusedTemplateLiteral": "error",
14
+ "useNumberNamespace": "error",
15
+ "noInferrableTypes": "error",
16
+ "noUselessElse": "error"
17
+ }
18
+ }
19
+ }
20
+ }
apps/web/components.json ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema.json",
3
+ "style": "default",
4
+ "rsc": true,
5
+ "tsx": true,
6
+ "tailwind": {
7
+ "css": "styles/globals.css",
8
+ "baseColor": "slate",
9
+ "cssVariables": true,
10
+ "prefix": ""
11
+ },
12
+ "aliases": {
13
+ "components": "@ui/components",
14
+ "utils": "@ui/lib",
15
+ "ui": "@ui/components"
16
+ }
17
+ }
apps/web/global.d.ts ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Messages } from "@repo/i18n";
2
+ import type { JSX as Jsx } from "react/jsx-runtime";
3
+
4
+ // temporary fix for mdx types
5
+ // TODO: remove once mdx has fully compatibility with react 19
6
+ declare global {
7
+ namespace JSX {
8
+ type ElementClass = Jsx.ElementClass;
9
+ type Element = Jsx.Element;
10
+ type IntrinsicElements = Jsx.IntrinsicElements;
11
+ }
12
+ }
13
+
14
+ declare global {
15
+ interface IntlMessages extends Messages {}
16
+ }
apps/web/modules/i18n/lib/locale-cookie.ts ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import "server-only";
2
+
3
+ import { config } from "@repo/config";
4
+ import type { Locale } from "@repo/i18n";
5
+ import { cookies } from "next/headers";
6
+
7
+ export async function getUserLocale() {
8
+ const cookie = (await cookies()).get(config.i18n.localeCookieName);
9
+ return cookie?.value ?? config.i18n.defaultLocale;
10
+ }
11
+
12
+ export async function setLocaleCookie(locale: Locale) {
13
+ (await cookies()).set(config.i18n.localeCookieName, locale);
14
+ }
apps/web/modules/i18n/lib/update-locale.ts ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ "use server";
2
+
3
+ import { setLocaleCookie } from "@i18n/lib/locale-cookie";
4
+ import type { Locale } from "@repo/i18n";
5
+ import { revalidatePath } from "next/cache";
6
+
7
+ export async function updateLocale(locale: Locale) {
8
+ await setLocaleCookie(locale);
9
+ revalidatePath("/");
10
+ }
apps/web/modules/i18n/request.ts ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { getUserLocale } from "@i18n/lib/locale-cookie";
2
+ import { routing } from "@i18n/routing";
3
+ import { config } from "@repo/config";
4
+ import { getMessagesForLocale } from "@repo/i18n";
5
+ import { getRequestConfig } from "next-intl/server";
6
+
7
+ export default getRequestConfig(async ({ requestLocale }) => {
8
+ let locale = await requestLocale;
9
+
10
+ if (!locale) {
11
+ locale = await getUserLocale();
12
+ }
13
+
14
+ if (!(routing.locales.includes(locale) && config.i18n.enabled)) {
15
+ locale = routing.defaultLocale;
16
+ }
17
+
18
+ return {
19
+ locale,
20
+ messages: await getMessagesForLocale(locale),
21
+ };
22
+ });
apps/web/modules/i18n/routing.ts ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { config } from "@repo/config";
2
+ import { createNavigation } from "next-intl/navigation";
3
+ import { defineRouting } from "next-intl/routing";
4
+
5
+ export const routing = defineRouting({
6
+ locales: Object.keys(config.i18n.locales),
7
+ defaultLocale: config.i18n.defaultLocale,
8
+ localeCookie: {
9
+ name: config.i18n.localeCookieName,
10
+ },
11
+ localePrefix: config.i18n.enabled ? "always" : "never",
12
+ localeDetection: config.i18n.enabled,
13
+ });
14
+
15
+ export const {
16
+ Link: LocaleLink,
17
+ redirect: localeRedirect,
18
+ usePathname: useLocalePathname,
19
+ useRouter: useLocaleRouter,
20
+ } = createNavigation(routing);
apps/web/modules/marketing/home/components/Hero.tsx ADDED
@@ -0,0 +1,267 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useGSAP } from "@gsap/react";
4
+ import type { WrappedResult } from "@repo/wrapped";
5
+ import { Button } from "@ui/components/button";
6
+ import { Input } from "@ui/components/input";
7
+ import gsap from "gsap";
8
+ import { ArrowRightIcon, GithubIcon, Loader2Icon } from "lucide-react";
9
+ import Image from "next/image";
10
+ import { useEffect, useRef, useState, useTransition } from "react";
11
+ import { toast } from "sonner";
12
+ import { StoryScroller } from "./StoryScroller";
13
+
14
+ const defaultHandle = process.env.NEXT_PUBLIC_WRAPPED_DEFAULT_HANDLE ?? "";
15
+ const defaultSubjectType =
16
+ process.env.NEXT_PUBLIC_WRAPPED_DEFAULT_SUBJECT_TYPE ?? "auto";
17
+
18
+ const demoWrapped: WrappedResult = {
19
+ profile: {
20
+ handle: "hf-demo",
21
+ displayName: "HF Demo",
22
+ subjectType: "user",
23
+ },
24
+ year: 2025,
25
+ activity: {
26
+ models: [],
27
+ datasets: [],
28
+ spaces: [],
29
+ papers: [],
30
+ totalDownloads: 120_000,
31
+ totalLikes: 800,
32
+ totalRepos: 6,
33
+ topTags: ["text-generation", "vision", "audio"],
34
+ busiestMonth: "June",
35
+ },
36
+ archetype: "Model Maestro",
37
+ badges: ["Top 1M+ downloads", "Community favorite", "Peak month: June"],
38
+ slides: [
39
+ {
40
+ id: "intro",
41
+ kind: "intro",
42
+ title: "Your 2025 Hugging Face Wrapped",
43
+ subtitle: "hf-demo",
44
+ metrics: [
45
+ { label: "Repositories", value: "6", accent: "primary" },
46
+ { label: "Downloads", value: "120k" },
47
+ ],
48
+ highlights: ["text-generation", "vision", "audio"],
49
+ },
50
+ {
51
+ id: "models",
52
+ kind: "models",
53
+ title: "Top models",
54
+ subtitle: "Most downloaded",
55
+ metrics: [
56
+ { label: "hf-demo/sdxl", value: "55k downloads" },
57
+ { label: "hf-demo/tts", value: "38k downloads" },
58
+ ],
59
+ },
60
+ {
61
+ id: "archetype",
62
+ kind: "archetype",
63
+ title: "Archetype",
64
+ subtitle: "Model Maestro",
65
+ metrics: [
66
+ { label: "Likes", value: "800" },
67
+ { label: "Busiest month", value: "June" },
68
+ ],
69
+ highlights: ["Community favorite", "Peak month: June"],
70
+ },
71
+ ],
72
+ cached: false,
73
+ generatedAt: new Date().toISOString(),
74
+ source: "live",
75
+ };
76
+
77
+ export function Hero() {
78
+ const [handle, setHandle] = useState(defaultHandle);
79
+ const [wrapped, setWrapped] = useState<WrappedResult>(demoWrapped);
80
+ const [isPending, startTransition] = useTransition();
81
+ const [hasSubmitted, setHasSubmitted] = useState(false);
82
+ const heroRef = useRef<HTMLDivElement | null>(null);
83
+ const panelRef = useRef<HTMLDivElement | null>(null);
84
+
85
+ useGSAP(() => {
86
+ if (heroRef.current) {
87
+ gsap.to(heroRef.current, {
88
+ backgroundPosition: "110% 90%",
89
+ scale: 1.005,
90
+ duration: 14,
91
+ repeat: -1,
92
+ yoyo: true,
93
+ ease: "sine.inOut",
94
+ });
95
+ }
96
+ if (panelRef.current) {
97
+ gsap.from(panelRef.current, {
98
+ opacity: 0,
99
+ y: 30,
100
+ scale: 0.98,
101
+ duration: 0.6,
102
+ ease: "power3.out",
103
+ });
104
+ }
105
+ });
106
+
107
+ async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
108
+ event.preventDefault();
109
+ if (!handle.trim()) {
110
+ toast.error("Enter a Hugging Face handle to generate the story.");
111
+ return;
112
+ }
113
+
114
+ startTransition(async () => {
115
+ try {
116
+ const response = await fetch("/api/wrapped", {
117
+ method: "POST",
118
+ body: JSON.stringify({
119
+ handle: handle.trim(),
120
+ year: 2025,
121
+ subjectType: "auto",
122
+ allowRefresh: true,
123
+ }),
124
+ });
125
+
126
+ const payload = (await response.json()) as
127
+ | { error?: string }
128
+ | WrappedResult;
129
+
130
+ if (!response.ok || "error" in payload) {
131
+ throw new Error(
132
+ (payload as { error?: string }).error ??
133
+ "Failed to generate wrapped",
134
+ );
135
+ }
136
+
137
+ setWrapped(payload as WrappedResult);
138
+ setHasSubmitted(true);
139
+ } catch (error) {
140
+ toast.error((error as Error).message);
141
+ }
142
+ });
143
+ }
144
+
145
+ useEffect(() => {
146
+ if (!defaultHandle) {
147
+ return;
148
+ }
149
+ startTransition(async () => {
150
+ try {
151
+ const response = await fetch("/api/wrapped", {
152
+ method: "POST",
153
+ body: JSON.stringify({
154
+ handle: defaultHandle,
155
+ year: 2025,
156
+ subjectType: defaultSubjectType,
157
+ allowRefresh: false,
158
+ }),
159
+ });
160
+ const payload = (await response.json()) as
161
+ | { error?: string }
162
+ | WrappedResult;
163
+ if (!response.ok || "error" in payload) {
164
+ return;
165
+ }
166
+ setWrapped(payload as WrappedResult);
167
+ } catch {
168
+ // ignore prefetch errors
169
+ }
170
+ });
171
+ }, []);
172
+
173
+ return (
174
+ <section
175
+ ref={heroRef}
176
+ className="relative flex min-h-screen flex-col items-center justify-center bg-[radial-gradient(circle_at_20%_20%,rgba(59,130,246,0.10),transparent_30%),radial-gradient(circle_at_80%_0%,rgba(236,72,153,0.10),transparent_30%),linear-gradient(135deg,rgba(15,23,42,0.94),rgba(15,23,42,0.98))] bg-size-[200%_200%] px-4 py-10"
177
+ id="generator"
178
+ >
179
+ <div
180
+ ref={panelRef}
181
+ className={`relative z-10 w-full ${
182
+ hasSubmitted
183
+ ? "max-w-none"
184
+ : "max-w-[560px] md:max-w-[720px]"
185
+ }`}
186
+ >
187
+ {!hasSubmitted ? (
188
+ <form
189
+ onSubmit={onSubmit}
190
+ className="flex flex-col gap-7 rounded-3xl border border-primary/15 bg-card/85 p-8 shadow-[0_30px_80px_-40px_rgba(59,130,246,0.65)] backdrop-blur transition duration-500 hover:shadow-[0_30px_90px_-35px_rgba(59,130,246,0.85)]"
191
+ >
192
+ <div className="flex flex-col items-center gap-3 text-center">
193
+ <h1 className="flex flex-wrap items-center justify-center gap-3 text-3xl font-bold text-foreground md:text-4xl">
194
+ <span>Your 2025 in</span>
195
+ <Image
196
+ src="/images/huggies/hf-logo-with-white-title.png"
197
+ alt="Hugging Face"
198
+ width={220}
199
+ height={60}
200
+ className="h-10 w-auto drop-shadow-[0_8px_25px_rgba(0,0,0,0.35)] md:h-12"
201
+ priority
202
+ />
203
+ </h1>
204
+ <p className="text-foreground/70 text-sm md:text-base">
205
+ Drop your username. Watch 2025 unfold.
206
+ </p>
207
+ <Button
208
+ asChild
209
+ variant="outline"
210
+ size="sm"
211
+ className="mt-2"
212
+ >
213
+ <a
214
+ href="https://github.com/mcdaqc/hf-wrapped/"
215
+ target="_blank"
216
+ rel="noreferrer"
217
+ className="flex items-center gap-2"
218
+ >
219
+ <GithubIcon className="size-4" />
220
+ View on GitHub
221
+ </a>
222
+ </Button>
223
+ </div>
224
+ <div className="space-y-2">
225
+ <Input
226
+ id="handle-input"
227
+ aria-label="Username"
228
+ placeholder="@huggingface"
229
+ value={handle}
230
+ onChange={(event) =>
231
+ setHandle(event.target.value)
232
+ }
233
+ required
234
+ autoFocus
235
+ className="h-12 text-base"
236
+ />
237
+ </div>
238
+ <Button
239
+ type="submit"
240
+ size="lg"
241
+ className="relative h-12 overflow-hidden rounded-full px-6 text-base font-semibold text-white shadow-[0_18px_55px_-30px_rgba(236,72,153,0.85)] transition duration-300 hover:scale-[1.01] focus-visible:ring-2 focus-visible:ring-primary/60 focus-visible:ring-offset-2 focus-visible:ring-offset-background"
242
+ disabled={isPending}
243
+ >
244
+ <span className="absolute inset-[-55%] animate-[spin_6s_linear_infinite] bg-[conic-gradient(at_50%_50%,#f97316,#f43f5e,#a855f7,#22d3ee,#22c55e,#f97316)] opacity-80" />
245
+ <span className="absolute inset-[2px] rounded-full bg-[linear-gradient(90deg,rgba(2,6,23,0.9),rgba(15,23,42,0.9),rgba(2,6,23,0.9))]" />
246
+ {isPending ? (
247
+ <span className="relative flex items-center justify-center">
248
+ <Loader2Icon className="mr-2 size-4 animate-spin" />
249
+ Generating…
250
+ </span>
251
+ ) : (
252
+ <span className="relative flex items-center justify-center gap-2">
253
+ See my recap
254
+ <ArrowRightIcon className="size-4" />
255
+ </span>
256
+ )}
257
+ </Button>
258
+ </form>
259
+ ) : (
260
+ <div className="mx-auto w-full max-w-none">
261
+ <StoryScroller wrapped={wrapped} />
262
+ </div>
263
+ )}
264
+ </div>
265
+ </section>
266
+ );
267
+ }
apps/web/modules/marketing/home/components/StoryScroller.tsx ADDED
@@ -0,0 +1,1894 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import * as Scrollytelling from "@bsmnt/scrollytelling";
4
+ import { useGSAP } from "@gsap/react";
5
+ import type { StorySlide, WrappedResult } from "@repo/wrapped";
6
+ import { Button } from "@ui/components/button";
7
+ import { cn } from "@ui/lib";
8
+ import gsap from "gsap";
9
+ import { ScrollTrigger } from "gsap/all";
10
+ import { DownloadIcon, ShareIcon } from "lucide-react";
11
+ import Image from "next/image";
12
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
13
+
14
+ const BASE_SIZE = 1080;
15
+ const BASE_PADDING = 16;
16
+
17
+ function getTimeline({
18
+ start,
19
+ end,
20
+ overlap = 0.3,
21
+ chunks,
22
+ }: {
23
+ start: number;
24
+ end: number;
25
+ overlap?: number;
26
+ chunks: number;
27
+ }) {
28
+ const duration = end - start;
29
+ const chunk = duration / chunks;
30
+ const raw = Array.from({ length: chunks }).map((_, i) => ({
31
+ start: start + i * chunk,
32
+ end: start + (i + 1) * chunk,
33
+ }));
34
+ if (overlap <= 0) {
35
+ return raw;
36
+ }
37
+
38
+ const overlapDuration = duration * overlap;
39
+ const per = overlapDuration / raw.length;
40
+ const adjusted = raw.map((slot, i) => ({
41
+ start: slot.start - per * i,
42
+ end: slot.end - per * i,
43
+ }));
44
+ const first = adjusted[0]?.start ?? start;
45
+ const last = adjusted[adjusted.length - 1]?.end ?? end;
46
+ const scale = duration / (last - first || duration);
47
+
48
+ return adjusted.map((slot) => ({
49
+ start: Math.max(start, start + (slot.start - first) * scale),
50
+ end: Math.min(end, start + (slot.end - first) * scale),
51
+ }));
52
+ }
53
+
54
+ type MediaPalette = {
55
+ gradient: string;
56
+ accent: string;
57
+ };
58
+
59
+ const archetypeHighlights: Partial<Record<ArchetypeKey, string[]>> = {
60
+ "Model Maestro": ["Innovative", "Precise", "Impactful"],
61
+ "Dataset Architect": ["Structured", "Reliable", "Curious"],
62
+ "Space Storyteller": ["Interactive", "Engaging", "Creative"],
63
+ "Research Curator": ["Analytical", "Insightful", "Rigorous"],
64
+ "HF Explorer": ["Versatile", "Adaptive", "Curious"],
65
+ };
66
+
67
+ const palettes: Record<StorySlide["kind"], MediaPalette> = {
68
+ intro: {
69
+ gradient:
70
+ "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)",
71
+ accent: "from-sky-400 to-fuchsia-400",
72
+ },
73
+ summary: {
74
+ gradient:
75
+ "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)",
76
+ accent: "from-emerald-400 to-sky-400",
77
+ },
78
+ models: {
79
+ gradient:
80
+ "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)",
81
+ accent: "from-indigo-400 to-pink-400",
82
+ },
83
+ datasets: {
84
+ gradient:
85
+ "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)",
86
+ accent: "from-cyan-400 to-sky-400",
87
+ },
88
+ spaces: {
89
+ gradient:
90
+ "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)",
91
+ accent: "from-amber-400 to-rose-400",
92
+ },
93
+ papers: {
94
+ gradient:
95
+ "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)",
96
+ accent: "from-teal-400 to-purple-400",
97
+ },
98
+ badges: {
99
+ gradient:
100
+ "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)",
101
+ accent: "from-yellow-300 to-sky-400",
102
+ },
103
+ archetype: {
104
+ gradient:
105
+ "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)",
106
+ accent: "from-pink-400 to-indigo-400",
107
+ },
108
+ cta: {
109
+ gradient:
110
+ "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)",
111
+ accent: "from-emerald-400 to-sky-400",
112
+ },
113
+ share: {
114
+ gradient:
115
+ "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)",
116
+ accent: "from-sky-400 to-emerald-300",
117
+ },
118
+ };
119
+
120
+ function sanitizeHighlights(tags: string[]): string[] {
121
+ const banned = ["gradio", "en", "region:us", "region:eu", "demo"];
122
+ const filtered = tags.filter((tag) => !banned.includes(tag.toLowerCase()));
123
+ return filtered.length > 0 ? filtered : ["Keep exploring", "Stay curious"];
124
+ }
125
+
126
+ type ArchetypeKey =
127
+ | "Model Maestro"
128
+ | "Dataset Architect"
129
+ | "Space Storyteller"
130
+ | "Research Curator"
131
+ | "HF Explorer";
132
+
133
+ const archetypeImage: Record<ArchetypeKey, string> = {
134
+ "Model Maestro": "/images/huggies/NEW_modelmaestro.png",
135
+ "Dataset Architect": "/images/huggies/NEW_datasetarchitect.png",
136
+ "Space Storyteller": "/images/huggies/NEW_spacestoryteller.png",
137
+ "Research Curator": "/images/huggies/NEW_researchcurator.png",
138
+ "HF Explorer": "/images/huggies/Huggy Hi.png",
139
+ };
140
+
141
+ const gifMap: Partial<Record<StorySlide["kind"], string[]>> = {
142
+ intro: ["/images/huggies/Huggy Pop.gif"],
143
+ summary: ["/images/huggies/Vibing Huggy.gif"],
144
+ models: ["/images/huggies/Doodle Huggy.gif"],
145
+ spaces: [],
146
+ datasets: [],
147
+ papers: [],
148
+ cta: ["/images/huggies/Huggy Pop.gif"],
149
+ share: [],
150
+ };
151
+
152
+ const badgeImage: Record<string, string> = {
153
+ "Model Powerhouse": "/images/huggies/Optimum Huggy.png",
154
+ "Community Favorite": "/images/huggies/NEW_communityfavorite.png",
155
+ "Research Beacon": "/images/huggies/NEW_researchbeacon.png",
156
+ "Spaces Trailblazer": "/images/huggies/Rocket Huggy.png",
157
+ "Data Shaper": "/images/huggies/Manager Huggy.png",
158
+ "Model Builder": "/images/huggies/Transformer20Huggy.png",
159
+ "HF Explorer": "/images/huggies/Huggy Hi.png",
160
+ };
161
+
162
+ const archetypeAccents: Record<ArchetypeKey, string> = {
163
+ "Model Maestro": "from-indigo-400 to-sky-400",
164
+ "Dataset Architect": "from-[#e85048] to-[#ff9a7d]",
165
+ "Space Storyteller": "from-sky-300 to-cyan-400",
166
+ "Research Curator": "from-purple-500 to-violet-400",
167
+ "HF Explorer": "from-emerald-400 to-lime-300",
168
+ };
169
+
170
+ function imageForSlide(
171
+ slide: StorySlide,
172
+ wrapped: WrappedResult,
173
+ badge: string,
174
+ ): { src?: string; isGif: boolean } {
175
+ if (slide.kind !== "badges" && slide.kind !== "archetype") {
176
+ const gifPool = gifMap[slide.kind];
177
+ if (gifPool && gifPool.length > 0) {
178
+ const gif = gifPool[0];
179
+ return { src: gif, isGif: true };
180
+ }
181
+ }
182
+ switch (slide.kind) {
183
+ case "intro":
184
+ return { src: "/images/huggies/Huggy Hi.png", isGif: false };
185
+ case "summary":
186
+ return { src: "/images/huggies/X-ray Huggy.png", isGif: false };
187
+ case "models":
188
+ return {
189
+ src: "/images/huggies/Transformer20Huggy.png",
190
+ isGif: false,
191
+ };
192
+ case "datasets":
193
+ return { src: "/images/huggies/Growing20Huggy.png", isGif: false };
194
+ case "spaces":
195
+ return { src: "/images/huggies/Rocket Huggy.png", isGif: false };
196
+ case "papers":
197
+ return { src: "/images/huggies/Paper Huggy.png", isGif: false };
198
+ case "archetype": {
199
+ const key =
200
+ (wrapped.archetype as ArchetypeKey) in archetypeImage
201
+ ? (wrapped.archetype as ArchetypeKey)
202
+ : ("HF Explorer" as ArchetypeKey);
203
+ return { src: archetypeImage[key], isGif: false };
204
+ }
205
+ case "badges":
206
+ return {
207
+ src: badgeImage[badge] ?? "/images/huggies/Huggy Hi.png",
208
+ isGif: false,
209
+ };
210
+ case "cta":
211
+ return { src: "/images/huggies/Huggy Sunny.png", isGif: false };
212
+ case "share": {
213
+ const byArchetype =
214
+ (wrapped.archetype as ArchetypeKey) in archetypeImage
215
+ ? archetypeImage[wrapped.archetype as ArchetypeKey]
216
+ : undefined;
217
+ return {
218
+ src: byArchetype ?? "/images/huggies/Huggy Sunny.png",
219
+ isGif: false,
220
+ };
221
+ }
222
+ default:
223
+ return { src: undefined, isGif: false };
224
+ }
225
+ }
226
+
227
+ function truncateHandle(handle: string, max = 25): string {
228
+ if (handle.length <= max) {
229
+ return handle;
230
+ }
231
+ return `${handle.slice(0, max - 1)}…`;
232
+ }
233
+
234
+ function ellipsize(value: string, max = 40): string {
235
+ if (value.length <= max) {
236
+ return value;
237
+ }
238
+ return `${value.slice(0, max - 3)}...`;
239
+ }
240
+
241
+ function pickBadge(activity: WrappedResult["activity"]): string {
242
+ if (activity.totalDownloads > 1_000_000) {
243
+ return "Model Powerhouse";
244
+ }
245
+ if (activity.totalLikes > 5_000) {
246
+ return "Community Favorite";
247
+ }
248
+ if (activity.papers.length >= 2) {
249
+ return "Research Beacon";
250
+ }
251
+ if (activity.spaces.length >= 3) {
252
+ return "Spaces Trailblazer";
253
+ }
254
+ if (activity.datasets.length >= 5) {
255
+ return "Data Shaper";
256
+ }
257
+ if (activity.models.length >= 3) {
258
+ return "Model Builder";
259
+ }
260
+ return "HF Explorer";
261
+ }
262
+
263
+ function badgeReason(badge: string): string {
264
+ switch (badge) {
265
+ case "Model Powerhouse":
266
+ return "1M+ downloads across your work";
267
+ case "Community Favorite":
268
+ return "5k+ likes from the community";
269
+ case "Research Beacon":
270
+ return "Shared multiple research papers";
271
+ case "Spaces Trailblazer":
272
+ return "Built 3+ interactive spaces";
273
+ case "Data Shaper":
274
+ return "Published 5+ datasets";
275
+ case "Model Builder":
276
+ return "Created 3+ models";
277
+ case "HF Explorer":
278
+ default:
279
+ return "Exploring across repos and topics";
280
+ }
281
+ }
282
+
283
+ function buildBadgeMetrics(
284
+ badge: string,
285
+ wrapped: WrappedResult,
286
+ fmt: Intl.NumberFormat,
287
+ ): { label: string; value: string }[] {
288
+ const activity = wrapped.activity;
289
+
290
+ switch (badge) {
291
+ case "Model Powerhouse":
292
+ return [
293
+ {
294
+ label: "Downloads",
295
+ value: fmt.format(activity.totalDownloads),
296
+ },
297
+ {
298
+ label: "Models",
299
+ value: fmt.format(activity.models.length || 1),
300
+ },
301
+ ];
302
+ case "Community Favorite":
303
+ return [
304
+ {
305
+ label: "Likes",
306
+ value: fmt.format(activity.totalLikes),
307
+ },
308
+ {
309
+ label: "Repos",
310
+ value: fmt.format(activity.totalRepos),
311
+ },
312
+ ];
313
+ case "Research Beacon":
314
+ return [
315
+ {
316
+ label: "Papers",
317
+ value: fmt.format(activity.papers.length),
318
+ },
319
+ {
320
+ label: "Repos",
321
+ value: fmt.format(activity.totalRepos),
322
+ },
323
+ ];
324
+ case "Spaces Trailblazer":
325
+ return [
326
+ {
327
+ label: "Spaces",
328
+ value: fmt.format(activity.spaces.length),
329
+ },
330
+ {
331
+ label: "Likes",
332
+ value: fmt.format(activity.totalLikes),
333
+ },
334
+ ];
335
+ case "Data Shaper":
336
+ return [
337
+ {
338
+ label: "Datasets",
339
+ value: fmt.format(activity.datasets.length),
340
+ },
341
+ {
342
+ label: "Downloads",
343
+ value: fmt.format(activity.totalDownloads),
344
+ },
345
+ ];
346
+ case "Model Builder":
347
+ return [
348
+ {
349
+ label: "Models",
350
+ value: fmt.format(activity.models.length),
351
+ },
352
+ {
353
+ label: "Downloads",
354
+ value: fmt.format(activity.totalDownloads),
355
+ },
356
+ ];
357
+ case "HF Explorer":
358
+ default:
359
+ return [
360
+ {
361
+ label: "Repos",
362
+ value: fmt.format(activity.totalRepos),
363
+ },
364
+ {
365
+ label: "Downloads",
366
+ value: fmt.format(activity.totalDownloads),
367
+ },
368
+ ];
369
+ }
370
+ }
371
+
372
+ function buildSlides(wrapped: WrappedResult): StorySlide[] {
373
+ const fmt = new Intl.NumberFormat("en-US", { notation: "compact" });
374
+ const badge = pickBadge(wrapped.activity);
375
+ const topModels = wrapped.activity.models.slice(0, 3);
376
+ const topDatasets = wrapped.activity.datasets.slice(0, 3);
377
+ const topSpaces = wrapped.activity.spaces.slice(0, 3);
378
+ const topPapers = wrapped.activity.papers.slice(0, 2);
379
+
380
+ return [
381
+ {
382
+ id: "intro",
383
+ kind: "intro",
384
+ title: `Your ${wrapped.year} Hugging Face Wrapped`,
385
+ subtitle: wrapped.profile.displayName ?? wrapped.profile.handle,
386
+ metrics: [
387
+ {
388
+ label: "Repositories",
389
+ value: wrapped.activity.totalRepos.toString(),
390
+ },
391
+ {
392
+ label: "Downloads",
393
+ value: fmt.format(wrapped.activity.totalDownloads),
394
+ },
395
+ ],
396
+ highlights: wrapped.activity.topTags.slice(0, 3),
397
+ },
398
+ {
399
+ id: "summary",
400
+ kind: "summary",
401
+ title: "Activity pulse",
402
+ subtitle: "Models, datasets, spaces, papers",
403
+ metrics: [
404
+ {
405
+ label: "Models",
406
+ value: wrapped.activity.models.length.toString(),
407
+ },
408
+ {
409
+ label: "Datasets",
410
+ value: wrapped.activity.datasets.length.toString(),
411
+ },
412
+ {
413
+ label: "Spaces",
414
+ value: wrapped.activity.spaces.length.toString(),
415
+ },
416
+ {
417
+ label: "Papers",
418
+ value: wrapped.activity.papers.length.toString(),
419
+ },
420
+ ],
421
+ highlights: [
422
+ wrapped.activity.busiestMonth
423
+ ? `Busiest month: ${wrapped.activity.busiestMonth}`
424
+ : "Consistent all year",
425
+ ],
426
+ },
427
+ ...(topModels.length
428
+ ? [
429
+ {
430
+ id: "models",
431
+ kind: "models",
432
+ title: "Models that led",
433
+ subtitle: "Most downloaded & loved",
434
+ metrics: topModels.map((repo) => ({
435
+ label: repo.name,
436
+ value: `${fmt.format(repo.downloads ?? 0)} downloads`,
437
+ })),
438
+ highlights: wrapped.activity.topTags.slice(0, 2),
439
+ } satisfies StorySlide,
440
+ ]
441
+ : []),
442
+ ...(topDatasets.length
443
+ ? [
444
+ {
445
+ id: "datasets",
446
+ kind: "datasets",
447
+ title: "Datasets that fueled",
448
+ subtitle: "Community favorites",
449
+ metrics: topDatasets.map((repo) => ({
450
+ label: repo.name,
451
+ value: `${fmt.format(repo.downloads ?? 0)} pulls`,
452
+ })),
453
+ highlights: wrapped.activity.topTags.slice(0, 2),
454
+ } satisfies StorySlide,
455
+ ]
456
+ : []),
457
+ ...(topSpaces.length
458
+ ? [
459
+ {
460
+ id: "spaces",
461
+ kind: "spaces",
462
+ title: "Spaces that told the story",
463
+ subtitle: "Interactive apps that resonated",
464
+ metrics: topSpaces.map((repo) => ({
465
+ label: repo.name,
466
+ value: `${fmt.format(repo.likes ?? 0)} likes`,
467
+ })),
468
+ highlights: wrapped.activity.topTags.slice(0, 2),
469
+ } satisfies StorySlide,
470
+ ]
471
+ : []),
472
+ ...(topPapers.length
473
+ ? [
474
+ {
475
+ id: "papers",
476
+ kind: "papers",
477
+ title: "Research you shared",
478
+ subtitle: "Papers and findings",
479
+ metrics: topPapers.map((paper) => ({
480
+ label: paper.title,
481
+ value: paper.publishedAt
482
+ ? new Date(paper.publishedAt)
483
+ .getFullYear()
484
+ .toString()
485
+ : "Published",
486
+ })),
487
+ } satisfies StorySlide,
488
+ ]
489
+ : []),
490
+ {
491
+ id: "archetype",
492
+ kind: "archetype",
493
+ title: "Your archetype",
494
+ subtitle: wrapped.archetype,
495
+ metrics: [
496
+ {
497
+ label: "Downloads",
498
+ value: fmt.format(wrapped.activity.totalDownloads),
499
+ },
500
+ {
501
+ label: "Likes",
502
+ value: fmt.format(wrapped.activity.totalLikes),
503
+ },
504
+ ],
505
+ highlights: sanitizeHighlights(
506
+ archetypeHighlights[wrapped.archetype as ArchetypeKey] ??
507
+ (wrapped.activity.topTags ?? []).slice(0, 3),
508
+ ),
509
+ },
510
+ {
511
+ id: "badges",
512
+ kind: "badges",
513
+ title: "Your badge this year",
514
+ subtitle: badge,
515
+ metrics: buildBadgeMetrics(badge, wrapped, fmt),
516
+ highlights: [badgeReason(badge)],
517
+ },
518
+ {
519
+ id: "share",
520
+ kind: "share",
521
+ title: `@${truncateHandle(wrapped.profile.handle)}`,
522
+ subtitle: `Your Hugging Face 🤗 in ${wrapped.year} `,
523
+ metrics: [
524
+ {
525
+ label: "Badge",
526
+ value: badge,
527
+ },
528
+ {
529
+ label: "Archetype",
530
+ value: wrapped.archetype,
531
+ },
532
+ {
533
+ label:
534
+ wrapped.activity.papers.length >
535
+ Math.max(
536
+ wrapped.activity.models.length,
537
+ wrapped.activity.datasets.length,
538
+ wrapped.activity.spaces.length,
539
+ )
540
+ ? "Papers"
541
+ : "Repos",
542
+ value:
543
+ wrapped.activity.papers.length >
544
+ Math.max(
545
+ wrapped.activity.models.length,
546
+ wrapped.activity.datasets.length,
547
+ wrapped.activity.spaces.length,
548
+ )
549
+ ? fmt.format(wrapped.activity.papers.length)
550
+ : fmt.format(wrapped.activity.totalRepos),
551
+ },
552
+ {
553
+ label: "Downloads",
554
+ value: fmt.format(wrapped.activity.totalDownloads),
555
+ },
556
+ {
557
+ label: "Likes",
558
+ value: fmt.format(wrapped.activity.totalLikes),
559
+ },
560
+ {
561
+ label: "Top model",
562
+ value:
563
+ wrapped.activity.models[0]?.name ??
564
+ "No model yet — create one!",
565
+ },
566
+ {
567
+ label: "Top dataset",
568
+ value:
569
+ wrapped.activity.datasets[0]?.name ??
570
+ "No dataset yet — publish one!",
571
+ },
572
+ {
573
+ label: "Top space",
574
+ value:
575
+ wrapped.activity.spaces[0]?.name ??
576
+ "No space yet — launch one!",
577
+ },
578
+ ],
579
+ highlights: [
580
+ ...(wrapped.activity.topTags.slice(0, 1) ?? []),
581
+ "huggingface.co/spaces/hf-wrapped/2025",
582
+ ],
583
+ },
584
+ ];
585
+ }
586
+
587
+ export function StoryScroller({ wrapped }: { wrapped: WrappedResult }) {
588
+ const slides = useMemo<StorySlide[]>(() => buildSlides(wrapped), [wrapped]);
589
+ const panelsRef = useRef<Map<string, HTMLElement>>(new Map());
590
+ const [isDownloading, setIsDownloading] = useState(false);
591
+ const [isMobile, setIsMobile] = useState(false);
592
+ const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
593
+ const scrollerRef = useRef<HTMLDivElement | null>(null);
594
+ const progressRef = useRef<HTMLDivElement | null>(null);
595
+ const currentIndexRef = useRef(0);
596
+ const badgeRef = useRef(pickBadge(wrapped.activity));
597
+ const [scale, setScale] = useState(1);
598
+ const scaleRef = useRef<HTMLDivElement | null>(null);
599
+ const reduceMotion = prefersReducedMotion || isMobile;
600
+ const disableAnim = isDownloading || reduceMotion;
601
+
602
+ useEffect(() => {
603
+ const updateMobile = () => {
604
+ if (typeof window !== "undefined") {
605
+ setIsMobile(window.innerWidth <= 768);
606
+ }
607
+ };
608
+ updateMobile();
609
+ window.addEventListener("resize", updateMobile);
610
+ return () => window.removeEventListener("resize", updateMobile);
611
+ }, []);
612
+
613
+ useEffect(() => {
614
+ if (typeof window === "undefined") {
615
+ return;
616
+ }
617
+ const mql = window.matchMedia("(prefers-reduced-motion: reduce)");
618
+ setPrefersReducedMotion(mql.matches);
619
+ const handler = (event: MediaQueryListEvent) => setPrefersReducedMotion(event.matches);
620
+ mql.addEventListener("change", handler);
621
+ return () => mql.removeEventListener("change", handler);
622
+ }, []);
623
+
624
+ useGSAP(() => {
625
+ if (disableAnim) {
626
+ return;
627
+ }
628
+ gsap.registerPlugin(ScrollTrigger);
629
+ const scroller = scrollerRef.current;
630
+
631
+ // lock page scroll; route all scroll to the stories container
632
+ const prevHtmlOverflow =
633
+ typeof document !== "undefined"
634
+ ? document.documentElement.style.overflow
635
+ : "";
636
+ const prevBodyOverflow =
637
+ typeof document !== "undefined" ? document.body.style.overflow : "";
638
+ if (typeof document !== "undefined") {
639
+ document.documentElement.style.overflow = "hidden";
640
+ document.body.style.overflow = "hidden";
641
+ }
642
+
643
+ ScrollTrigger.defaults({
644
+ scroller: scroller ?? undefined,
645
+ });
646
+
647
+ return () => {
648
+ ScrollTrigger.defaults({
649
+ scroller: undefined,
650
+ });
651
+ if (typeof document !== "undefined") {
652
+ document.documentElement.style.overflow = prevHtmlOverflow;
653
+ document.body.style.overflow = prevBodyOverflow;
654
+ }
655
+ };
656
+ }, []);
657
+
658
+ useEffect(() => {
659
+ const scroller = scrollerRef.current;
660
+ if (!scroller) {
661
+ return;
662
+ }
663
+ const handleScroll = () => {
664
+ const max = scroller.scrollHeight - scroller.clientHeight;
665
+ const ratio = max > 0 ? scroller.scrollTop / max : 0;
666
+ if (progressRef.current) {
667
+ progressRef.current.style.height = `${
668
+ Math.min(1, Math.max(0, ratio)) * 100
669
+ }%`;
670
+ }
671
+ const pct = ratio * 100;
672
+ const idx = segments.findIndex(
673
+ (slot) => pct >= slot.start && pct < slot.end,
674
+ );
675
+ currentIndexRef.current = idx >= 0 ? idx : 0;
676
+ };
677
+ scroller.addEventListener("scroll", handleScroll, { passive: true });
678
+ handleScroll();
679
+ return () => {
680
+ scroller.removeEventListener("scroll", handleScroll);
681
+ };
682
+ }, []);
683
+
684
+ const recomputeScale = useCallback(() => {
685
+ if (typeof window === "undefined") {
686
+ return;
687
+ }
688
+ const availableWidth = window.innerWidth - BASE_PADDING * 2;
689
+ const availableHeight = window.innerHeight - BASE_PADDING * 2;
690
+ const ratio = Math.min(
691
+ availableWidth / BASE_SIZE,
692
+ availableHeight / BASE_SIZE,
693
+ );
694
+ // keep the layout rigid and only scale down when space is limited
695
+ const next = Math.min(1, ratio);
696
+ setScale(Number.isFinite(next) && next > 0 ? next : 1);
697
+ }, []);
698
+
699
+ useEffect(() => {
700
+ recomputeScale();
701
+ window.addEventListener("resize", recomputeScale);
702
+ return () => {
703
+ window.removeEventListener("resize", recomputeScale);
704
+ };
705
+ }, [recomputeScale]);
706
+
707
+ const downloadSlide = useCallback(async () => {
708
+ const slideId = slides[currentIndexRef.current]?.id;
709
+ const target =
710
+ (slideId ? panelsRef.current.get(slideId) : undefined) ??
711
+ panelsRef.current.values().next().value;
712
+ if (!target) {
713
+ return;
714
+ }
715
+
716
+ setIsDownloading(true);
717
+ try {
718
+ const { toPng } = await import("html-to-image");
719
+ const prev = {
720
+ transform: target.style.transform,
721
+ opacity: target.style.opacity,
722
+ filter: target.style.filter,
723
+ transition: target.style.transition,
724
+ borderRadius: target.style.borderRadius,
725
+ backgroundColor: target.style.backgroundColor,
726
+ };
727
+ const offsetX = "-482px";
728
+ const offsetY = "-40px";
729
+ target.style.transform = `translate(${offsetX}, ${offsetY})`;
730
+ target.style.opacity = "1";
731
+ target.style.filter = "none";
732
+ target.style.transition = "none";
733
+ target.style.borderRadius = "0px";
734
+ target.style.backgroundColor = "#000";
735
+ const dataUrl = await toPng(target, {
736
+ cacheBust: true,
737
+ pixelRatio: reduceMotion ? 1.5 : 2,
738
+ backgroundColor: "#000",
739
+ style: {
740
+ transform: `translate(${offsetX}, ${offsetY})`,
741
+ opacity: "1",
742
+ filter: "none",
743
+ transformOrigin: "top left",
744
+ borderRadius: "0px",
745
+ backgroundColor: "#000",
746
+ },
747
+ });
748
+ target.style.transform = prev.transform;
749
+ target.style.opacity = prev.opacity;
750
+ target.style.filter = prev.filter;
751
+ target.style.transition = prev.transition;
752
+ target.style.borderRadius = prev.borderRadius;
753
+ target.style.backgroundColor = prev.backgroundColor;
754
+
755
+ const link = document.createElement("a");
756
+ link.download = `${wrapped.profile.handle}-story.png`;
757
+ link.href = dataUrl;
758
+ link.click();
759
+ } catch (error) {
760
+ console.error("Failed to export story", error);
761
+ } finally {
762
+ setIsDownloading(false);
763
+ }
764
+ }, [wrapped.profile.handle]);
765
+
766
+ const segments = useMemo(
767
+ () =>
768
+ getTimeline({
769
+ start: 0,
770
+ end: 100,
771
+ overlap: 0.18,
772
+ chunks: Math.max(slides.length, 1),
773
+ }),
774
+ [slides.length],
775
+ );
776
+
777
+ return (
778
+ <div
779
+ className="relative mx-auto grid min-h-screen w-full place-items-center overflow-hidden pb-10"
780
+ style={{
781
+ fontFamily: "BasementGrotesque, var(--font-sans, sans-serif)",
782
+ }}
783
+ >
784
+ <div
785
+ ref={scrollerRef}
786
+ className="sticky top-0 h-screen overflow-x-hidden overflow-y-auto overscroll-contain"
787
+ style={{
788
+ scrollbarWidth: "none",
789
+ touchAction: "auto",
790
+ scrollBehavior: reduceMotion ? "auto" : "smooth",
791
+ }}
792
+ >
793
+ <Scrollytelling.Root defaults={{ ease: "power1.out" }}>
794
+ <Scrollytelling.Pin
795
+ childHeight="100vh"
796
+ pinSpacerHeight={`${slides.length * 110}vh`}
797
+ pinSpacerClassName="rounded-3xl"
798
+ >
799
+ <div className="flex h-full w-full items-center justify-center">
800
+ <div
801
+ ref={scaleRef}
802
+ className="relative"
803
+ style={{
804
+ width: `${BASE_SIZE}px`,
805
+ height: `${BASE_SIZE}px`,
806
+ transform: `scale(${scale})`,
807
+ transformOrigin: "center center",
808
+ padding: `${BASE_PADDING}px`,
809
+ }}
810
+ >
811
+ {!reduceMotion && (
812
+ <div className="pointer-events-none absolute -right-3 top-1/2 z-20 flex -translate-y-1/2 items-center justify-center text-white/80">
813
+ <div className="relative h-10 w-6 rounded-full border border-white/50">
814
+ <span className="absolute left-1/2 top-2 h-2 w-1 -translate-x-1/2 rounded-full bg-white/80 animate-bounce" />
815
+ </div>
816
+ </div>
817
+ )}
818
+
819
+ <div className="absolute left-8 top-8 z-20 flex flex-wrap items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-white/80">
820
+ <span className="rounded-full bg-white/10 px-3 py-1">
821
+ {wrapped.profile.displayName ??
822
+ `@${wrapped.profile.handle}`}
823
+ </span>
824
+ <span className="rounded-full bg-white/10 px-3 py-1">
825
+ Hugging Face Wrapped {wrapped.year}
826
+ </span>
827
+ </div>
828
+
829
+ <div className="absolute right-8 top-8 z-20 flex gap-2">
830
+ <Button
831
+ size="sm"
832
+ variant="secondary"
833
+ className="border border-white/30 bg-white/10 text-white shadow-sm transition hover:bg-white/20"
834
+ onClick={downloadSlide}
835
+ disabled={isDownloading}
836
+ >
837
+ <DownloadIcon className="mr-2 size-4" />
838
+ {isDownloading
839
+ ? "Exporting…"
840
+ : "Export PNG"}
841
+ </Button>
842
+ <Button
843
+ size="sm"
844
+ variant="secondary"
845
+ className="border border-white/30 bg-white/10 text-white shadow-sm transition hover:bg-white/20"
846
+ onClick={async () => {
847
+ const url =
848
+ typeof window !== "undefined"
849
+ ? window.location.href
850
+ : "";
851
+ if (navigator.share && url) {
852
+ await navigator
853
+ .share({ url })
854
+ .catch(() => {});
855
+ } else if (
856
+ navigator.clipboard &&
857
+ url
858
+ ) {
859
+ await navigator.clipboard
860
+ .writeText(url)
861
+ .catch(() => {});
862
+ }
863
+ }}
864
+ >
865
+ <ShareIcon className="mr-2 size-4" />
866
+ Share
867
+ </Button>
868
+ </div>
869
+
870
+ <div className="relative flex h-full w-full items-center justify-center px-8 py-8">
871
+ <div className="relative mx-auto flex aspect-square w-[98%] max-w-[1200px] items-center justify-center overflow-hidden rounded-3xl border border-white/10 bg-black/35 shadow-[0_18px_55px_-35px_rgba(59,130,246,0.35)] backdrop-blur-sm">
872
+ <div className="pointer-events-none absolute -left-8 inset-y-0 z-30 w-1 rounded-full bg-white/10">
873
+ <div
874
+ ref={progressRef}
875
+ className="absolute left-0 top-0 w-full rounded-full bg-white"
876
+ />
877
+ </div>
878
+
879
+ {slides.map((slide, index) => {
880
+ const slot = segments[index] ?? {
881
+ start: 0,
882
+ end: 100,
883
+ };
884
+ const mid =
885
+ slot.start +
886
+ (slot.end - slot.start) * 0.55;
887
+ const tail =
888
+ slot.start +
889
+ (slot.end - slot.start) * 0.9;
890
+ const palette =
891
+ palettes[slide.kind] ??
892
+ palettes.intro;
893
+ const archetypeKey =
894
+ (wrapped.archetype as ArchetypeKey) in
895
+ archetypeAccents
896
+ ? (wrapped.archetype as ArchetypeKey)
897
+ : ("HF Explorer" as ArchetypeKey);
898
+ const imageSrc = imageForSlide(
899
+ slide,
900
+ wrapped,
901
+ badgeRef.current,
902
+ );
903
+ const mediaLeft = index % 2 === 1;
904
+ const isShare =
905
+ slide.kind === "share";
906
+ const accentFill = `bg-gradient-to-br ${
907
+ archetypeAccents[
908
+ archetypeKey
909
+ ] ?? palette.accent
910
+ }`;
911
+ const hasAccentRail = false;
912
+ const isSingleColumn =
913
+ slide.kind === "archetype" ||
914
+ slide.kind === "badges";
915
+ const isBadge =
916
+ slide.kind === "badges";
917
+ const isArchetype =
918
+ slide.kind === "archetype";
919
+ const shareLink =
920
+ isShare &&
921
+ (slide.highlights ?? []).find(
922
+ (h) =>
923
+ h.includes(
924
+ "huggingface.co/spaces/hf-wrapped/2025",
925
+ ),
926
+ );
927
+ const filteredHighlights = shareLink
928
+ ? (
929
+ slide.highlights ?? []
930
+ ).filter(
931
+ (h) => h !== shareLink,
932
+ )
933
+ : slide.highlights;
934
+
935
+ return (
936
+ <Scrollytelling.Animation
937
+ // eslint-disable-next-line react/no-array-index-key
938
+ key={slide.id + index}
939
+ tween={[
940
+ {
941
+ start: slot.start,
942
+ end: mid,
943
+ fromTo: [
944
+ {
945
+ opacity: 0,
946
+ y: 80,
947
+ scale: 0.97,
948
+ },
949
+ {
950
+ opacity: 1,
951
+ y: 0,
952
+ scale: 1,
953
+ },
954
+ ],
955
+ },
956
+ {
957
+ start: mid,
958
+ end: tail,
959
+ to: {
960
+ opacity: 0,
961
+ y: -60,
962
+ scale: 0.985,
963
+ },
964
+ },
965
+ ]}
966
+ >
967
+ <article
968
+ ref={(node) => {
969
+ if (node) {
970
+ panelsRef.current.set(
971
+ slide.id,
972
+ node,
973
+ );
974
+ }
975
+ }}
976
+ className={cn(
977
+ "absolute left-1/2 top-10 grid aspect-square w-[94%] max-w-[1140px] -translate-x-1/2 items-start overflow-hidden rounded-[30px] border border-white/12 bg-black/35 p-9 shadow-[0_28px_95px_-55px_rgba(59,130,246,0.5)] backdrop-blur-xl ring-1 ring-white/5",
978
+ isSingleColumn
979
+ ? "grid-cols-1 gap-6"
980
+ : "grid-cols-[1.05fr,0.95fr] grid-rows-[auto,1fr] gap-4",
981
+ mediaLeft &&
982
+ !isSingleColumn
983
+ ? "[&>div:nth-child(2)]:order-1 [&>div:nth-child(1)]:order-2"
984
+ : "",
985
+ )}
986
+ style={{
987
+ willChange:
988
+ "transform, opacity",
989
+ backgroundImage:
990
+ palette.gradient,
991
+ }}
992
+ >
993
+ {hasAccentRail ? (
994
+ <span
995
+ className={cn(
996
+ "absolute inset-y-0 left-0 w-[6px] rounded-full opacity-90",
997
+ accentFill,
998
+ )}
999
+ />
1000
+ ) : null}
1001
+ <div className="absolute left-8 right-8 top-8 z-10 flex items-center justify-between gap-3 text-xs uppercase tracking-[0.18em] text-white/70">
1002
+ <div className="flex items-center gap-2">
1003
+ <span className="rounded-full bg-white/15 px-3 py-1 text-white/80">
1004
+ {slide.kind}
1005
+ </span>
1006
+ {isShare &&
1007
+ shareLink ? (
1008
+ <span className="rounded-full bg-white/15 px-3 py-1 text-[11px] font-medium uppercase tracking-[0.18em] text-white/80">
1009
+ {
1010
+ shareLink
1011
+ }
1012
+ </span>
1013
+ ) : null}
1014
+ </div>
1015
+ <span className="rounded-full bg-white/15 px-3 py-1 text-white/80">
1016
+ {index + 1} /{" "}
1017
+ {slides.length}
1018
+ </span>
1019
+ </div>
1020
+
1021
+ {slide.kind ===
1022
+ "intro" &&
1023
+ wrapped.profile
1024
+ .avatarUrl ? (
1025
+ <div className="flex items-center gap-3 rounded-2xl border border-white/15 bg-black/30 px-4 py-3">
1026
+ <Image
1027
+ src={
1028
+ wrapped
1029
+ .profile
1030
+ .avatarUrl
1031
+ }
1032
+ alt={
1033
+ wrapped
1034
+ .profile
1035
+ .handle
1036
+ }
1037
+ width={48}
1038
+ height={48}
1039
+ className="size-12 rounded-full border border-white/20 object-cover"
1040
+ />
1041
+ <div className="flex flex-col text-sm">
1042
+ <span className="font-semibold text-white">
1043
+ {wrapped
1044
+ .profile
1045
+ .displayName ??
1046
+ wrapped
1047
+ .profile
1048
+ .handle}
1049
+ </span>
1050
+ <span className="text-white/70">
1051
+ @
1052
+ {
1053
+ wrapped
1054
+ .profile
1055
+ .handle
1056
+ }
1057
+ </span>
1058
+ </div>
1059
+ </div>
1060
+ ) : null}
1061
+
1062
+ {isShare ? (
1063
+ <header className="col-span-1 space-y-4 px-2 pt-10 w-full overflow-hidden">
1064
+ {disableAnim ? (
1065
+ <h2 className="block w-full truncate whitespace-nowrap text-5xl font-semibold leading-tight text-white mt-8">
1066
+ {
1067
+ slide.title
1068
+ }
1069
+ </h2>
1070
+ ) : (
1071
+ <Scrollytelling.Animation
1072
+ tween={{
1073
+ start: slot.start,
1074
+ end: mid,
1075
+ fromTo: [
1076
+ {
1077
+ opacity: 0,
1078
+ y: 40,
1079
+ },
1080
+ {
1081
+ opacity: 1,
1082
+ y: 0,
1083
+ },
1084
+ ],
1085
+ }}
1086
+ >
1087
+ <h2 className="block w-full truncate whitespace-nowrap text-5xl font-semibold leading-tight text-white mt-8">
1088
+ {
1089
+ slide.title
1090
+ }
1091
+ </h2>
1092
+ </Scrollytelling.Animation>
1093
+ )}
1094
+ {slide.subtitle ? (
1095
+ disableAnim ? (
1096
+ <p className="wrap-break-word font-semibold text-white/85 text-[50px] sm:text-[58px]">
1097
+ {
1098
+ slide.subtitle
1099
+ }
1100
+ </p>
1101
+ ) : (
1102
+ <Scrollytelling.Animation
1103
+ tween={{
1104
+ start:
1105
+ slot.start +
1106
+ (mid -
1107
+ slot.start) *
1108
+ 0.2,
1109
+ end: mid,
1110
+ fromTo: [
1111
+ {
1112
+ opacity: 0,
1113
+ y: 30,
1114
+ },
1115
+ {
1116
+ opacity: 1,
1117
+ y: 0,
1118
+ },
1119
+ ],
1120
+ }}
1121
+ >
1122
+ <p
1123
+ className={cn(
1124
+ "wrap-break-word font-semibold text-white/85",
1125
+ isShare
1126
+ ? "text-[50px] sm:text-[58px]"
1127
+ : "text-[50px]",
1128
+ )}
1129
+ >
1130
+ {
1131
+ slide.subtitle
1132
+ }
1133
+ </p>
1134
+ </Scrollytelling.Animation>
1135
+ )
1136
+ ) : null}
1137
+ </header>
1138
+ ) : null}
1139
+
1140
+ {isSingleColumn ? (
1141
+ <div className="grid h-full w-full grid-cols-1 place-items-center gap-5 pt-8 text-center">
1142
+ <div className="flex h-full w-full max-w-[880px] flex-col items-center justify-center gap-5 px-4">
1143
+ <header className="space-y-4">
1144
+ <Scrollytelling.Animation
1145
+ tween={{
1146
+ start: slot.start,
1147
+ end: mid,
1148
+ fromTo: [
1149
+ {
1150
+ opacity: 0,
1151
+ y: 32,
1152
+ },
1153
+ {
1154
+ opacity: 1,
1155
+ y: 0,
1156
+ },
1157
+ ],
1158
+ }}
1159
+ >
1160
+ <h2 className="text-balance text-5xl font-bold leading-tight sm:text-6xl">
1161
+ {
1162
+ slide.title
1163
+ }
1164
+ </h2>
1165
+ </Scrollytelling.Animation>
1166
+ {slide.subtitle ? (
1167
+ <Scrollytelling.Animation
1168
+ tween={{
1169
+ start:
1170
+ slot.start +
1171
+ (mid -
1172
+ slot.start) *
1173
+ 0.2,
1174
+ end: mid,
1175
+ fromTo: [
1176
+ {
1177
+ opacity: 0,
1178
+ y: 28,
1179
+ },
1180
+ {
1181
+ opacity: 1,
1182
+ y: 0,
1183
+ },
1184
+ ],
1185
+ }}
1186
+ >
1187
+ <p
1188
+ className={cn(
1189
+ "text-white/80",
1190
+ isBadge ||
1191
+ isArchetype
1192
+ ? "text-3xl font-semibold leading-tight"
1193
+ : "text-xl",
1194
+ )}
1195
+ >
1196
+ {
1197
+ slide.subtitle
1198
+ }
1199
+ </p>
1200
+ </Scrollytelling.Animation>
1201
+ ) : null}
1202
+ </header>
1203
+
1204
+ {imageSrc?.src && (
1205
+ <div className="flex w-full items-center justify-center">
1206
+ <Image
1207
+ src={
1208
+ imageSrc.src
1209
+ }
1210
+ alt={`${slide.kind} visual`}
1211
+ width={
1212
+ 560
1213
+ }
1214
+ height={
1215
+ 560
1216
+ }
1217
+ unoptimized={
1218
+ imageSrc.isGif
1219
+ }
1220
+ className="max-h-[460px] max-w-[460px] object-contain"
1221
+ />
1222
+ </div>
1223
+ )}
1224
+
1225
+ {slide.metrics &&
1226
+ slide
1227
+ .metrics
1228
+ .length >
1229
+ 0 && (
1230
+ <div className="flex w-full flex-col items-center gap-4">
1231
+ {[
1232
+ "models",
1233
+ "datasets",
1234
+ "spaces",
1235
+ "papers",
1236
+ ].includes(
1237
+ slide.kind,
1238
+ ) ? (
1239
+ <ul className="flex w-full max-w-[760px] flex-col gap-4">
1240
+ {slide.metrics.map(
1241
+ (
1242
+ metric,
1243
+ idx,
1244
+ ) => (
1245
+ <li
1246
+ key={`${slide.id}-${metric.label}`}
1247
+ className="group relative flex items-center justify-between gap-4 overflow-hidden rounded-2xl border border-white/12 bg-white/5 px-6 py-5 shadow-lg shadow-black/25 backdrop-blur"
1248
+ data-story-metric
1249
+ >
1250
+ <div className="flex min-w-0 flex-col gap-2 text-left">
1251
+ <p className="truncate text-base uppercase tracking-wide text-white/85">
1252
+ {
1253
+ metric.label
1254
+ }
1255
+ </p>
1256
+ <p className="text-3xl font-semibold text-white">
1257
+ {
1258
+ metric.value
1259
+ }
1260
+ </p>
1261
+ </div>
1262
+ <span className="rounded-full border border-white/20 bg-white/10 px-3.5 py-2 text-[12px] uppercase tracking-wide text-white/80">
1263
+ #
1264
+ {idx +
1265
+ 1}
1266
+ </span>
1267
+ </li>
1268
+ ),
1269
+ )}
1270
+ </ul>
1271
+ ) : (
1272
+ <ul className="grid w-full max-w-[780px] grid-cols-2 gap-4">
1273
+ {slide.metrics.map(
1274
+ (
1275
+ metric,
1276
+ ) => (
1277
+ <li
1278
+ key={`${slide.id}-${metric.label}`}
1279
+ className="group relative overflow-hidden rounded-2xl border border-white/12 bg-white/5 px-6 py-5 shadow-lg shadow-black/25 backdrop-blur"
1280
+ data-story-metric
1281
+ >
1282
+ <p className="truncate text-base uppercase tracking-wide text-white/85">
1283
+ {
1284
+ metric.label
1285
+ }
1286
+ </p>
1287
+ <p className="text-3xl font-semibold text-white">
1288
+ {
1289
+ metric.value
1290
+ }
1291
+ </p>
1292
+ </li>
1293
+ ),
1294
+ )}
1295
+ </ul>
1296
+ )}
1297
+ </div>
1298
+ )}
1299
+
1300
+ {filteredHighlights &&
1301
+ filteredHighlights.filter(
1302
+ (
1303
+ item,
1304
+ ) => {
1305
+ const banned =
1306
+ [
1307
+ "gradio",
1308
+ "en",
1309
+ "region:us",
1310
+ "region:eu",
1311
+ "demo",
1312
+ ];
1313
+ return !banned.includes(
1314
+ item.toLowerCase(),
1315
+ );
1316
+ },
1317
+ )
1318
+ .length >
1319
+ 0 && (
1320
+ <div className="flex flex-wrap justify-center gap-2">
1321
+ {filteredHighlights
1322
+ .filter(
1323
+ (
1324
+ item,
1325
+ ) => {
1326
+ const banned =
1327
+ [
1328
+ "gradio",
1329
+ "en",
1330
+ "region:us",
1331
+ "region:eu",
1332
+ "demo",
1333
+ ];
1334
+ return !banned.includes(
1335
+ item.toLowerCase(),
1336
+ );
1337
+ },
1338
+ )
1339
+ .map(
1340
+ (
1341
+ item,
1342
+ ) => (
1343
+ <span
1344
+ key={
1345
+ item
1346
+ }
1347
+ className={cn(
1348
+ item.includes(
1349
+ "huggingface.co/spaces/hf-wrapped/2025",
1350
+ )
1351
+ ? "truncate bg-transparent px-0 py-0 text-white/90"
1352
+ : "truncate rounded-full border border-white/20 bg-white/5 shadow-sm shadow-black/20 transition hover:-translate-y-[1px] hover:border-white/35 hover:bg-white/10",
1353
+ !item.includes(
1354
+ "huggingface.co/spaces/hf-wrapped/2025",
1355
+ ) &&
1356
+ (isShare
1357
+ ? "px-9 py-3.5 text-2xl font-semibold text-white"
1358
+ : "px-4 py-1.5 text-sm font-semibold text-white/90"),
1359
+ )}
1360
+ data-story-badge
1361
+ >
1362
+ {
1363
+ item
1364
+ }
1365
+ </span>
1366
+ ),
1367
+ )}
1368
+ </div>
1369
+ )}
1370
+
1371
+ {imageSrc?.src && (
1372
+ <div className="flex w-full items-center justify-center">
1373
+ <Image
1374
+ src={
1375
+ imageSrc.src
1376
+ }
1377
+ alt={`${slide.kind} visual`}
1378
+ width={
1379
+ 520
1380
+ }
1381
+ height={
1382
+ 520
1383
+ }
1384
+ unoptimized={
1385
+ imageSrc.isGif
1386
+ }
1387
+ className="max-h-[420px] max-w-[420px] object-contain"
1388
+ />
1389
+ </div>
1390
+ )}
1391
+ </div>
1392
+ </div>
1393
+ ) : (
1394
+ <div
1395
+ className={cn(
1396
+ "grid h-full w-full grid-cols-2 gap-4",
1397
+ isShare
1398
+ ? "pt-6"
1399
+ : "pt-10",
1400
+ )}
1401
+ >
1402
+ <div
1403
+ className={cn(
1404
+ "flex flex-col gap-5",
1405
+ isShare
1406
+ ? "px-1"
1407
+ : "",
1408
+ )}
1409
+ >
1410
+ {!isShare ? (
1411
+ <header className="space-y-4">
1412
+ {disableAnim ? (
1413
+ <h2 className="text-balance text-5xl font-bold leading-tight sm:text-6xl">
1414
+ {
1415
+ slide.title
1416
+ }
1417
+ </h2>
1418
+ ) : (
1419
+ <Scrollytelling.Animation
1420
+ tween={{
1421
+ start: slot.start,
1422
+ end: mid,
1423
+ fromTo: [
1424
+ {
1425
+ opacity: 0,
1426
+ y: 32,
1427
+ },
1428
+ {
1429
+ opacity: 1,
1430
+ y: 0,
1431
+ },
1432
+ ],
1433
+ }}
1434
+ >
1435
+ <h2 className="text-balance text-5xl font-bold leading-tight sm:text-6xl">
1436
+ {
1437
+ slide.title
1438
+ }
1439
+ </h2>
1440
+ </Scrollytelling.Animation>
1441
+ )}
1442
+ {slide.subtitle ? (
1443
+ disableAnim ? (
1444
+ <p
1445
+ className={cn(
1446
+ "text-white/80",
1447
+ isShare
1448
+ ? "text-2xl sm:text-3xl"
1449
+ : "text-xl",
1450
+ )}
1451
+ >
1452
+ {
1453
+ slide.subtitle
1454
+ }
1455
+ </p>
1456
+ ) : (
1457
+ <Scrollytelling.Animation
1458
+ tween={{
1459
+ start:
1460
+ slot.start +
1461
+ (mid -
1462
+ slot.start) *
1463
+ 0.2,
1464
+ end: mid,
1465
+ fromTo: [
1466
+ {
1467
+ opacity: 0,
1468
+ y: 28,
1469
+ },
1470
+ {
1471
+ opacity: 1,
1472
+ y: 0,
1473
+ },
1474
+ ],
1475
+ }}
1476
+ >
1477
+ <p
1478
+ className={cn(
1479
+ "text-white/80",
1480
+ isShare
1481
+ ? "text-2xl sm:text-3xl"
1482
+ : "text-xl",
1483
+ )}
1484
+ >
1485
+ {
1486
+ slide.subtitle
1487
+ }
1488
+ </p>
1489
+ </Scrollytelling.Animation>
1490
+ )
1491
+ ) : null}
1492
+ </header>
1493
+ ) : null}
1494
+
1495
+ {slide.metrics &&
1496
+ slide
1497
+ .metrics
1498
+ .length >
1499
+ 0 && (
1500
+ <div
1501
+ className={cn(
1502
+ "space-y-3",
1503
+ isShare
1504
+ ? "col-span-1"
1505
+ : "",
1506
+ )}
1507
+ >
1508
+ {[
1509
+ "models",
1510
+ "datasets",
1511
+ "spaces",
1512
+ "papers",
1513
+ ].includes(
1514
+ slide.kind,
1515
+ ) ? (
1516
+ <ul className="flex flex-col gap-4">
1517
+ {slide.metrics.map(
1518
+ (
1519
+ metric,
1520
+ idx,
1521
+ ) => (
1522
+ <li
1523
+ key={`${slide.id}-${metric.label}`}
1524
+ className="group relative flex items-center justify-between gap-4 overflow-hidden rounded-2xl border border-white/12 bg-white/5 px-6 py-5 shadow-lg shadow-black/25 backdrop-blur"
1525
+ data-story-metric
1526
+ >
1527
+ <div className="flex min-w-0 flex-col gap-1">
1528
+ <p className="truncate text-base font-semibold text-white">
1529
+ {
1530
+ metric.label
1531
+ }
1532
+ </p>
1533
+ <p className="text-sm text-white/65">
1534
+ {
1535
+ metric.value
1536
+ }
1537
+ </p>
1538
+ </div>
1539
+ <span className="rounded-full border border-white/20 bg-white/10 px-3.5 py-2 text-[12px] uppercase tracking-wide text-white/80">
1540
+ #
1541
+ {idx +
1542
+ 1}
1543
+ </span>
1544
+ </li>
1545
+ ),
1546
+ )}
1547
+ </ul>
1548
+ ) : isShare ? (
1549
+ <div className="space-y-3">
1550
+ <div className="grid grid-cols-1 gap-3">
1551
+ {(
1552
+ slide.metrics ??
1553
+ []
1554
+ )
1555
+ .filter(
1556
+ (
1557
+ m,
1558
+ ) =>
1559
+ m.label ===
1560
+ "Badge",
1561
+ )
1562
+ .map(
1563
+ (
1564
+ metric,
1565
+ ) => (
1566
+ <div
1567
+ key={
1568
+ metric.label
1569
+ }
1570
+ className="rounded-2xl border border-white/12 bg-white/5 px-6 py-5 shadow-inner shadow-black/10"
1571
+ >
1572
+ <p className="text-base uppercase tracking-wide text-white/80">
1573
+ {
1574
+ metric.label
1575
+ }
1576
+ </p>
1577
+ <p className="truncate whitespace-nowrap text-2xl font-semibold leading-tight text-white">
1578
+ {
1579
+ metric.value
1580
+ }
1581
+ </p>
1582
+ </div>
1583
+ ),
1584
+ )}
1585
+ </div>
1586
+ <div className="grid grid-cols-3 gap-3">
1587
+ {((
1588
+ slide.metrics ??
1589
+ []
1590
+ ).some(
1591
+ (
1592
+ m,
1593
+ ) =>
1594
+ m.label ===
1595
+ "Papers",
1596
+ )
1597
+ ? [
1598
+ "Papers",
1599
+ "Downloads",
1600
+ "Likes",
1601
+ ]
1602
+ : [
1603
+ "Repos",
1604
+ "Downloads",
1605
+ "Likes",
1606
+ ]
1607
+ ).map(
1608
+ (
1609
+ label,
1610
+ ) => {
1611
+ const metric =
1612
+ (
1613
+ slide.metrics ??
1614
+ []
1615
+ ).find(
1616
+ (
1617
+ m,
1618
+ ) =>
1619
+ m.label ===
1620
+ label,
1621
+ );
1622
+ if (
1623
+ !metric
1624
+ ) {
1625
+ return null;
1626
+ }
1627
+ return (
1628
+ <div
1629
+ key={
1630
+ metric.label
1631
+ }
1632
+ className="rounded-2xl border border-white/12 bg-white/5 px-6 py-5 shadow-inner shadow-black/10"
1633
+ >
1634
+ <p className="truncate text-sm uppercase tracking-wide text-white/75">
1635
+ {
1636
+ metric.label
1637
+ }
1638
+ </p>
1639
+ <p className="truncate whitespace-nowrap text-3xl font-semibold text-white">
1640
+ {ellipsize(
1641
+ metric.value,
1642
+ 40,
1643
+ )}
1644
+ </p>
1645
+ </div>
1646
+ );
1647
+ },
1648
+ )}
1649
+ </div>
1650
+ <div className="space-y-3">
1651
+ {[
1652
+ "Top dataset",
1653
+ "Top model",
1654
+ "Top space",
1655
+ ].map(
1656
+ (
1657
+ label,
1658
+ ) => {
1659
+ const metric =
1660
+ (
1661
+ slide.metrics ??
1662
+ []
1663
+ ).find(
1664
+ (
1665
+ m,
1666
+ ) =>
1667
+ m.label ===
1668
+ label,
1669
+ );
1670
+ if (
1671
+ !metric
1672
+ ) {
1673
+ return null;
1674
+ }
1675
+ return (
1676
+ <div
1677
+ key={
1678
+ metric.label
1679
+ }
1680
+ className="rounded-2xl border border-white/12 bg-white/5 px-6 py-5 shadow-inner shadow-black/10"
1681
+ >
1682
+ <p className="truncate text-sm uppercase tracking-wide text-white/75">
1683
+ {
1684
+ metric.label
1685
+ }
1686
+ </p>
1687
+ <p className="truncate whitespace-nowrap text-2xl font-semibold text-white">
1688
+ {ellipsize(
1689
+ metric.value,
1690
+ 40,
1691
+ )}
1692
+ </p>
1693
+ </div>
1694
+ );
1695
+ },
1696
+ )}
1697
+ </div>
1698
+ </div>
1699
+ ) : (
1700
+ <ul className="grid grid-cols-2 gap-4">
1701
+ {slide.metrics.map(
1702
+ (
1703
+ metric,
1704
+ ) => (
1705
+ <li
1706
+ key={`${slide.id}-${metric.label}`}
1707
+ className="group relative overflow-hidden rounded-2xl border border-white/12 bg-white/5 px-6 py-5 shadow-lg shadow-black/25 backdrop-blur"
1708
+ data-story-metric
1709
+ >
1710
+ <p className="truncate text-sm uppercase tracking-wide text-white/70">
1711
+ {
1712
+ metric.label
1713
+ }
1714
+ </p>
1715
+ <p className="text-2xl font-semibold text-white">
1716
+ {
1717
+ metric.value
1718
+ }
1719
+ </p>
1720
+ </li>
1721
+ ),
1722
+ )}
1723
+ </ul>
1724
+ )}
1725
+ </div>
1726
+ )}
1727
+
1728
+ {filteredHighlights &&
1729
+ filteredHighlights.filter(
1730
+ (
1731
+ item,
1732
+ ) => {
1733
+ const banned =
1734
+ [
1735
+ "gradio",
1736
+ "en",
1737
+ "region:us",
1738
+ "region:eu",
1739
+ "demo",
1740
+ ];
1741
+ return !banned.includes(
1742
+ item.toLowerCase(),
1743
+ );
1744
+ },
1745
+ )
1746
+ .length >
1747
+ 0 && (
1748
+ <div className="col-span-2 flex flex-wrap gap-2">
1749
+ {filteredHighlights
1750
+ .filter(
1751
+ (
1752
+ item,
1753
+ ) => {
1754
+ const banned =
1755
+ [
1756
+ "gradio",
1757
+ "en",
1758
+ "region:us",
1759
+ "region:eu",
1760
+ "demo",
1761
+ ];
1762
+ return !banned.includes(
1763
+ item.toLowerCase(),
1764
+ );
1765
+ },
1766
+ )
1767
+ .map(
1768
+ (
1769
+ item,
1770
+ ) => (
1771
+ <span
1772
+ key={
1773
+ item
1774
+ }
1775
+ className={cn(
1776
+ item.includes(
1777
+ "huggingface.co/spaces/hf-wrapped/2025",
1778
+ )
1779
+ ? "truncate bg-transparent px-3 py-0 text-white/90"
1780
+ : "truncate rounded-full border border-white/20 bg-white/5 shadow-sm shadow-black/20 transition hover:-translate-y-[1px] hover:border-white/35 hover:bg-white/10",
1781
+ !item.includes(
1782
+ "huggingface.co/spaces/hf-wrapped/2025",
1783
+ ) &&
1784
+ (isShare
1785
+ ? "px-2 py-3.5 text-1xl font-semibold text-white"
1786
+ : "px-4 py-1.5 text-sm font-semibold text-white/90"),
1787
+ )}
1788
+ data-story-badge
1789
+ >
1790
+ {
1791
+ item
1792
+ }
1793
+ </span>
1794
+ ),
1795
+ )}
1796
+ </div>
1797
+ )}
1798
+ </div>
1799
+
1800
+ <div
1801
+ className={cn(
1802
+ "relative flex h-full w-full items-center justify-center",
1803
+ isShare
1804
+ ? "col-span-1 flex-col items-start justify-start gap-4 px-2"
1805
+ : mediaLeft
1806
+ ? "order-1"
1807
+ : "order-2",
1808
+ )}
1809
+ >
1810
+ {isShare ? (
1811
+ <div className="flex w-full max-w-full flex-col gap-4">
1812
+ <div className="w-full max-w-full overflow-hidden rounded-2xl border border-white/12 bg-white/5 px-5 py-4 text-left text-white shadow-inner shadow-black/10">
1813
+ <p className="text-sm uppercase tracking-wide text-white/70">
1814
+ Archetype
1815
+ </p>
1816
+ <p className="truncate whitespace-nowrap text-3xl font-semibold leading-tight text-white">
1817
+ {
1818
+ (
1819
+ slide.metrics ??
1820
+ []
1821
+ ).find(
1822
+ (
1823
+ m,
1824
+ ) =>
1825
+ m.label ===
1826
+ "Archetype",
1827
+ )
1828
+ ?.value
1829
+ }
1830
+ </p>
1831
+ </div>
1832
+ <div className="flex min-h-[420px] w-full max-w-full items-center justify-center overflow-hidden rounded-2xl border border-transparent bg-transparent px-6 py-6">
1833
+ {imageSrc?.src ? (
1834
+ <Image
1835
+ src={
1836
+ imageSrc.src
1837
+ }
1838
+ alt={`${slide.kind} visual`}
1839
+ width={
1840
+ 520
1841
+ }
1842
+ height={
1843
+ 520
1844
+ }
1845
+ unoptimized={
1846
+ imageSrc.isGif
1847
+ }
1848
+ className="h-auto w-full max-w-[440px] object-contain"
1849
+ />
1850
+ ) : (
1851
+ <span className="text-sm text-white/60">
1852
+ Image
1853
+ unavailable
1854
+ </span>
1855
+ )}
1856
+ </div>
1857
+ </div>
1858
+ ) : (
1859
+ imageSrc?.src && (
1860
+ <Image
1861
+ src={
1862
+ imageSrc.src
1863
+ }
1864
+ alt={`${slide.kind} visual`}
1865
+ width={
1866
+ 520
1867
+ }
1868
+ height={
1869
+ 520
1870
+ }
1871
+ unoptimized={
1872
+ imageSrc.isGif
1873
+ }
1874
+ className="max-h-[420px] max-w-[420px] object-contain"
1875
+ />
1876
+ )
1877
+ )}
1878
+ </div>
1879
+ </div>
1880
+ )}
1881
+ </article>
1882
+ </Scrollytelling.Animation>
1883
+ );
1884
+ })}
1885
+ </div>
1886
+ </div>
1887
+ </div>
1888
+ </div>
1889
+ </Scrollytelling.Pin>
1890
+ </Scrollytelling.Root>
1891
+ </div>
1892
+ </div>
1893
+ );
1894
+ }
apps/web/modules/marketing/shared/components/Footer.tsx ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { config } from "@repo/config";
2
+ import { Logo } from "@shared/components/Logo";
3
+
4
+ export function Footer() {
5
+ return (
6
+ <footer className="border-t py-4 text-foreground/60 text-xs">
7
+ <div className="container flex flex-wrap items-center justify-between gap-3">
8
+ <div className="flex items-center gap-3">
9
+ <Logo className="h-6 w-auto opacity-80 grayscale" />
10
+ <span>
11
+ © {new Date().getFullYear()} {config.appName}. Built for
12
+ the Hugging Face community.
13
+ </span>
14
+ </div>
15
+ </div>
16
+ </footer>
17
+ );
18
+ }
apps/web/modules/marketing/shared/components/NavBar.tsx ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { LocaleLink, useLocalePathname } from "@i18n/routing";
4
+ import { config } from "@repo/config";
5
+ import { ColorModeToggle } from "@shared/components/ColorModeToggle";
6
+ import { LocaleSwitch } from "@shared/components/LocaleSwitch";
7
+ import { Logo } from "@shared/components/Logo";
8
+ import { Button } from "@ui/components/button";
9
+ import {
10
+ Sheet,
11
+ SheetContent,
12
+ SheetTitle,
13
+ SheetTrigger,
14
+ } from "@ui/components/sheet";
15
+ import { cn } from "@ui/lib";
16
+ import { MenuIcon } from "lucide-react";
17
+ import { Suspense, useEffect, useState } from "react";
18
+ import { useDebounceCallback } from "usehooks-ts";
19
+
20
+ export function NavBar() {
21
+ const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
22
+ const localePathname = useLocalePathname();
23
+ const [isTop, setIsTop] = useState(true);
24
+
25
+ const handleMobileMenuClose = () => {
26
+ setMobileMenuOpen(false);
27
+ };
28
+
29
+ const debouncedScrollHandler = useDebounceCallback(
30
+ () => {
31
+ setIsTop(window.scrollY <= 10);
32
+ },
33
+ 150,
34
+ {
35
+ maxWait: 150,
36
+ },
37
+ );
38
+
39
+ useEffect(() => {
40
+ window.addEventListener("scroll", debouncedScrollHandler);
41
+ debouncedScrollHandler();
42
+ return () => {
43
+ window.removeEventListener("scroll", debouncedScrollHandler);
44
+ };
45
+ }, [debouncedScrollHandler]);
46
+
47
+ useEffect(() => {
48
+ handleMobileMenuClose();
49
+ }, [localePathname]);
50
+
51
+ const menuItems: {
52
+ label: string;
53
+ href: string;
54
+ }[] = [
55
+ {
56
+ label: "Wrapped",
57
+ href: "/#generator",
58
+ },
59
+ ];
60
+
61
+ const isMenuItemActive = (href: string) => localePathname.startsWith(href);
62
+
63
+ return (
64
+ <nav
65
+ className={cn(
66
+ "fixed top-0 left-0 z-50 w-full transition-shadow duration-200",
67
+ !isTop
68
+ ? "bg-card/80 shadow-sm backdrop-blur-lg"
69
+ : "shadow-none",
70
+ )}
71
+ data-test="navigation"
72
+ >
73
+ <div className="container">
74
+ <div
75
+ className={cn(
76
+ "flex items-center justify-stretch gap-6 transition-[padding] duration-200",
77
+ !isTop ? "py-4" : "py-6",
78
+ )}
79
+ >
80
+ <div className="flex flex-1 justify-start">
81
+ <LocaleLink
82
+ href="/"
83
+ className="block hover:no-underline active:no-underline"
84
+ >
85
+ <Logo />
86
+ </LocaleLink>
87
+ </div>
88
+
89
+ <div className="hidden flex-1 items-center justify-center lg:flex">
90
+ {menuItems.map((menuItem) => (
91
+ <LocaleLink
92
+ key={menuItem.href}
93
+ href={menuItem.href}
94
+ className={cn(
95
+ "block px-3 py-2 font-medium text-foreground/80 text-sm",
96
+ isMenuItemActive(menuItem.href)
97
+ ? "font-bold text-foreground"
98
+ : "",
99
+ )}
100
+ prefetch
101
+ >
102
+ {menuItem.label}
103
+ </LocaleLink>
104
+ ))}
105
+ </div>
106
+
107
+ <div className="flex flex-1 items-center justify-end gap-3">
108
+ <ColorModeToggle />
109
+ {config.i18n.enabled && (
110
+ <Suspense>
111
+ <LocaleSwitch />
112
+ </Suspense>
113
+ )}
114
+
115
+ <Sheet
116
+ open={mobileMenuOpen}
117
+ onOpenChange={(open) => setMobileMenuOpen(open)}
118
+ >
119
+ <SheetTrigger asChild>
120
+ <Button
121
+ className="lg:hidden"
122
+ size="icon"
123
+ variant="light"
124
+ aria-label="Menu"
125
+ >
126
+ <MenuIcon className="size-4" />
127
+ </Button>
128
+ </SheetTrigger>
129
+ <SheetContent className="w-[280px]" side="right">
130
+ <SheetTitle />
131
+ <div className="flex flex-col items-start justify-center">
132
+ {menuItems.map((menuItem) => (
133
+ <LocaleLink
134
+ key={menuItem.href}
135
+ href={menuItem.href}
136
+ onClick={handleMobileMenuClose}
137
+ className={cn(
138
+ "block px-3 py-2 font-medium text-base text-foreground/80",
139
+ isMenuItemActive(menuItem.href)
140
+ ? "font-bold text-foreground"
141
+ : "",
142
+ )}
143
+ prefetch
144
+ >
145
+ {menuItem.label}
146
+ </LocaleLink>
147
+ ))}
148
+ </div>
149
+ </SheetContent>
150
+ </Sheet>
151
+ </div>
152
+ </div>
153
+ </div>
154
+ </nav>
155
+ );
156
+ }
apps/web/modules/marketing/shared/components/NotFound.tsx ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { LocaleLink } from "@i18n/routing";
4
+ import { Button } from "@ui/components/button";
5
+ import { UndoIcon } from "lucide-react";
6
+
7
+ export function NotFound() {
8
+ return (
9
+ <div className="flex h-screen flex-col items-center justify-center">
10
+ <h1 className="font-bold text-5xl">404</h1>
11
+ <p className="mt-2 text-2xl">Page not found</p>
12
+
13
+ <Button asChild className="mt-4">
14
+ <LocaleLink href="/">
15
+ <UndoIcon className="mr-2 size-4" /> Go to homepage
16
+ </LocaleLink>
17
+ </Button>
18
+ </div>
19
+ );
20
+ }
apps/web/modules/shared/components/ClientProviders.tsx ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { ProgressProvider } from "@bprogress/next/app";
4
+ import { config } from "@repo/config";
5
+ import { Toaster } from "@ui/components/toast";
6
+ import { ThemeProvider } from "next-themes";
7
+ import type { PropsWithChildren } from "react";
8
+
9
+ export function ClientProviders({ children }: PropsWithChildren) {
10
+ return (
11
+ <ProgressProvider
12
+ height="4px"
13
+ color="var(--color-primary)"
14
+ options={{ showSpinner: false }}
15
+ shallowRouting
16
+ delay={250}
17
+ >
18
+ <ThemeProvider
19
+ attribute="class"
20
+ disableTransitionOnChange
21
+ enableSystem
22
+ defaultTheme={config.ui.defaultTheme}
23
+ themes={config.ui.enabledThemes}
24
+ >
25
+ {children}
26
+
27
+ <Toaster position="top-right" />
28
+ </ThemeProvider>
29
+ </ProgressProvider>
30
+ );
31
+ }
apps/web/modules/shared/components/ColorModeToggle.tsx ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { Button } from "@ui/components/button";
4
+ import {
5
+ DropdownMenu,
6
+ DropdownMenuContent,
7
+ DropdownMenuRadioGroup,
8
+ DropdownMenuRadioItem,
9
+ DropdownMenuTrigger,
10
+ } from "@ui/components/dropdown-menu";
11
+ import { HardDriveIcon, MoonIcon, SunIcon } from "lucide-react";
12
+ import { useTheme } from "next-themes";
13
+ import { useState } from "react";
14
+ import { useIsClient } from "usehooks-ts";
15
+
16
+ export function ColorModeToggle() {
17
+ const { resolvedTheme, setTheme, theme } = useTheme();
18
+ const [value, setValue] = useState<string>(theme ?? "system");
19
+ const isClient = useIsClient();
20
+
21
+ const colorModeOptions = [
22
+ {
23
+ value: "system",
24
+ label: "System",
25
+ icon: HardDriveIcon,
26
+ },
27
+ {
28
+ value: "light",
29
+ label: "Light",
30
+ icon: SunIcon,
31
+ },
32
+ {
33
+ value: "dark",
34
+ label: "Dark",
35
+ icon: MoonIcon,
36
+ },
37
+ ];
38
+
39
+ if (!isClient) {
40
+ return null;
41
+ }
42
+
43
+ return (
44
+ <DropdownMenu modal={false}>
45
+ <DropdownMenuTrigger asChild>
46
+ <Button
47
+ variant="ghost"
48
+ size="icon"
49
+ data-test="color-mode-toggle"
50
+ aria-label="Color mode"
51
+ >
52
+ {resolvedTheme === "light" ? (
53
+ <SunIcon className="size-4" />
54
+ ) : (
55
+ <MoonIcon className="size-4" />
56
+ )}
57
+ </Button>
58
+ </DropdownMenuTrigger>
59
+
60
+ <DropdownMenuContent>
61
+ <DropdownMenuRadioGroup
62
+ value={value}
63
+ onValueChange={(value) => {
64
+ setTheme(value);
65
+ setValue(value);
66
+ }}
67
+ >
68
+ {colorModeOptions.map((option) => (
69
+ <DropdownMenuRadioItem
70
+ key={option.value}
71
+ value={option.value}
72
+ data-test={`color-mode-toggle-item-${option.value}`}
73
+ >
74
+ <option.icon className="mr-2 size-4 opacity-50" />{" "}
75
+ {option.label}
76
+ </DropdownMenuRadioItem>
77
+ ))}
78
+ </DropdownMenuRadioGroup>
79
+ </DropdownMenuContent>
80
+ </DropdownMenu>
81
+ );
82
+ }
apps/web/modules/shared/components/Document.tsx ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ClientProviders } from "@shared/components/ClientProviders";
2
+ import { cn } from "@ui/lib";
3
+ import { Geist } from "next/font/google";
4
+ import { NuqsAdapter } from "nuqs/adapters/next/app";
5
+ import type { PropsWithChildren } from "react";
6
+
7
+ const sansFont = Geist({
8
+ weight: ["400", "500", "600", "700"],
9
+ subsets: ["latin"],
10
+ variable: "--font-sans",
11
+ });
12
+
13
+ export async function Document({
14
+ children,
15
+ locale,
16
+ }: PropsWithChildren<{ locale: string }>) {
17
+ return (
18
+ <html
19
+ lang={locale}
20
+ suppressHydrationWarning
21
+ className={sansFont.className}
22
+ >
23
+ <body
24
+ className={cn(
25
+ "min-h-screen bg-background text-foreground antialiased",
26
+ )}
27
+ >
28
+ <NuqsAdapter>
29
+ <ClientProviders>{children}</ClientProviders>
30
+ </NuqsAdapter>
31
+ </body>
32
+ </html>
33
+ );
34
+ }
apps/web/modules/shared/components/LocaleSwitch.tsx ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { updateLocale } from "@i18n/lib/update-locale";
4
+ import { useLocalePathname, useLocaleRouter } from "@i18n/routing";
5
+ import { config } from "@repo/config";
6
+ import type { Locale } from "@repo/i18n";
7
+ import { Button } from "@ui/components/button";
8
+ import {
9
+ DropdownMenu,
10
+ DropdownMenuContent,
11
+ DropdownMenuRadioGroup,
12
+ DropdownMenuRadioItem,
13
+ DropdownMenuTrigger,
14
+ } from "@ui/components/dropdown-menu";
15
+ import { LanguagesIcon } from "lucide-react";
16
+ import { useRouter, useSearchParams } from "next/navigation";
17
+ import { useLocale } from "next-intl";
18
+ import { useState } from "react";
19
+
20
+ const { locales } = config.i18n;
21
+
22
+ export function LocaleSwitch({
23
+ withLocaleInUrl = true,
24
+ }: {
25
+ withLocaleInUrl?: boolean;
26
+ }) {
27
+ const localeRouter = useLocaleRouter();
28
+ const localePathname = useLocalePathname();
29
+ const router = useRouter();
30
+ const searchParams = useSearchParams();
31
+ const currentLocale = useLocale();
32
+ const [value, setValue] = useState<string>(currentLocale);
33
+
34
+ return (
35
+ <DropdownMenu modal={false}>
36
+ <DropdownMenuTrigger asChild>
37
+ <Button variant="ghost" size="icon" aria-label="Language">
38
+ <LanguagesIcon className="size-4" />
39
+ </Button>
40
+ </DropdownMenuTrigger>
41
+
42
+ <DropdownMenuContent>
43
+ <DropdownMenuRadioGroup
44
+ value={value}
45
+ onValueChange={(value) => {
46
+ setValue(value);
47
+
48
+ if (withLocaleInUrl) {
49
+ localeRouter.replace(
50
+ `${localePathname}?${searchParams.toString()}`,
51
+ {
52
+ locale: value,
53
+ },
54
+ );
55
+ } else {
56
+ updateLocale(value as Locale);
57
+ router.refresh();
58
+ }
59
+ }}
60
+ >
61
+ {Object.entries(locales).map(([locale, { label }]) => {
62
+ return (
63
+ <DropdownMenuRadioItem key={locale} value={locale}>
64
+ {label}
65
+ </DropdownMenuRadioItem>
66
+ );
67
+ })}
68
+ </DropdownMenuRadioGroup>
69
+ </DropdownMenuContent>
70
+ </DropdownMenu>
71
+ );
72
+ }
apps/web/modules/shared/components/Logo.tsx ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { cn } from "@ui/lib";
2
+
3
+ export function Logo({
4
+ withLabel = true,
5
+ className,
6
+ }: {
7
+ className?: string;
8
+ withLabel?: boolean;
9
+ }) {
10
+ return (
11
+ <span
12
+ className={cn(
13
+ "flex items-center font-semibold text-foreground leading-none",
14
+ className,
15
+ )}
16
+ >
17
+ <svg className="size-10 text-primary" viewBox="0 0 734 635">
18
+ <title>acme</title>
19
+ <path
20
+ opacity="0.2"
21
+ d="M282.102 232.435C328.904 205.42 404.785 205.42 451.588 232.435L697.946 374.634C744.748 401.648 744.748 445.447 697.946 472.462L451.588 614.661C404.785 641.676 328.904 641.676 282.102 614.661L35.7432 472.462C-11.059 445.447 -11.0589 401.648 35.7432 374.634L282.102 232.435Z"
22
+ fill="currentColor"
23
+ />
24
+ <path
25
+ opacity="0.4"
26
+ d="M282.102 126.674C328.904 99.66 404.785 99.66 451.588 126.674L697.946 268.874C744.748 295.888 744.748 339.687 697.946 366.702L451.588 508.901C404.785 535.915 328.904 535.915 282.102 508.901L35.7432 366.702C-11.059 339.687 -11.0589 295.888 35.7432 268.874L282.102 126.674Z"
27
+ fill="currentColor"
28
+ />
29
+ <path
30
+ fillRule="evenodd"
31
+ clipRule="evenodd"
32
+ d="M451.588 20.9141C404.785 -6.10027 328.904 -6.1003 282.102 20.9141L35.7432 163.113C-11.0589 190.128 -11.059 233.927 35.7432 260.941L282.102 403.141C328.904 430.155 404.785 430.155 451.588 403.141L697.946 260.941C744.748 233.927 744.748 190.128 697.946 163.113L451.588 20.9141ZM497.704 114.921C499.134 115.855 500.121 117.04 500.545 118.332C505.138 132.238 505.138 143.12 505.072 154.003C505.072 198.349 468.453 225.167 420.48 245.161V290.25C420.485 294.097 418.849 297.868 415.755 301.141C412.662 304.413 408.233 307.058 402.967 308.777L337.739 330.105C335.32 330.893 332.634 331.263 329.935 331.181C327.236 331.1 324.613 330.569 322.316 329.64C320.019 328.71 318.124 327.412 316.809 325.87C315.495 324.327 314.806 322.591 314.806 320.825V275.982L299.957 285.686C297.993 286.969 295.661 287.987 293.095 288.682C290.529 289.377 287.779 289.734 285.001 289.734C282.223 289.734 279.473 289.377 276.907 288.682C274.341 287.987 272.009 286.969 270.045 285.686L236.407 263.7C232.442 261.109 230.214 257.594 230.214 253.93C230.214 250.265 232.442 246.751 236.407 244.159L251.257 234.456H182.678C179.975 234.457 177.316 234.006 174.955 233.147C172.593 232.288 170.607 231.049 169.184 229.547C167.761 228.046 166.949 226.331 166.825 224.567C166.701 222.803 167.269 221.047 168.475 219.466L201.136 176.8C203.771 173.364 207.817 170.475 212.82 168.455C217.823 166.435 223.587 165.364 229.468 165.36H298.331C328.857 133.922 369.765 110.084 437.967 110.084C454.555 110.084 471.202 110.084 492.483 113.064C494.46 113.341 496.273 113.986 497.704 114.921ZM405.86 179.723C410.207 181.621 415.318 182.634 420.546 182.634C427.557 182.634 434.281 180.814 439.239 177.575C444.196 174.335 446.981 169.942 446.981 165.36C446.981 161.944 445.431 158.604 442.526 155.763C439.622 152.923 435.493 150.709 430.663 149.401C425.832 148.094 420.517 147.752 415.389 148.418C410.261 149.085 405.551 150.73 401.854 153.146C398.157 155.562 395.639 158.64 394.619 161.99C393.599 165.341 394.123 168.814 396.124 171.971C398.124 175.127 401.513 177.825 405.86 179.723Z"
33
+ fill="currentColor"
34
+ />
35
+ </svg>
36
+ {withLabel && (
37
+ <span className="ml-3 hidden text-lg md:block">acme</span>
38
+ )}
39
+ </span>
40
+ );
41
+ }
apps/web/modules/shared/components/Spinner.tsx ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import { cn } from "@ui/lib";
2
+ import { Loader2Icon } from "lucide-react";
3
+
4
+ export function Spinner({ className }: { className?: string }) {
5
+ return (
6
+ <Loader2Icon
7
+ className={cn("size-4 animate-spin text-primary", className)}
8
+ />
9
+ );
10
+ }
apps/web/modules/ui/components/avatar.tsx ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import * as AvatarPrimitive from "@radix-ui/react-avatar";
4
+ import { cn } from "@ui/lib";
5
+ import * as React from "react";
6
+
7
+ const Avatar = ({
8
+ className,
9
+ ...props
10
+ }: React.ComponentProps<typeof AvatarPrimitive.Root>) => (
11
+ <AvatarPrimitive.Root
12
+ className={cn(
13
+ "relative flex h-8 w-8 shrink-0 overflow-hidden rounded-sm",
14
+ className,
15
+ )}
16
+ {...props}
17
+ />
18
+ );
19
+
20
+ const AvatarImage = ({
21
+ className,
22
+ ...props
23
+ }: React.ComponentProps<typeof AvatarPrimitive.Image>) => (
24
+ <AvatarPrimitive.Image
25
+ className={cn("aspect-square h-full w-full rounded-sm", className)}
26
+ {...props}
27
+ />
28
+ );
29
+
30
+ const AvatarFallback = ({
31
+ className,
32
+ ...props
33
+ }: React.ComponentProps<typeof AvatarPrimitive.Fallback>) => (
34
+ <AvatarPrimitive.Fallback
35
+ className={cn(
36
+ "flex h-full w-full items-center justify-center rounded-sm bg-muted font-bold text-xs",
37
+ className,
38
+ )}
39
+ {...props}
40
+ />
41
+ );
42
+
43
+ export { Avatar, AvatarFallback, AvatarImage };
apps/web/modules/ui/components/button.tsx ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Slot, Slottable } from "@radix-ui/react-slot";
2
+ import { Spinner } from "@shared/components/Spinner";
3
+ import { cn } from "@ui/lib";
4
+ import type { VariantProps } from "class-variance-authority";
5
+ import { cva } from "class-variance-authority";
6
+ import * as React from "react";
7
+
8
+ const buttonVariants = cva(
9
+ "flex items-center justify-center border font-medium enabled:cursor-pointer transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 [&>svg]:mr-1.5 [&>svg]:opacity-60 [&>svg+svg]:hidden",
10
+ {
11
+ variants: {
12
+ variant: {
13
+ primary:
14
+ "border-transparent bg-primary text-primary-foreground hover:bg-primary/90 shadow-sm shadow-primary/20",
15
+ error: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
16
+ outline:
17
+ "border-secondary/15 bg-transparent text-secondary hover:bg-secondary/10",
18
+ secondary:
19
+ "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/90",
20
+ light: "border-transparent bg-secondary/5 text-foreground hover:bg-secondary/10",
21
+ ghost: "border-transparent text-primary hover:bg-primary/10 hover:text-primary",
22
+ link: "border-transparent text-primary underline-offset-4 hover:underline",
23
+ },
24
+ size: {
25
+ md: "h-9 rounded-md px-4 text-sm",
26
+ sm: "h-8 rounded-md px-3 text-xs",
27
+ lg: "h-11 rounded-md px-6 text-base",
28
+ icon: "size-9 rounded-md [&>svg]:m-0 [&>svg]:opacity-100",
29
+ },
30
+ },
31
+ defaultVariants: {
32
+ variant: "secondary",
33
+ size: "md",
34
+ },
35
+ },
36
+ );
37
+
38
+ export type ButtonProps = {
39
+ asChild?: boolean;
40
+ loading?: boolean;
41
+ } & React.ButtonHTMLAttributes<HTMLButtonElement> &
42
+ VariantProps<typeof buttonVariants>;
43
+
44
+ const Button = ({
45
+ className,
46
+ children,
47
+ variant,
48
+ size,
49
+ asChild = false,
50
+ loading,
51
+ disabled,
52
+ ...props
53
+ }: ButtonProps) => {
54
+ const Comp = asChild ? Slot : "button";
55
+ return (
56
+ <Comp
57
+ className={cn(buttonVariants({ variant, size, className }))}
58
+ disabled={disabled || loading}
59
+ {...props}
60
+ >
61
+ {loading && <Spinner className="mr-1.5 size-4 text-inherit" />}
62
+ <Slottable>{children}</Slottable>
63
+ </Comp>
64
+ );
65
+ };
66
+
67
+ export { Button, buttonVariants };
apps/web/modules/ui/components/dropdown-menu.tsx ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
4
+ import { cn } from "@ui/lib";
5
+ import { CheckIcon, ChevronRightIcon } from "lucide-react";
6
+ import * as React from "react";
7
+
8
+ const DropdownMenu = DropdownMenuPrimitive.Root;
9
+
10
+ const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
11
+
12
+ const DropdownMenuGroup = DropdownMenuPrimitive.Group;
13
+
14
+ const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
15
+
16
+ const DropdownMenuSub = DropdownMenuPrimitive.Sub;
17
+
18
+ const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
19
+
20
+ const DropdownMenuSubTrigger = ({
21
+ className,
22
+ inset,
23
+ children,
24
+ ...props
25
+ }: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
26
+ inset?: boolean;
27
+ }) => (
28
+ <DropdownMenuPrimitive.SubTrigger
29
+ className={cn(
30
+ "flex cursor-default select-none items-center rounded-md px-3 py-1.5 text-sm outline-hidden focus:bg-accent data-[state=open]:bg-accent",
31
+ inset ? "pl-8" : "",
32
+ className,
33
+ )}
34
+ {...props}
35
+ >
36
+ {children}
37
+ <ChevronRightIcon className="ml-auto size-4" />
38
+ </DropdownMenuPrimitive.SubTrigger>
39
+ );
40
+
41
+ const DropdownMenuSubContent = ({
42
+ className,
43
+ ...props
44
+ }: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) => (
45
+ <DropdownMenuPrimitive.SubContent
46
+ className={cn(
47
+ "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=closed]:animate-out data-[state=open]:animate-in",
48
+ className,
49
+ )}
50
+ {...props}
51
+ />
52
+ );
53
+
54
+ const DropdownMenuContent = ({
55
+ className,
56
+ sideOffset = 4,
57
+ ...props
58
+ }: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) => (
59
+ <DropdownMenuPrimitive.Portal>
60
+ <DropdownMenuPrimitive.Content
61
+ sideOffset={sideOffset}
62
+ className={cn(
63
+ "z-50 min-w-[8rem] overflow-hidden rounded-lg border bg-popover p-1 text-popover-foreground shadow-lg",
64
+ "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=open]:animate-in",
65
+ className,
66
+ )}
67
+ {...props}
68
+ />
69
+ </DropdownMenuPrimitive.Portal>
70
+ );
71
+
72
+ const DropdownMenuItem = ({
73
+ className,
74
+ inset,
75
+ ...props
76
+ }: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
77
+ inset?: boolean;
78
+ }) => (
79
+ <DropdownMenuPrimitive.Item
80
+ className={cn(
81
+ "relative flex cursor-default select-none items-center rounded-md px-3 py-2 text-sm outline-hidden transition-colors focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50",
82
+ inset ? "pl-8" : "",
83
+ className,
84
+ )}
85
+ {...props}
86
+ />
87
+ );
88
+
89
+ const DropdownMenuCheckboxItem = ({
90
+ className,
91
+ children,
92
+ checked,
93
+ ...props
94
+ }: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) => (
95
+ <DropdownMenuPrimitive.CheckboxItem
96
+ className={cn(
97
+ "relative flex cursor-default select-none items-center rounded-md py-3 pr-3 pl-8 text-sm outline-hidden transition-colors focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50",
98
+ className,
99
+ )}
100
+ checked={checked}
101
+ {...props}
102
+ >
103
+ <span className="absolute left-2 flex size-3.5 items-center justify-center">
104
+ <DropdownMenuPrimitive.ItemIndicator>
105
+ <CheckIcon className="size-4" />
106
+ </DropdownMenuPrimitive.ItemIndicator>
107
+ </span>
108
+ {children}
109
+ </DropdownMenuPrimitive.CheckboxItem>
110
+ );
111
+
112
+ const DropdownMenuRadioItem = ({
113
+ className,
114
+ children,
115
+ ...props
116
+ }: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) => (
117
+ <DropdownMenuPrimitive.RadioItem
118
+ className={cn(
119
+ "relative flex cursor-default select-none items-center rounded-md py-2 pr-8 pl-3 text-sm outline-hidden transition-colors focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-[state=checked]:font-semibold data-disabled:opacity-50",
120
+ className,
121
+ )}
122
+ {...props}
123
+ >
124
+ <span className="absolute right-2 flex size-3.5 items-center justify-center">
125
+ <DropdownMenuPrimitive.ItemIndicator>
126
+ <CheckIcon className="size-4" />
127
+ </DropdownMenuPrimitive.ItemIndicator>
128
+ </span>
129
+ {children}
130
+ </DropdownMenuPrimitive.RadioItem>
131
+ );
132
+
133
+ const DropdownMenuLabel = ({
134
+ className,
135
+ inset,
136
+ ...props
137
+ }: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
138
+ inset?: boolean;
139
+ }) => (
140
+ <DropdownMenuPrimitive.Label
141
+ className={cn(
142
+ "px-3 py-2 font-semibold text-sm",
143
+ inset ? "pl-8" : "",
144
+ className,
145
+ )}
146
+ {...props}
147
+ />
148
+ );
149
+
150
+ const DropdownMenuSeparator = ({
151
+ className,
152
+ ...props
153
+ }: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) => (
154
+ <DropdownMenuPrimitive.Separator
155
+ className={cn("-mx-1 my-1 h-px bg-border", className)}
156
+ {...props}
157
+ />
158
+ );
159
+
160
+ const DropdownMenuShortcut = ({
161
+ className,
162
+ ...props
163
+ }: React.HTMLAttributes<HTMLSpanElement>) => {
164
+ return (
165
+ <span
166
+ className={cn(
167
+ "ml-auto text-xs tracking-widest opacity-60",
168
+ className,
169
+ )}
170
+ {...props}
171
+ />
172
+ );
173
+ };
174
+
175
+ export {
176
+ DropdownMenu,
177
+ DropdownMenuCheckboxItem,
178
+ DropdownMenuContent,
179
+ DropdownMenuGroup,
180
+ DropdownMenuItem,
181
+ DropdownMenuLabel,
182
+ DropdownMenuPortal,
183
+ DropdownMenuRadioGroup,
184
+ DropdownMenuRadioItem,
185
+ DropdownMenuSeparator,
186
+ DropdownMenuShortcut,
187
+ DropdownMenuSub,
188
+ DropdownMenuSubContent,
189
+ DropdownMenuSubTrigger,
190
+ DropdownMenuTrigger,
191
+ };
apps/web/modules/ui/components/input.tsx ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { cn } from "@ui/lib";
2
+ import React from "react";
3
+
4
+ export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
5
+
6
+ const Input = ({ className, type, ...props }: InputProps) => {
7
+ return (
8
+ <input
9
+ type={type}
10
+ className={cn(
11
+ "flex h-9 w-full rounded-md bg-card shadow-xs border border-input px-3 py-1 text-base transition-colors file:border-0 file:bg-transparent file:font-medium file:text-sm placeholder:text-foreground/60 focus-visible:outline-hidden focus-visible:ring-1 focus-visible:border-ring focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
12
+ className,
13
+ )}
14
+ {...props}
15
+ />
16
+ );
17
+ };
18
+
19
+ export { Input };
apps/web/modules/ui/components/sheet.tsx ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import * as SheetPrimitive from "@radix-ui/react-dialog";
4
+ import { cn } from "@ui/lib";
5
+ import type { VariantProps } from "class-variance-authority";
6
+ import { cva } from "class-variance-authority";
7
+ import { XIcon } from "lucide-react";
8
+ import * as React from "react";
9
+
10
+ const Sheet = SheetPrimitive.Root;
11
+
12
+ const SheetTrigger = SheetPrimitive.Trigger;
13
+
14
+ const SheetClose = SheetPrimitive.Close;
15
+
16
+ const SheetPortal = ({ ...props }: SheetPrimitive.DialogPortalProps) => (
17
+ <SheetPrimitive.Portal {...props} />
18
+ );
19
+
20
+ const SheetOverlay = ({
21
+ className,
22
+ ...props
23
+ }: React.ComponentProps<typeof SheetPrimitive.Overlay>) => (
24
+ <SheetPrimitive.Overlay
25
+ className={cn(
26
+ "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-background/80 backdrop-blur-xs data-[state=closed]:animate-out data-[state=open]:animate-in",
27
+ className,
28
+ )}
29
+ {...props}
30
+ />
31
+ );
32
+
33
+ const sheetVariants = cva(
34
+ "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
35
+ {
36
+ variants: {
37
+ side: {
38
+ top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
39
+ bottom: "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
40
+ left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
41
+ right: "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
42
+ },
43
+ },
44
+ defaultVariants: {
45
+ side: "right",
46
+ },
47
+ },
48
+ );
49
+
50
+ type SheetContentProps = {} & React.ComponentProps<
51
+ typeof SheetPrimitive.Content
52
+ > &
53
+ VariantProps<typeof sheetVariants>;
54
+
55
+ const SheetContent = ({
56
+ side = "right",
57
+ className,
58
+ children,
59
+ ...props
60
+ }: SheetContentProps) => (
61
+ <SheetPortal>
62
+ <SheetOverlay />
63
+ <SheetPrimitive.Content
64
+ className={sheetVariants({ side, className })}
65
+ {...props}
66
+ >
67
+ {children}
68
+ <SheetPrimitive.Close className="absolute top-4 right-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
69
+ <XIcon className="size-4" />
70
+ <span className="sr-only">Close</span>
71
+ </SheetPrimitive.Close>
72
+ </SheetPrimitive.Content>
73
+ </SheetPortal>
74
+ );
75
+
76
+ const SheetHeader = ({
77
+ className,
78
+ ...props
79
+ }: React.HTMLAttributes<HTMLDivElement>) => (
80
+ <div
81
+ className={cn(
82
+ "flex flex-col space-y-2 text-center sm:text-left",
83
+ className,
84
+ )}
85
+ {...props}
86
+ />
87
+ );
88
+
89
+ const SheetFooter = ({
90
+ className,
91
+ ...props
92
+ }: React.HTMLAttributes<HTMLDivElement>) => (
93
+ <div
94
+ className={cn(
95
+ "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
96
+ className,
97
+ )}
98
+ {...props}
99
+ />
100
+ );
101
+
102
+ const SheetTitle = ({
103
+ className,
104
+ ...props
105
+ }: React.ComponentProps<typeof SheetPrimitive.Title>) => (
106
+ <SheetPrimitive.Title
107
+ className={cn("font-semibold text-foreground text-lg", className)}
108
+ {...props}
109
+ />
110
+ );
111
+
112
+ const SheetDescription = ({
113
+ className,
114
+ ...props
115
+ }: React.ComponentProps<typeof SheetPrimitive.Description>) => (
116
+ <SheetPrimitive.Description
117
+ className={cn("text-muted-foreground text-sm", className)}
118
+ {...props}
119
+ />
120
+ );
121
+
122
+ export {
123
+ Sheet,
124
+ SheetClose,
125
+ SheetContent,
126
+ SheetDescription,
127
+ SheetFooter,
128
+ SheetHeader,
129
+ SheetTitle,
130
+ SheetTrigger,
131
+ };
apps/web/modules/ui/components/toast.tsx ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useTheme } from "next-themes";
4
+ import { Toaster as Sonner } from "sonner";
5
+
6
+ type ToasterProps = React.ComponentProps<typeof Sonner>;
7
+
8
+ const Toaster = ({ ...props }: ToasterProps) => {
9
+ const { theme = "system" } = useTheme();
10
+
11
+ return (
12
+ <Sonner
13
+ theme={theme as ToasterProps["theme"]}
14
+ className="toaster group"
15
+ toastOptions={{
16
+ classNames: {
17
+ toast: "group toast !rounded-lg !font-sans group-[.toaster]:bg-card group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-md",
18
+ description: "group-[.toast]:text-muted-foreground",
19
+ actionButton:
20
+ "group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
21
+ cancelButton:
22
+ "group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
23
+ success: "!text-success",
24
+ error: "!text-destructive",
25
+ },
26
+ duration: 5000,
27
+ }}
28
+ {...props}
29
+ />
30
+ );
31
+ };
32
+
33
+ export { Toaster };
apps/web/modules/ui/lib/index.ts ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ import type { ClassValue } from "clsx";
2
+ import { clsx } from "clsx";
3
+ import { twMerge } from "tailwind-merge";
4
+
5
+ export function cn(...inputs: ClassValue[]) {
6
+ return twMerge(clsx(inputs));
7
+ }
apps/web/next.config.ts ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { NextConfig } from "next";
2
+ import nextIntlPlugin from "next-intl/plugin";
3
+
4
+ const withNextIntl = nextIntlPlugin("./modules/i18n/request.ts");
5
+
6
+ const nextConfig: NextConfig = {
7
+ transpilePackages: [
8
+ "@repo/config",
9
+ "@repo/i18n",
10
+ "@repo/utils",
11
+ "@repo/wrapped",
12
+ ],
13
+ images: {
14
+ remotePatterns: [
15
+ {
16
+ protocol: "https",
17
+ hostname: "lh3.googleusercontent.com",
18
+ },
19
+ {
20
+ protocol: "https",
21
+ hostname: "avatars.githubusercontent.com",
22
+ },
23
+ ],
24
+ },
25
+ };
26
+
27
+ export default withNextIntl(nextConfig);