Spaces:
Running
Running
Commit
·
ad19202
0
Parent(s):
Initial commit
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .dockerignore +10 -0
- .editorconfig +9 -0
- .env.local.example +11 -0
- .gitattributes +6 -0
- .github/dependabot.yml +15 -0
- .github/workflows/validate-prs.yml +41 -0
- .gitignore +45 -0
- .npmrc +1 -0
- .vscode/extensions.json +7 -0
- .vscode/settings.json +31 -0
- Dockerfile +38 -0
- README.md +13 -0
- apps/web/.gitignore +39 -0
- apps/web/app/(marketing)/[locale]/(home)/page.tsx +25 -0
- apps/web/app/(marketing)/[locale]/[...rest]/page.tsx +5 -0
- apps/web/app/(marketing)/[locale]/layout.tsx +39 -0
- apps/web/app/(marketing)/[locale]/not-found.tsx +5 -0
- apps/web/app/api/wrapped/route.ts +76 -0
- apps/web/app/favicon.ico +3 -0
- apps/web/app/globals.css +67 -0
- apps/web/app/icon.png +3 -0
- apps/web/app/layout.tsx +16 -0
- apps/web/app/robots.ts +10 -0
- apps/web/app/sitemap.ts +15 -0
- apps/web/biome.json +20 -0
- apps/web/components.json +17 -0
- apps/web/global.d.ts +16 -0
- apps/web/modules/i18n/lib/locale-cookie.ts +14 -0
- apps/web/modules/i18n/lib/update-locale.ts +10 -0
- apps/web/modules/i18n/request.ts +22 -0
- apps/web/modules/i18n/routing.ts +20 -0
- apps/web/modules/marketing/home/components/Hero.tsx +267 -0
- apps/web/modules/marketing/home/components/StoryScroller.tsx +1894 -0
- apps/web/modules/marketing/shared/components/Footer.tsx +18 -0
- apps/web/modules/marketing/shared/components/NavBar.tsx +156 -0
- apps/web/modules/marketing/shared/components/NotFound.tsx +20 -0
- apps/web/modules/shared/components/ClientProviders.tsx +31 -0
- apps/web/modules/shared/components/ColorModeToggle.tsx +82 -0
- apps/web/modules/shared/components/Document.tsx +34 -0
- apps/web/modules/shared/components/LocaleSwitch.tsx +72 -0
- apps/web/modules/shared/components/Logo.tsx +41 -0
- apps/web/modules/shared/components/Spinner.tsx +10 -0
- apps/web/modules/ui/components/avatar.tsx +43 -0
- apps/web/modules/ui/components/button.tsx +67 -0
- apps/web/modules/ui/components/dropdown-menu.tsx +191 -0
- apps/web/modules/ui/components/input.tsx +19 -0
- apps/web/modules/ui/components/sheet.tsx +131 -0
- apps/web/modules/ui/components/toast.tsx +33 -0
- apps/web/modules/ui/lib/index.ts +7 -0
- 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
|
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
|
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);
|