From ee9876bd029c78e4015cda769ff9d34ad3a4304e Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Sat, 17 Oct 2026 18:01:53 +0200 Subject: [PATCH 1/4] feat: Add bento section for vitnode home page --- apps/web/src/locales/@vitnode/core/pl.json | 75 +- apps/web/src/locales/site/en.json | 78 ++ apps/web/src/locales/site/pl.json | 78 ++ apps/web/src/page-cache.gen.ts | 1 + apps/web/src/site/home/home-content.tsx | 24 +- .../src/site/home/illustrations/agent-map.tsx | 202 ---- .../site/home/illustrations/bento/ai-chip.tsx | 180 ++++ .../site/home/illustrations/bento/board.tsx | 237 +++++ .../site/home/illustrations/bento/co-type.tsx | 241 +++++ .../site/home/illustrations/bento/drop.tsx | 115 +++ .../site/home/illustrations/bento/scene.tsx | 94 ++ .../site/home/illustrations/bento/slab.tsx | 72 ++ .../site/home/illustrations/bento/suggest.tsx | 162 +++ .../home/illustrations/bento/use-loop-step.ts | 58 ++ .../home/illustrations/feature-visuals.tsx | 968 ------------------ .../web/src/site/home/illustrations/gates.tsx | 176 ---- apps/web/src/site/home/namespaces.ts | 6 +- apps/web/src/site/home/sections/agents.tsx | 87 -- apps/web/src/site/home/sections/bento.tsx | 68 ++ apps/web/src/site/home/sections/community.tsx | 111 -- .../web/src/site/home/sections/developers.tsx | 109 -- apps/web/src/site/home/sections/faq.tsx | 96 -- .../src/site/home/sections/features-bento.tsx | 159 --- apps/web/src/site/home/sections/final-cta.tsx | 39 - apps/web/src/site/home/sections/outcomes.tsx | 119 --- apps/web/src/site/home/sections/pricing.tsx | 114 --- apps/web/src/site/home/sections/security.tsx | 87 -- .../site/home/sections/showcase-carousel.tsx | 85 -- .../site/home/sections/showcase-slides.tsx | 29 - apps/web/src/site/home/sections/showcase.tsx | 100 -- apps/web/src/site/marketing/marketing.css | 745 ++------------ apps/web/src/site/marketing/safari-frame.tsx | 85 -- .../src/api/lib/updates/status.test.ts | 2 + .../vitnode/src/api/lib/updates/status.ts | 2 + .../admin/plugins/routes/updates.schema.ts | 1 + packages/vitnode/src/components/side-nav.tsx | 2 +- packages/vitnode/src/locales/en.json | 75 +- packages/vitnode/src/routes.tsx | 29 +- .../src/tanstack/admin/debug/pending.tsx | 412 ++++++-- .../src/tanstack/pending/guard-pending.tsx | 137 ++- .../vitnode/src/tanstack/pending/index.ts | 6 +- .../src/tanstack/plugin-routes/components.tsx | 10 +- packages/vitnode/src/tests/update-summary.ts | 1 + .../system-updates-widget.test.tsx | 8 +- .../system-updates/system-updates-widget.tsx | 154 ++- .../system-updates/update-status.test.ts | 1 + .../widgets/system-updates/update-status.ts | 5 + 47 files changed, 2183 insertions(+), 3462 deletions(-) delete mode 100644 apps/web/src/site/home/illustrations/agent-map.tsx create mode 100644 apps/web/src/site/home/illustrations/bento/ai-chip.tsx create mode 100644 apps/web/src/site/home/illustrations/bento/board.tsx create mode 100644 apps/web/src/site/home/illustrations/bento/co-type.tsx create mode 100644 apps/web/src/site/home/illustrations/bento/drop.tsx create mode 100644 apps/web/src/site/home/illustrations/bento/scene.tsx create mode 100644 apps/web/src/site/home/illustrations/bento/slab.tsx create mode 100644 apps/web/src/site/home/illustrations/bento/suggest.tsx create mode 100644 apps/web/src/site/home/illustrations/bento/use-loop-step.ts delete mode 100644 apps/web/src/site/home/illustrations/feature-visuals.tsx delete mode 100644 apps/web/src/site/home/illustrations/gates.tsx delete mode 100644 apps/web/src/site/home/sections/agents.tsx create mode 100644 apps/web/src/site/home/sections/bento.tsx delete mode 100644 apps/web/src/site/home/sections/community.tsx delete mode 100644 apps/web/src/site/home/sections/developers.tsx delete mode 100644 apps/web/src/site/home/sections/faq.tsx delete mode 100644 apps/web/src/site/home/sections/features-bento.tsx delete mode 100644 apps/web/src/site/home/sections/final-cta.tsx delete mode 100644 apps/web/src/site/home/sections/outcomes.tsx delete mode 100644 apps/web/src/site/home/sections/pricing.tsx delete mode 100644 apps/web/src/site/home/sections/security.tsx delete mode 100644 apps/web/src/site/home/sections/showcase-carousel.tsx delete mode 100644 apps/web/src/site/home/sections/showcase-slides.tsx delete mode 100644 apps/web/src/site/home/sections/showcase.tsx delete mode 100644 apps/web/src/site/marketing/safari-frame.tsx diff --git a/apps/web/src/locales/@vitnode/core/pl.json b/apps/web/src/locales/@vitnode/core/pl.json index 52103b7d13..a9f69bca43 100644 --- a/apps/web/src/locales/@vitnode/core/pl.json +++ b/apps/web/src/locales/@vitnode/core/pl.json @@ -223,32 +223,67 @@ "status_unsaved": "Niezapisane zmiany", "system-updates": { "title": "Aktualizacje systemu", - "desc": "Wersja rdzenia, dostępne aktualizacje i ostrzeżenia bezpieczeństwa z zapisanych sprawdzeń aktualizacji.", + "desc": "Nowsze wydania i poprawki bezpieczeństwa dla VitNode i Twoich wtyczek.", "rows": { - "status": "Stan", "core": "VitNode", "plugin_updates": "Aktualizacje wtyczek", - "security": "Bezpieczeństwo", - "last_checked": "Ostatnie sprawdzenie" + "security": "Poprawki bezpieczeństwa", + "no_info": "Brak informacji" }, - "status": { - "security": "Wymagana poprawka bezpieczeństwa", - "updates": "Dostępne aktualizacje", - "up_to_date": "Wszystko aktualne", - "unconfirmed": "{count, plural, one {# pakiet niepotwierdzony} few {# pakiety niepotwierdzone} other {# pakietów niepotwierdzonych}}", - "running": "Trwa sprawdzanie...", - "not_checked": "Jeszcze nie sprawdzono", - "unavailable": "Brak źródeł aktualizacji", - "disabled": "Sprawdzanie wyłączone" + "verdict": { + "security": { + "title": "Wymagana poprawka bezpieczeństwa", + "desc": "{count, plural, one {# pakiet ma znany problem bezpieczeństwa naprawiony w nowszym wydaniu. Zaktualizuj go jak najszybciej.} few {# pakiety mają znane problemy bezpieczeństwa naprawione w nowszym wydaniu. Zaktualizuj je jak najszybciej.} other {# pakietów ma znane problemy bezpieczeństwa naprawione w nowszym wydaniu. Zaktualizuj je jak najszybciej.}}" + }, + "updates": { + "title": "Dostępne aktualizacje", + "desc": "{count, plural, one {# nowsze wydanie czeka na instalację.} few {# nowsze wydania czekają na instalację.} other {# nowszych wydań czeka na instalację.}}" + }, + "up_to_date": { + "title": "Wszystko jest aktualne", + "desc": "{count, plural, one {Twój pakiet ma najnowsze wydanie.} other {Wszystkie pakiety ({count}) mają najnowsze wydania.}}" + }, + "unconfirmed": { + "title": "Nie znaleziono aktualizacji", + "desc": "Aktualne pakiety: {verified} z {total}. {count, plural, one {Pozostały nie publikuje informacji o aktualizacjach, więc VitNode nie może go sprawdzić.} other {Pozostałe ({count}) nie publikują informacji o aktualizacjach, więc VitNode nie może ich sprawdzić.}}" + }, + "no_info": { + "title": "Nie można sprawdzić aktualizacji", + "desc": "{count, plural, one {Twój pakiet nie publikuje informacji o aktualizacjach, więc VitNode nie wie, czy istnieje nowsza wersja.} other {Żaden z Twoich pakietów ({count}) nie publikuje informacji o aktualizacjach, więc VitNode nie wie, czy istnieją nowsze wersje.}}" + }, + "unavailable": { + "title": "Nie można sprawdzić aktualizacji", + "desc": "{count, plural, one {Twój pakiet nie publikuje informacji o aktualizacjach, więc VitNode nie wie, czy istnieje nowsza wersja.} other {Żaden z Twoich pakietów ({count}) nie publikuje informacji o aktualizacjach, więc VitNode nie wie, czy istnieją nowsze wersje.}}" + }, + "running": { + "title": "Trwa sprawdzanie aktualizacji...", + "desc": "Wyniki pojawią się tutaj po zakończeniu sprawdzania." + }, + "not_checked": { + "title": "Jeszcze nie sprawdzono", + "desc": "VitNode sprawdza aktualizacje raz dziennie. Aby sprawdzić teraz, otwórz Wtyczki." + }, + "disabled": { + "title": "Sprawdzanie aktualizacji jest wyłączone", + "desc": "Ta strona ustawia updates.enabled na false w konfiguracji API, więc nic nie jest sprawdzane." + } }, "core_update": "dostępna aktualizacja do {version}", - "security_affected": "{count, plural, one {# pakiet} few {# pakiety} other {# pakietów}}", - "security_none": "Brak", - "last_checked": { - "running": "W toku", - "failed": "Niepowodzenie ", - "stale": "Nieaktualne", - "never": "Nigdy" + "plugin_updates": { + "none": "Brak", + "available": "Dostępne: {count}" + }, + "security": { + "affected": "{count, plural, one {Potrzebna dla # pakietu} other {Potrzebna dla # pakietów}}", + "none": "Nie znaleziono" + }, + "no_info_count": "{total, plural, one {{count} z # pakietu} other {{count} z # pakietów}}", + "checked": { + "running": "Trwa sprawdzanie...", + "failed": "Ostatnie sprawdzenie nie powiodło się ", + "stale": "Sprawdzono , wymaga ponownego sprawdzenia", + "never": "Nigdy nie sprawdzono", + "at": "Sprawdzono " }, "unknown": "Nieznane", "load_error": "Nie udało się wczytać podsumowania aktualizacji. Strona Wtyczki nadal pokazuje wszystkie wyniki.", diff --git a/apps/web/src/locales/site/en.json b/apps/web/src/locales/site/en.json index c787132c1c..a0093188ef 100644 --- a/apps/web/src/locales/site/en.json +++ b/apps/web/src/locales/site/en.json @@ -227,6 +227,84 @@ "user": "User" } } + }, + "bento": { + "eyebrow": "Built in", + "title": "Everything a community needs.", + "content": { + "title": "One file, a whole feature", + "desc": "Tables, typed API and AdminCP screens from one definition.", + "features": { + "admin": "AdminCP screens", + "api": "Typed API", + "database": "Database table", + "publishing": "Drafts and publishing", + "revisions": "Revisions", + "scheduling": "Scheduling", + "translations": "Translations", + "search": "Site search", + "live": "Live editing", + "comments": "Comments", + "reactions": "Reactions", + "followers": "Followers" + } + }, + "notifications": { + "title": "News that finds its people", + "desc": "An inbox, a live count and email digests.", + "mark_all": "Mark all as read", + "items": { + "reply": { + "title": "Kuba replied to your topic", + "sub": "on “Summer meetup photos”" + }, + "live": { + "title": "Your article is live", + "sub": "Our summer meetup recap" + }, + "comment": { + "title": "Ola commented", + "sub": "“Count me in for next year!”" + }, + "report": { + "title": "A moderator closed your report", + "sub": "Report #128 · content hidden" + } + } + }, + "live": { + "title": "Write together in real time", + "desc": "Rich text takes several cursors at once, and every change saves itself.", + "saving": "Saving…", + "saved": "Saved", + "first": "Forty-two members met on the", + "selected": "rooftop", + "first_end": ".", + "ola": " The pizza ran out by eight.", + "second": "Next year we are booking", + "kuba": " a bigger venue and two more ovens." + }, + "ai": { + "title": "AI that does the chores", + "desc": "ALT text and translations, any provider, on a budget.", + "provider": "Any provider", + "alt": "ALT text", + "budget": "{percent}% of budget" + }, + "search": { + "title": "Found before you finish typing", + "desc": "Suggestions update on every key, ranked by what people read.", + "tab": "Tab", + "queries": "m|me|mem|me|mee|meet", + "suggestions": { + "recap": "Meetup recap 2026", + "photos": "Meetup photos", + "moderators": "Meet the moderators", + "rules": "Membership rules", + "events": "Mentoring evenings", + "library": "Media library tips" + } + } } }, "search": { diff --git a/apps/web/src/locales/site/pl.json b/apps/web/src/locales/site/pl.json index b7d3ffadb7..5e23403e94 100644 --- a/apps/web/src/locales/site/pl.json +++ b/apps/web/src/locales/site/pl.json @@ -227,6 +227,84 @@ "user": "Użytkownik" } } + }, + "bento": { + "eyebrow": "W zestawie", + "title": "Wszystko, czego potrzebuje społeczność.", + "content": { + "title": "Jeden plik, cała funkcja", + "desc": "Tabele, typowane API i ekrany AdminCP z jednej definicji.", + "features": { + "admin": "Ekrany AdminCP", + "api": "Typowane API", + "database": "Tabela w bazie", + "publishing": "Szkice i publikacja", + "revisions": "Rewizje", + "scheduling": "Harmonogram", + "translations": "Tłumaczenia", + "search": "Wyszukiwarka", + "live": "Wspólna edycja", + "comments": "Komentarze", + "reactions": "Reakcje", + "followers": "Obserwujący" + } + }, + "notifications": { + "title": "Wiadomości, które same trafiają do ludzi", + "desc": "Skrzynka, licznik na żywo i podsumowania e-mailem.", + "mark_all": "Oznacz wszystkie jako przeczytane", + "items": { + "reply": { + "title": "Kuba odpowiedział w Twoim temacie", + "sub": "w „Zdjęcia z letniego spotkania”" + }, + "live": { + "title": "Twój artykuł jest opublikowany", + "sub": "Podsumowanie letniego spotkania" + }, + "comment": { + "title": "Ola dodała komentarz", + "sub": "„Wpisz mnie na przyszły rok!”" + }, + "report": { + "title": "Moderator zamknął Twoje zgłoszenie", + "sub": "Zgłoszenie #128 · treść ukryta" + } + } + }, + "live": { + "title": "Piszcie razem w czasie rzeczywistym", + "desc": "Tekst sformatowany przyjmuje kilka kursorów naraz, a każda zmiana zapisuje się sama.", + "saving": "Zapisywanie…", + "saved": "Zapisano", + "first": "Czterdziestu dwóch członków spotkało się na", + "selected": "dachu", + "first_end": ".", + "ola": " Pizza skończyła się przed ósmą.", + "second": "W przyszłym roku rezerwujemy", + "kuba": " większą salę i dwa piece więcej." + }, + "ai": { + "title": "AI, które odwala nudną robotę", + "desc": "Teksty ALT i tłumaczenia, dowolny dostawca, w ramach budżetu.", + "provider": "Dowolny dostawca", + "alt": "Tekst ALT", + "budget": "{percent}% budżetu" + }, + "search": { + "title": "Znalezione, zanim skończysz pisać", + "desc": "Podpowiedzi zmieniają się z każdym klawiszem, ułożone według tego, co ludzie czytają.", + "tab": "Tab", + "queries": "s|sp|spr|sp|spo|spot", + "suggestions": { + "recap": "Spotkanie letnie 2026", + "photos": "Spotkanie — zdjęcia", + "moderators": "Spotkaj moderatorów", + "rules": "Społeczność — zasady", + "events": "Sport i rekreacja", + "library": "Sprzedam rower" + } + } } }, "search": { diff --git a/apps/web/src/page-cache.gen.ts b/apps/web/src/page-cache.gen.ts index 6ad5252a0f..05f0336fef 100644 --- a/apps/web/src/page-cache.gen.ts +++ b/apps/web/src/page-cache.gen.ts @@ -47,6 +47,7 @@ export const pageCacheRoutes: PageCacheRouteTable = [ { path: '/admin/core/users' }, { path: '/forum/create/topic' }, { path: '/forum/me/pending' }, + { path: '/admin/proto/system-updates' }, { path: '/forum/topics/:id/edit' }, { path: '/example/guide/:topic' }, { path: '/forum/comments/:slug' }, diff --git a/apps/web/src/site/home/home-content.tsx b/apps/web/src/site/home/home-content.tsx index f7759485c6..3e89768682 100644 --- a/apps/web/src/site/home/home-content.tsx +++ b/apps/web/src/site/home/home-content.tsx @@ -1,19 +1,9 @@ import { RouteMessages } from '@vitnode/core/tanstack/i18n' import { HOME_NAMESPACES } from '@/site/home/namespaces' -// import { AgentsSection } from '@/site/home/sections/agents' -// import { CommunitySection } from '@/site/home/sections/community' -// import { DevelopersSection } from '@/site/home/sections/developers' -// import { FaqSection } from '@/site/home/sections/faq' -// import { FeaturesBentoSection } from '@/site/home/sections/features-bento' -// import { FinalCtaSection } from '@/site/home/sections/final-cta' +import { BentoSection } from '@/site/home/sections/bento' import { HeroSection } from '@/site/home/sections/hero' import { IntegrationsSection } from '@/site/home/sections/integrations' -// import { OutcomesSection } from '@/site/home/sections/outcomes' -// import { PluginsSection } from '@/site/home/sections/plugins' -// import { PricingSection } from '@/site/home/sections/pricing' -// import { SecuritySection } from '@/site/home/sections/security' -// import { ShowcaseSection } from '@/site/home/sections/showcase' import { MarketingPage } from '@/site/marketing/marketing-page' import { CanaryNotice } from '@/site/marketing/shared' @@ -24,18 +14,8 @@ export const HomeRouteContent = () => (
- {/* - - - - - - - - - */} + - {/* */} ) diff --git a/apps/web/src/site/home/illustrations/agent-map.tsx b/apps/web/src/site/home/illustrations/agent-map.tsx deleted file mode 100644 index 3c84632251..0000000000 --- a/apps/web/src/site/home/illustrations/agent-map.tsx +++ /dev/null @@ -1,202 +0,0 @@ -import type { LucideIcon } from 'lucide-react' - -import { - Bot, - Check, - FileCode2, - FileJson, - FileText, - Folder, - FolderOpen, -} from 'lucide-react' - -interface TreeRow { - depth: number - highlight?: 1 | 2 | 3 | 4 - Icon: LucideIcon - label: string -} - -const TREE: TreeRow[] = [ - { depth: 0, Icon: FileText, highlight: 1, label: 'AGENTS.md' }, - { depth: 0, Icon: FileText, label: 'llms-full.txt' }, - { depth: 0, Icon: FolderOpen, label: 'plugins/events' }, - { depth: 1, Icon: FileCode2, highlight: 2, label: 'config.ts' }, - { depth: 1, Icon: FileCode2, highlight: 3, label: 'routes.ts' }, - { depth: 1, Icon: Folder, label: 'api/modules' }, - { depth: 2, Icon: FileCode2, highlight: 3, label: 'events.ts' }, - { depth: 1, Icon: Folder, label: 'pages' }, - { depth: 2, Icon: FileCode2, highlight: 4, label: 'admin-events.tsx' }, - { depth: 1, Icon: FileJson, label: 'locales/en.json' }, -] - -const STEPS = [ - 'Read AGENTS.md conventions', - 'Loaded llms-full.txt: routing, plugins', - 'Scaffolded plugins/events', - 'Added /events route, RSVP API module', - 'Registered AdminCP page + permission', - 'Lint and typecheck clean. Ready to review.', -] - -const ROW_HEIGHT = 24 -const TREE_TOP = 70 - -export const AgentMap = () => ( - - - - YOUR REPOSITORY - - - - {TREE.map(({ depth, highlight, Icon, label }, index) => { - const y = TREE_TOP + index * ROW_HEIGHT - - return ( - - {highlight ? ( - - ) : null} - - - {label} - - - ) - })} - - - - - - Coding agent - - - - working - - - - - - “Build an events plugin with RSVPs.” - - - {STEPS.map((step, index) => { - const y = 118 + index * 28 - - return ( - - - - - {step} - - - ) - })} - - - - -) diff --git a/apps/web/src/site/home/illustrations/bento/ai-chip.tsx b/apps/web/src/site/home/illustrations/bento/ai-chip.tsx new file mode 100644 index 0000000000..f9069f159d --- /dev/null +++ b/apps/web/src/site/home/illustrations/bento/ai-chip.tsx @@ -0,0 +1,180 @@ +import { Image, Layers, Sparkles } from 'lucide-react' +import { useTranslations } from 'use-intl' + +import { + Fit, + GridTexture, + ScenePill, +} from '@/site/home/illustrations/bento/scene' +import { useLoopStep } from '@/site/home/illustrations/bento/use-loop-step' + +type Point = readonly [number, number] + +const TRACES: readonly { + delay: number + dot: Point + points: readonly Point[] +}[] = [ + { + delay: 0, + dot: [160, 10], + points: [ + [160, 78], + [160, 10], + ], + }, + { + delay: 0.9, + dot: [100, 56], + points: [ + [130, 78], + [130, 56], + [100, 56], + ], + }, + { + delay: 0.4, + dot: [268, 42], + points: [ + [204, 94], + [226, 94], + [226, 42], + [268, 42], + ], + }, + { + delay: 1.4, + dot: [46, 122], + points: [ + [116, 122], + [46, 122], + ], + }, + { + delay: 0.2, + dot: [274, 122], + points: [ + [204, 122], + [274, 122], + ], + }, + { + delay: 1.8, + dot: [52, 200], + points: [ + [116, 150], + [94, 150], + [94, 200], + [52, 200], + ], + }, + { + delay: 1.1, + dot: [238, 228], + points: [ + [204, 188], + [238, 188], + [238, 228], + ], + }, +] + +const toPath = (points: readonly Point[]) => { + const d = points + .map(([x, y], index) => `${index === 0 ? 'M' : 'L'}${x} ${y}`) + .join(' ') + const length = points.reduce((sum, [x, y], index) => { + const previous = points[index - 1] + + return previous + ? sum + Math.abs(x - previous[0]) + Math.abs(y - previous[1]) + : sum + }, 0) + + return { d, length } +} + +const PATHS = TRACES.map((trace) => ({ ...trace, ...toPath(trace.points) })) + +const BUDGET = [57, 55, 52, 54, 56] as const +const DURATIONS = BUDGET.map(() => 1600) + +export const AiChipScene = () => { + const t = useTranslations('site.home.bento.ai') + const { ref, step } = useLoopStep(DURATIONS) + + return ( +
+ + + + + {PATHS.map(({ d, delay, dot, length }) => ( + + + + + + ))} + + + + + + + + + + + {t('provider')} + + + {t('alt')} + + + + + + + + {t('budget', { percent: BUDGET[step] ?? BUDGET[0] })} + + + +
+ ) +} diff --git a/apps/web/src/site/home/illustrations/bento/board.tsx b/apps/web/src/site/home/illustrations/bento/board.tsx new file mode 100644 index 0000000000..6575b18abf --- /dev/null +++ b/apps/web/src/site/home/illustrations/bento/board.tsx @@ -0,0 +1,237 @@ +import type { LucideIcon } from 'lucide-react' + +import { cn } from 'cn' +import { + Braces, + CalendarClock, + Database, + History, + Languages, + LayoutDashboard, + MessageCircle, + Search, + Send, + SmilePlus, + UserPlus, + Users, +} from 'lucide-react' +import { useTranslations } from 'use-intl' + +import { Fit, ScenePill } from '@/site/home/illustrations/bento/scene' +import { Slab } from '@/site/home/illustrations/bento/slab' +import { useLoopStep } from '@/site/home/illustrations/bento/use-loop-step' + +const FEATURES: readonly { Icon: LucideIcon; key: string }[] = [ + { Icon: LayoutDashboard, key: 'admin' }, + { Icon: Braces, key: 'api' }, + { Icon: Database, key: 'database' }, + { Icon: Send, key: 'publishing' }, + { Icon: History, key: 'revisions' }, + { Icon: CalendarClock, key: 'scheduling' }, + { Icon: Languages, key: 'translations' }, + { Icon: Search, key: 'search' }, + { Icon: Users, key: 'live' }, + { Icon: MessageCircle, key: 'comments' }, + { Icon: SmilePlus, key: 'reactions' }, + { Icon: UserPlus, key: 'followers' }, +] + +const COLS = 4 +const ROWS = 3 +const DURATIONS = [...FEATURES.map(() => 600), 2800] +const WIDTH = 600 +const BASE = { cx: 300, h: 140, t: 16, topY: 64, w: 280 } +const CUBE = { h: 28, t: 13, w: 56 } +const PILL_LEFT_EDGE = 138 +const PILL_RIGHT_EDGE = 462 +const PILL_TOP = 22 +const PILL_STEP = 36 + +const cellCenter = (i: number, j: number) => { + const u = (i + 0.5) / COLS + const v = (j + 0.5) / ROWS + + return { + x: BASE.cx + (u - v) * (BASE.w / 2), + y: BASE.topY + (u + v) * (BASE.h / 2), + } +} + +const pillAnchor = (index: number) => { + const left = index % 2 === 0 + const row = Math.floor(index / 2) + + return { + left, + x: left ? PILL_LEFT_EDGE : PILL_RIGHT_EDGE, + y: PILL_TOP + row * PILL_STEP + (left ? 0 : PILL_STEP / 2), + } +} + +const CELLS = FEATURES.map((feature, index) => { + const i = index % COLS + const j = Math.floor(index / COLS) + const center = cellCenter(i, j) + const anchor = pillAnchor(index) + const topY = center.y - CUBE.t + const midX = (center.x + anchor.x) / 2 + + return { + ...feature, + anchor, + center, + depth: i + j, + index, + path: `M${center.x} ${topY} C${midX} ${topY} ${midX} ${anchor.y} ${anchor.x} ${anchor.y}`, + } +}) + +const PAINT_ORDER = [...CELLS].sort((a, b) => a.depth - b.depth) + +const gridLine = (from: { x: number; y: number }, dx: number) => + `M${from.x} ${from.y} l${dx} ${BASE.h / 2}` + +export const BoardScene = () => { + const t = useTranslations('site.home.bento.content.features') + const { ref, step } = useLoopStep(DURATIONS) + + return ( +
+ + + + + + + {Array.from({ length: COLS - 1 }, (_, n) => (n + 1) / COLS).map( + (u) => ( + + ), + )} + {Array.from({ length: ROWS - 1 }, (_, n) => (n + 1) / ROWS).map( + (v) => ( + + ), + )} + + + + {PAINT_ORDER.map(({ center, Icon, index, key }) => { + const landed = index <= step + + return ( + + + + ) + })} + + + {CELLS.map(({ anchor, index, key, path }) => { + const landed = index <= step + + return ( + + + + + + {index === step ? ( + + ) : null} + + ) + })} + + + {CELLS.map(({ anchor, Icon, index, key }) => { + const landed = index <= step + + return ( + + {t(key)} + + + ) + })} + +
+ ) +} diff --git a/apps/web/src/site/home/illustrations/bento/co-type.tsx b/apps/web/src/site/home/illustrations/bento/co-type.tsx new file mode 100644 index 0000000000..68485ecd2d --- /dev/null +++ b/apps/web/src/site/home/illustrations/bento/co-type.tsx @@ -0,0 +1,241 @@ +import { cn } from 'cn' +import { Bold, Italic, Link2, Underline } from 'lucide-react' +import { useEffect, useMemo, useRef, useState } from 'react' +import { useTranslations } from 'use-intl' + +import { Fit } from '@/site/home/illustrations/bento/scene' + +const CYCLE = 9000 +const FADE_AT = 7600 +const IDLE_AFTER = 450 +const SELECT_FROM = 3400 +const SELECT_TO = 3800 +const REDUCED_MOTION = '(prefers-reduced-motion: reduce)' + +const OLA = 'bg-primary text-primary-foreground' +const KUBA = 'bg-amber-400 text-amber-950' + +const jitter = (index: number, seed: number) => { + const value = Math.sin(index * 12.9898 + seed * 78.233) * 43758.5453 + + return value - Math.floor(value) +} + +const schedule = ( + text: string, + { + base, + seed, + spread, + startAt, + }: { base: number; seed: number; spread: number; startAt: number }, +) => { + const times: number[] = [] + let at = startAt + + for (let index = 0; index < text.length; index += 1) { + const char = text[index] ?? '' + at += base + jitter(index, seed) * spread + if (char === ' ') at += 40 + if (char === '.' || char === ',') at += 160 + times.push(at) + } + + return times +} + +const countAt = (times: readonly number[], t: number) => { + let count = 0 + + while (count < times.length && (times[count] ?? Infinity) <= t) count += 1 + + return count +} + +const isIdle = (times: readonly number[], count: number, t: number) => + count === 0 || t - (times[count - 1] ?? 0) > IDLE_AFTER + +const useAnimationClock = (cycle: number, still: number) => { + const ref = useRef(null) + const [t, setT] = useState(still) + + useEffect(() => { + const node = ref.current + + if (!node || window.matchMedia(REDUCED_MOTION).matches) return + + let frame = 0 + let origin = 0 + let elapsed = 0 + + const tick = (now: number) => { + if (!origin) origin = now - elapsed + setT((now - origin) % cycle) + frame = requestAnimationFrame(tick) + } + + const observer = new IntersectionObserver(([entry]) => { + if (entry?.isIntersecting) { + if (!frame) frame = requestAnimationFrame(tick) + + return + } + + if (frame) { + cancelAnimationFrame(frame) + frame = 0 + elapsed = performance.now() - origin + origin = 0 + } + }) + + observer.observe(node) + + return () => { + observer.disconnect() + cancelAnimationFrame(frame) + } + }, [cycle]) + + return { ref, t } +} + +const Initial = ({ + className, + initials, +}: { + className: string + initials: string +}) => ( + + {initials} + +) + +const Caret = ({ + className, + idle, + name, +}: { + className: string + idle: boolean + name: string +}) => ( + + + + {name} + + +) + +export const CoTypeScene = () => { + const t = useTranslations('site.home.bento.live') + const olaText = t('ola') + const kubaText = t('kuba') + const olaTimes = useMemo( + () => schedule(olaText, { base: 46, seed: 1, spread: 50, startAt: 500 }), + [olaText], + ) + const kubaTimes = useMemo( + () => schedule(kubaText, { base: 40, seed: 2, spread: 44, startAt: 1500 }), + [kubaText], + ) + const { ref, t: time } = useAnimationClock(CYCLE, FADE_AT - 1) + const ola = countAt(olaTimes, time) + const kuba = countAt(kubaTimes, time) + const olaIdle = isIdle(olaTimes, ola, time) + const kubaIdle = isIdle(kubaTimes, kuba, time) + const selection = Math.min( + 1, + Math.max(0, (time - SELECT_FROM) / (SELECT_TO - SELECT_FROM)), + ) + const fading = time >= FADE_AT + const saving = !fading && (!olaIdle || !kubaIdle) + + return ( +
+ + + + + + + + + + + + {saving ? t('saving') : t('saved')} + + + + + + + + + + + {t('first')}{' '} + + + {t('selected')} + + {t('first_end')} + + {olaText.slice(0, ola)} + + + + {olaText.slice(ola)} + + + + {t('second')} + + {kubaText.slice(0, kuba)} + + + + {kubaText.slice(kuba)} + + + + + + +
+ ) +} diff --git a/apps/web/src/site/home/illustrations/bento/drop.tsx b/apps/web/src/site/home/illustrations/bento/drop.tsx new file mode 100644 index 0000000000..086eddbd16 --- /dev/null +++ b/apps/web/src/site/home/illustrations/bento/drop.tsx @@ -0,0 +1,115 @@ +import { cn } from 'cn' +import { Bell, CheckCheck } from 'lucide-react' +import { useTranslations } from 'use-intl' + +import { Fit, GridTexture } from '@/site/home/illustrations/bento/scene' +import { useLoopStep } from '@/site/home/illustrations/bento/use-loop-step' + +const MESSAGES = [ + { avatar: 'K', key: 'reply', tone: 'bg-amber-400 text-amber-950' }, + { avatar: 'V', key: 'live', tone: 'bg-muted text-muted-foreground' }, + { avatar: 'O', key: 'comment', tone: 'bg-primary text-primary-foreground' }, + { avatar: 'M', key: 'report', tone: 'bg-muted text-muted-foreground' }, +] as const + +const DURATIONS = [1500, 1500, 1500, 1500, 2400] as const +const READ_STEP = 4 + +export const DropScene = () => { + const t = useTranslations('site.home.bento.notifications') + const { ref, step } = useLoopStep(DURATIONS) + const arrived = Math.min(step + 1, MESSAGES.length) + const read = step === READ_STEP + const count = read ? 0 : arrived + + return ( +
+ + {MESSAGES.map(({ avatar, key, tone }, index) => { + const depth = arrived - 1 - index + const visible = depth >= 0 && depth <= 2 + const behind = depth > 0 + + return ( + + + + {avatar} + + + + {t(`items.${key}.title`)} + + + {t(`items.${key}.sub`)} + + + + = 0 ? 'opacity-100' : 'opacity-0', + )} + /> + + ) + })} + + + + + + + 0 ? 'scale-100 opacity-100' : 'scale-50 opacity-0', + )} + > + {Math.max(count, 1)} + + + + + + {t('mark_all')} + + +
+ ) +} diff --git a/apps/web/src/site/home/illustrations/bento/scene.tsx b/apps/web/src/site/home/illustrations/bento/scene.tsx new file mode 100644 index 0000000000..48bc8889a5 --- /dev/null +++ b/apps/web/src/site/home/illustrations/bento/scene.tsx @@ -0,0 +1,94 @@ +import type { LucideIcon } from 'lucide-react' + +import { cn } from 'cn' + +export const Fit = ({ + children, + height, + width, +}: { + children: React.ReactNode + height: number + width: number +}) => ( +
+
+ {children} +
+
+) + +export const ScenePill = ({ + children, + className, + Icon, + style, +}: { + children: React.ReactNode + className?: string + Icon?: LucideIcon + style?: React.CSSProperties +}) => ( + + {Icon ? ( + + ) : null} + {children} + +) + +export const SceneBackdrop = () => ( + + + + +) + +export const GridTexture = ({ + className, + size, +}: { + className?: string + size: number +}) => ( + +) diff --git a/apps/web/src/site/home/illustrations/bento/slab.tsx b/apps/web/src/site/home/illustrations/bento/slab.tsx new file mode 100644 index 0000000000..41eeefdffe --- /dev/null +++ b/apps/web/src/site/home/illustrations/bento/slab.tsx @@ -0,0 +1,72 @@ +import type { LucideIcon } from 'lucide-react' + +import { cn } from 'cn' + +const TONES = { + accent: { + icon: 'text-primary-foreground', + left: 'fill-primary/85', + right: 'fill-primary/65', + top: 'fill-primary', + }, + plain: { + icon: 'text-foreground/70', + left: 'fill-muted stroke-border', + right: 'fill-border stroke-border', + top: 'fill-card stroke-border', + }, +} as const + +export const Slab = ({ + h, + Icon, + t, + tone = 'plain', + w, +}: { + h: number + Icon?: LucideIcon + t: number + tone?: keyof typeof TONES + w: number +}) => { + const colors = TONES[tone] + + return ( + + + + + + + {Icon ? ( + + + + ) : null} + + ) +} diff --git a/apps/web/src/site/home/illustrations/bento/suggest.tsx b/apps/web/src/site/home/illustrations/bento/suggest.tsx new file mode 100644 index 0000000000..d0e4ce9374 --- /dev/null +++ b/apps/web/src/site/home/illustrations/bento/suggest.tsx @@ -0,0 +1,162 @@ +import type { LucideIcon } from 'lucide-react' + +import { cn } from 'cn' +import { + CalendarDays, + CornerDownLeft, + FileText, + MessageCircle, + MessagesSquare, + Search, + ShieldCheck, + Users, +} from 'lucide-react' +import { useTranslations } from 'use-intl' + +import { Fit } from '@/site/home/illustrations/bento/scene' +import { useLoopStep } from '@/site/home/illustrations/bento/use-loop-step' + +const SUGGESTIONS: readonly { Icon: LucideIcon; key: string; score: number }[] = + [ + { Icon: FileText, key: 'recap', score: 0.92 }, + { Icon: MessagesSquare, key: 'photos', score: 0.81 }, + { Icon: ShieldCheck, key: 'moderators', score: 0.64 }, + { Icon: Users, key: 'rules', score: 0.58 }, + { Icon: CalendarDays, key: 'events', score: 0.41 }, + { Icon: MessageCircle, key: 'library', score: 0.33 }, + ] + +const DURATIONS = [800, 500, 900, 450, 450, 800, 420, 2600] as const +const TAB_STEP = 6 +const ACCEPT_STEP = 7 +const VISIBLE_ROWS = 5 +const ROW_STRIDE = 34 +const LIST_PADDING = 12 + +export const SuggestScene = () => { + const t = useTranslations('site.home.bento.search') + const { ref, step } = useLoopStep(DURATIONS) + const queries = t('queries').split('|') + const query = queries[Math.min(step, queries.length - 1)] ?? '' + const pressing = step === TAB_STEP + const accepted = step === ACCEPT_STEP + const selected = step >= TAB_STEP + const labelled = SUGGESTIONS.map((suggestion) => ({ + ...suggestion, + label: t(`suggestions.${suggestion.key}`), + })) + const matchesFor = (value: string) => + labelled.filter(({ label }) => + label.toLowerCase().startsWith(value.toLowerCase()), + ) + const matches = matchesFor(query) + const previous = matchesFor(queries[Math.max(step - 1, 0)] ?? '') + const top = matches[0] + const ghost = top ? top.label.slice(query.length).toLowerCase() : '' + const listHeight = + Math.max(Math.min(matches.length, VISIBLE_ROWS), 1) * ROW_STRIDE + + LIST_PADDING + + return ( +
+ + + + + + {query} + {accepted ? null : ( + + + + )} + + {ghost} + + + + {t('tab')} + + + + {labelled.map(({ Icon, key, label, score }) => { + const rank = matches.findIndex((item) => item.key === key) + const shown = rank !== -1 && rank < VISIBLE_ROWS + const previousRank = previous.findIndex( + (item) => item.key === key, + ) + const slot = shown + ? rank + : previousRank === -1 + ? VISIBLE_ROWS + : previousRank + const active = selected && rank === 0 + + return ( + + + + + {label.slice(0, query.length)} + + + {label.slice(query.length)} + + + + + + + + ) + })} + + + +
+ ) +} diff --git a/apps/web/src/site/home/illustrations/bento/use-loop-step.ts b/apps/web/src/site/home/illustrations/bento/use-loop-step.ts new file mode 100644 index 0000000000..7df7a59892 --- /dev/null +++ b/apps/web/src/site/home/illustrations/bento/use-loop-step.ts @@ -0,0 +1,58 @@ +import { startTransition, useEffect, useRef, useState } from 'react' + +const NEAR_VIEWPORT = '120px 0px' +const REDUCED_MOTION = '(prefers-reduced-motion: reduce)' + +export const useLoopStep = (durations: readonly number[]) => { + const ref = useRef(null) + const lastStep = durations.length - 1 + const [step, setStep] = useState(lastStep) + const stepRef = useRef(lastStep) + + useEffect(() => { + const node = ref.current + + if (!node || window.matchMedia(REDUCED_MOTION).matches) return + + let timer: number | undefined + + const stop = () => { + window.clearTimeout(timer) + timer = undefined + } + + const schedule = () => { + timer = window.setTimeout(() => { + stepRef.current = (stepRef.current + 1) % durations.length + const next = stepRef.current + + startTransition(() => { + setStep(next) + }) + schedule() + }, durations[stepRef.current]) + } + + const observer = new IntersectionObserver( + ([entry]) => { + if (!entry?.isIntersecting) { + stop() + + return + } + + if (timer === undefined) schedule() + }, + { rootMargin: NEAR_VIEWPORT }, + ) + + observer.observe(node) + + return () => { + observer.disconnect() + stop() + } + }, [durations]) + + return { ref, step } +} diff --git a/apps/web/src/site/home/illustrations/feature-visuals.tsx b/apps/web/src/site/home/illustrations/feature-visuals.tsx deleted file mode 100644 index 06548ce3d6..0000000000 --- a/apps/web/src/site/home/illustrations/feature-visuals.tsx +++ /dev/null @@ -1,968 +0,0 @@ -import type { LucideIcon } from 'lucide-react' - -import { cn } from 'cn' -import { - Bell, - Boxes, - Braces, - Check, - Database, - FileCheck, - FileText, - Languages, - LayoutDashboard, - Mail, - Monitor, - Search, - ShieldCheck, - Smartphone, - Sparkles, - Tablet, - Zap, -} from 'lucide-react' - -const Visual = ({ - children, - className, - viewBox = '0 0 320 180', -}: { - children: React.ReactNode - className?: string - viewBox?: string -}) => ( - - {children} - -) - -const delay = (seconds: number): React.CSSProperties => ({ - animationDelay: `-${seconds}s`, -}) - -const stagger = ( - index: number, - count: number, - period: number, -): React.CSSProperties => ({ - animationDelay: `${(index - count) * period}s`, -}) - -const CODE_LINES = [ - { text: 'defineContentType({', tone: 'plain' }, - { text: ' id: "blog.article",', tone: 'plain' }, - { text: ' fields: {', tone: 'plain' }, - { text: ' title: field.text({ required: true }),', tone: 'accent' }, - { text: ' body: field.richText(),', tone: 'accent' }, - { text: ' cover: field.image(),', tone: 'accent' }, - { text: ' },', tone: 'plain' }, - { text: '})', tone: 'plain' }, -] as const - -const CONTENT_OUTPUTS: { Icon: LucideIcon; label: string; meta: string }[] = [ - { Icon: Database, label: 'PostgreSQL table', meta: 'blog_articles' }, - { Icon: FileCheck, label: 'Zod validation', meta: 'z.object({ … })' }, - { Icon: Braces, label: 'Typed CRUD API', meta: 'GET /api/articles' }, - { - Icon: ShieldCheck, - label: 'Staff permissions', - meta: 'can_view · can_edit', - }, - { - Icon: LayoutDashboard, - label: 'AdminCP screens', - meta: 'list · create · edit', - }, - { Icon: Languages, label: 'Translations', meta: 'en · pl · es · ja' }, -] - -const OUTPUT_ROW_Y = [330, 420, 510] -const OUTPUT_LEFT_X = 24 -const OUTPUT_RIGHT_X = 312 -const OUTPUT_WIDTH = 224 -const OUTPUT_HEIGHT = 60 -const TRUNK_X = 280 - -export const ContentEngineVisual = () => ( - - - - - - - plugins/blog/src/content/article.ts - - {CODE_LINES.map(({ text, tone }, index) => ( - - {text} - - ))} - - - - - - - - - Content Engine - - - - - - {CONTENT_OUTPUTS.map(({ Icon, label, meta }, index) => { - const left = index % 2 === 0 - const x = left ? OUTPUT_LEFT_X : OUTPUT_RIGHT_X - const y = OUTPUT_ROW_Y[Math.floor(index / 2)] ?? 330 - const branchEnd = left ? x + OUTPUT_WIDTH : x - - return ( - - - - - - - - - {label} - - - {meta} - - - - ) - })} - - - one definition in · a whole product out - - -) - -const GREETINGS = ['Hello!', 'Cześć!', '¡Hola!', 'こんにちは!'] - -export const I18nVisual = () => ( - - - - - - - - - - - - - - {GREETINGS.map((greeting, index) => ( - - {greeting} - - ))} - - {['EN', 'PL', 'ES', 'JA'].map((code, index) => ( - - - - - {code} - - - {code} - - - ))} - -) - -const CACHE_HIT_PATH = 'M62 104 C 96 104, 108 62, 142 62' -const CACHE_MISS_PATH = - 'M62 104 C 96 104, 108 62, 142 62 L 178 62 C 212 62, 222 104, 256 104' - -export const CacheVisual = () => ( - - - - - - - - - - - - - - - - - - - - - HIT · 2 ms - - - - - Visitor - - - Cache - - - Database - - -) - -const EVENT_LOG = [ - { name: 'user.created', tone: 'fill-emerald-500' }, - { name: 'blog.post.created', tone: 'fill-primary' }, - { name: 'role.updated', tone: 'fill-amber-500' }, - { name: 'file.uploaded', tone: 'fill-primary' }, -] - -const LISTENERS: { Icon: LucideIcon; label: string; y: number }[] = [ - { Icon: Mail, label: 'Send email', y: 46 }, - { Icon: Search, label: 'Reindex', y: 90 }, - { Icon: Bell, label: 'Broadcast', y: 134 }, -] - -const LOG_ROW = 26 -const LOG = { height: 140, width: 166, x: 16, y: 20 } -const CHIP = { height: 30, width: 106, x: 202 } - -export const EventsVisual = () => ( - - - - - - - - - - - event bus · live - - - - - - {[...EVENT_LOG, ...EVENT_LOG].map(({ name, tone }, index) => ( - - - - {name} - - - ))} - - - - {LISTENERS.map(({ Icon, label, y }, index) => { - const path = `M${LOG.x + LOG.width} 90 C ${LOG.x + LOG.width + 12} 90, ${CHIP.x - 12} ${y}, ${CHIP.x} ${y}` - - return ( - - - - - - - - {label} - - - ) - })} - -) - -export const AiVisual = () => ( - - - - - Summarize this thread for me - - - - - - {[ - { width: 220, y: 84 }, - { width: 180, y: 100 }, - { width: 132, y: 116 }, - ].map(({ width, y }, index) => ( - - ))} - - - {[ - { size: 16, x: 262, y: 32 }, - { size: 10, x: 282, y: 50 }, - { size: 8, x: 250, y: 56 }, - ].map(({ size, x, y }, index) => ( - - - - ))} - - - Your provider · your model · your rules - - -) - -const SEARCH_RESULTS = [ - { meta: 'Guide · 4 min', title: 'Welcome aboard: your first week' }, - { meta: 'Rules · 2 min', title: 'Community guidelines' }, - { meta: 'Guide · 6 min', title: 'How to ask a great question' }, -] - -export const SearchVisual = () => ( - - - - - - - - - - - onboarding guide for new members - - - - ⌘K - - - {SEARCH_RESULTS.map(({ meta, title }, index) => ( - - - - - {title} - - - {meta} - - - ))} - - - - Ranking engine - - {['Postgres', 'Elasticsearch'].map((engine, index) => ( - - - - - {engine} - - - ))} - -) - -const DEVICES: { Icon: LucideIcon; name: string }[] = [ - { Icon: Monitor, name: 'desktop' }, - { Icon: Smartphone, name: 'phone' }, - { Icon: Tablet, name: 'tablet' }, -] - -export const RealtimeVisual = () => ( - - - - - - - - - {[0, 1, 2].map((index) => ( - - ))} - - - - - - - Hello from the admin - - - from the dashboard widget - - - - - - - - - Maintenance at 22:00 - - - broadcast to everyone online - - - - {DEVICES.map(({ Icon, name }, index) => ( - - - - - - ))} - -) diff --git a/apps/web/src/site/home/illustrations/gates.tsx b/apps/web/src/site/home/illustrations/gates.tsx deleted file mode 100644 index 3d98a62137..0000000000 --- a/apps/web/src/site/home/illustrations/gates.tsx +++ /dev/null @@ -1,176 +0,0 @@ -import type { LucideIcon } from 'lucide-react' - -import { Bot, Check, Gauge, Lock, ShieldUser, X } from 'lucide-react' - -interface Gate { - Icon: LucideIcon - label: string - sub: string -} - -const GATES: Gate[] = [ - { Icon: Bot, label: 'Bot check', sub: 'Turnstile · reCAPTCHA' }, - { Icon: Gauge, label: 'Rate limit', sub: '429 after the budget' }, - { Icon: Lock, label: 'Session', sub: 'HttpOnly · hashed' }, - { Icon: ShieldUser, label: 'Permissions', sub: 'role · staff · plugin' }, -] - -const GATE_TOP = 56 -const GATE_STEP = 66 -const GATE_HEIGHT = 48 -const MEMBER_LANE = 56 -const BOT_LANE = 96 -const MEMBER_CYCLE = 6 -const TRACK = { end: 322, start: 14 } - -const gateY = (index: number) => GATE_TOP + index * GATE_STEP - -const gateCenter = (index: number) => gateY(index) + GATE_HEIGHT / 2 - -const memberDelay = (index: number) => { - const arrival = - (MEMBER_CYCLE * (gateCenter(index) - TRACK.start)) / - (TRACK.end - TRACK.start) - const turnOn = MEMBER_CYCLE * 0.24 - - return `${arrival - turnOn - MEMBER_CYCLE}s` -} - -export const GatesVisual = () => ( - - - - - {GATES.map(({ Icon, label, sub }, index) => { - const y = gateY(index) - - return ( - - - - - - {label} - - - {sub} - - - - - - - ) - })} - - - - - - - - - - - - - -) diff --git a/apps/web/src/site/home/namespaces.ts b/apps/web/src/site/home/namespaces.ts index b9e5a62dbb..e918a81de2 100644 --- a/apps/web/src/site/home/namespaces.ts +++ b/apps/web/src/site/home/namespaces.ts @@ -1 +1,5 @@ -export const HOME_NAMESPACES = ['core.global', 'site.home.hero'] as const +export const HOME_NAMESPACES = [ + 'core.global', + 'site.home.hero', + 'site.home.bento', +] as const diff --git a/apps/web/src/site/home/sections/agents.tsx b/apps/web/src/site/home/sections/agents.tsx deleted file mode 100644 index 83f0d88acd..0000000000 --- a/apps/web/src/site/home/sections/agents.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import type { LucideIcon } from 'lucide-react' - -import { cn } from 'cn' -import { - BookOpen, - Boxes, - Braces, - FileJson, - FileText, - ListChecks, -} from 'lucide-react' - -import { AgentMap } from '@/site/home/illustrations/agent-map' -import { - ActionLink, - MarketingSection, - SectionHeading, - SectionRow, - SURFACE, - TextLink, -} from '@/site/marketing/shared' - -const MAP_LEGEND: { Icon: LucideIcon; label: string }[] = [ - { Icon: FileText, label: 'AGENTS.md conventions' }, - { Icon: BookOpen, label: 'llms-full.txt of every doc' }, - { Icon: Braces, label: 'End-to-end typed fetcher' }, - { Icon: FileJson, label: 'OpenAPI from your routes' }, - { Icon: Boxes, label: 'Hard plugin boundaries' }, - { Icon: ListChecks, label: 'Lint rules that teach' }, -] - -export const AgentsSection = () => ( - - - Docs for your agent - - } - > - - Readable docs, one conventions file, the entire documentation as a - single text file, typed APIs and strict plugin boundaries. Your agent - stops guessing where things go. You review real progress instead of - creative archaeology. - - - -
-
- -
- -
-
    - {MAP_LEGEND.map(({ Icon, label }) => ( -
  • - - - - {label} -
  • - ))} -
- -

- Building AI into the product itself? The same framework gives you - summaries, streaming answers and embeddings through the Vercel AI SDK - with whichever provider you trust. -

- - - Build AI features - -
-
-
-) diff --git a/apps/web/src/site/home/sections/bento.tsx b/apps/web/src/site/home/sections/bento.tsx new file mode 100644 index 0000000000..25f053c159 --- /dev/null +++ b/apps/web/src/site/home/sections/bento.tsx @@ -0,0 +1,68 @@ +import { cn } from 'cn' +import { useTranslations } from 'use-intl' + +import { AiChipScene } from '@/site/home/illustrations/bento/ai-chip' +import { BoardScene } from '@/site/home/illustrations/bento/board' +import { CoTypeScene } from '@/site/home/illustrations/bento/co-type' +import { DropScene } from '@/site/home/illustrations/bento/drop' +import { SceneBackdrop } from '@/site/home/illustrations/bento/scene' +import { SuggestScene } from '@/site/home/illustrations/bento/suggest' +import { MarketingSection, SectionHeading } from '@/site/marketing/shared' + +interface Tile { + className?: string + key: 'ai' | 'content' | 'live' | 'notifications' | 'search' + Scene: React.ElementType +} + +const TILES: readonly Tile[] = [ + { className: 'md:col-span-2', key: 'content', Scene: BoardScene }, + { key: 'notifications', Scene: DropScene }, + { key: 'live', Scene: CoTypeScene }, + { key: 'ai', Scene: AiChipScene }, + { key: 'search', Scene: SuggestScene }, +] + +export const BentoSection = () => { + const t = useTranslations('site.home.bento') + + return ( + + + +
+ {TILES.map(({ className, key, Scene }) => ( +
+
+ + +
+
+

+ {t(`${key}.title`)} +

+

+ {t(`${key}.desc`)} +

+
+
+ ))} +
+
+ ) +} diff --git a/apps/web/src/site/home/sections/community.tsx b/apps/web/src/site/home/sections/community.tsx deleted file mode 100644 index 46e2d80c8f..0000000000 --- a/apps/web/src/site/home/sections/community.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import type { LucideIcon } from 'lucide-react' - -import { cn } from 'cn' -import { Check, Compass, Gavel, UserRound, UsersRound } from 'lucide-react' - -import type { Surface } from '@/site/marketing/shared' - -import { - ActionLink, - MarketingSection, - SectionHeading, - SectionRow, - SURFACE, -} from '@/site/marketing/shared' - -const CARDS: { - Icon: LucideIcon - status: 'Included' | 'On the roadmap' - surface: Surface - text: string - title: string -}[] = [ - { - Icon: UsersRound, - status: 'Included', - surface: 'dark', - text: 'Primary and secondary groups, coloured names and sensible seeded defaults. Organise people the way your community actually works.', - title: 'Member roles', - }, - { - Icon: Gavel, - status: 'On the roadmap', - surface: 'soft', - text: 'Moderators already have their own permission set in the AdminCP. A dedicated moderation workspace is one of the next things being built.', - title: 'Moderator CP', - }, - { - Icon: UserRound, - status: 'Included', - surface: 'tint', - text: 'Registration, sign-in, password reset, social login and device sessions are ready before you write your first page.', - title: 'Accounts & sessions', - }, - { - Icon: Compass, - status: 'Included', - surface: 'soft', - text: 'A site-wide activity feed every plugin can publish into, so members always find the newest thing worth their time.', - title: 'Discover feed', - }, -] - -export const CommunitySection = () => ( - - - See how roles work - - } - > - - Members sign up, get roles and earn trust. Your team keeps the place - healthy from a panel built for the job, not from a spreadsheet and a - prayer. - - - -
    - {CARDS.map(({ Icon, status, surface, text, title }) => ( -
  • -
    - - - - - {status === 'Included' && ( - - )} - {status} - -
    -

    {title}

    -

    - {text} -

    -
  • - ))} -
-
-) diff --git a/apps/web/src/site/home/sections/developers.tsx b/apps/web/src/site/home/sections/developers.tsx deleted file mode 100644 index 7c118ed5b1..0000000000 --- a/apps/web/src/site/home/sections/developers.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import { cn } from 'cn' - -import { - ActionLink, - MarketingSection, - SectionHeading, - SectionRow, - SURFACE, -} from '@/site/marketing/shared' - -const STACK = [ - 'TypeScript', - 'React 19', - 'TanStack Start', - 'Hono', - 'PostgreSQL', - 'Drizzle ORM', - 'Tailwind CSS', -] - -const COMMANDS = [ - { - comment: 'scaffold an app, choose Turborepo for plugins', - line: 'pnpm create vitnode-app@canary', - }, - { - comment: 'start Postgres and run', - line: 'pnpm docker:dev && pnpm dev', - }, - { - comment: 'your first feature gets its own package', - line: 'pnpm create vitnode-app@canary --plugin', - }, -] - -export const DevelopersSection = () => ( - - - Build your first plugin - - } - > - - Start a project, build a plugin, make it yours. Familiar tools - underneath, end-to-end types in between, and more of your actual product - on top. - - - -
-
-

- The stack underneath -

-
    - {STACK.map((item) => ( -
  • - {item} -
  • - ))} -
-

- Node.js 22.18+, Postgres or Docker, and whichever package manager you - already argue about. bun, pnpm and npm are all welcome. -

-
- -
-
- - - - - - - terminal - -
-
-          
-            {COMMANDS.map(({ comment, line }) => (
-              
-                # {comment}
-                
-                  $ 
-                  {line}
-                
-              
-            ))}
-          
-        
-
-
-
-) diff --git a/apps/web/src/site/home/sections/faq.tsx b/apps/web/src/site/home/sections/faq.tsx deleted file mode 100644 index f6a9d1b1a4..0000000000 --- a/apps/web/src/site/home/sections/faq.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, -} from '@vitnode/core/components/ui/accordion' - -import { SPONSOR_URL } from '@/site/marketing/links' -import { MarketingSection, SectionHeading } from '@/site/marketing/shared' - -const FAQS: { answer: React.ReactNode; question: string }[] = [ - { - answer: - 'A free, open-source framework for building community applications: members, roles, content, real-time updates, search and an Admin Control Panel come built in, and every feature you add ships as a plugin. Think of it as the foundation of a help center, a membership site or a project hub, minus the licence.', - question: 'What is VitNode, in one breath?', - }, - { - answer: - 'Yes. VitNode is MIT-licensed. There is no subscription, no per-member fee and no paid tier hiding behind a sales call. You only pay for the infrastructure and services you choose to run it on.', - question: 'Is it actually free?', - }, - { - answer: - 'Yes. The MIT licence allows commercial use, modification and redistribution. Keep the copyright and licence notice when you distribute the software, and build as serious a product as you like.', - question: 'Can I use it for my company or a client project?', - }, - { - answer: - 'VitNode 2.0 is a very early canary build. Expect breaking changes, bugs and unfinished features. It is great for exploring, prototyping and shaping the roadmap, and not yet the place for a production community you cannot afford to break.', - question: 'Is the canary ready for production?', - }, - { - answer: - 'Hosting, a PostgreSQL database, a domain and backups, plus any services you switch on: email delivery, file storage, a search cluster, AI provider usage. Deploy to your own cloud account with the Vercel guide or self-host on a server or in Docker.', - question: 'What do I still pay for?', - }, - { - answer: - 'Not today. There is no managed VitNode cloud or support SLA. The docs cover self-hosting and cloud deployment step by step, and the community on GitHub is where questions land.', - question: 'Do you offer managed hosting or support?', - }, - { - answer: - 'TypeScript end to end: React 19 with TanStack Start on the front, a Hono API, PostgreSQL through Drizzle ORM and Tailwind CSS for styling. Redis and Elasticsearch are optional when you grow.', - question: 'Which stack is underneath?', - }, - { - answer: - 'Yes, on purpose. The repository ships an AGENTS.md with conventions, the entire documentation is available as one llms-full.txt, the API is typed end to end and exposes OpenAPI, and plugin boundaries keep an agent’s changes contained.', - question: 'Can my AI coding agent work with it?', - }, - { - answer: ( - <> - Try it, report a reproducible bug, improve the docs or build a plugin. - If you would rather send coffee than code, donations through{' '} - - GitHub Sponsors - {' '} - keep the project independent and the maintainer awake. Stars are welcome - too. - - ), - question: 'How can I support the project?', - }, -] - -export const FaqSection = () => ( - - - No mysterious asterisk. Just the things people usually ask before they - start. - - - - {FAQS.map(({ answer, question }) => ( - - {question} - - {answer} - - - ))} - - -) diff --git a/apps/web/src/site/home/sections/features-bento.tsx b/apps/web/src/site/home/sections/features-bento.tsx deleted file mode 100644 index 10b04d9d43..0000000000 --- a/apps/web/src/site/home/sections/features-bento.tsx +++ /dev/null @@ -1,159 +0,0 @@ -import type { LucideIcon } from 'lucide-react' - -import { cn } from 'cn' -import { Bell, Boxes, Globe, Radio, Search, Sparkles, Zap } from 'lucide-react' - -import { - AiVisual, - CacheVisual, - ContentEngineVisual, - EventsVisual, - I18nVisual, - RealtimeVisual, - SearchVisual, -} from '@/site/home/illustrations/feature-visuals' -import { - MarketingSection, - SectionHeading, - TextLink, -} from '@/site/marketing/shared' - -interface Feature { - className?: string - docs: string - heading: string - Icon: LucideIcon - linkLabel: string - text: string - title: string - Visual: () => React.ReactNode -} - -const FEATURES: Feature[] = [ - { - className: 'md:col-span-2 lg:row-span-2', - heading: 'Describe it once. Ship the whole thing.', - docs: 'content-engine', - Icon: Boxes, - linkLabel: 'Explore the Content Engine', - text: 'Describe a content type in one file and get the database table, a typed API, admin screens, search indexing and translations. Articles, listings, docs, events: anything with a shape.', - title: 'Content Engine', - Visual: ContentEngineVisual, - }, - { - heading: 'Hello, whole world.', - docs: 'dev/i18n', - Icon: Globe, - linkLabel: 'Read the internationalization guide', - text: 'Every screen, email and content field can speak your members’ language. Add a locale with one command.', - title: 'Internationalization', - Visual: I18nVisual, - }, - { - heading: 'Fast on the page. Fast on the API.', - docs: 'dev/cache', - Icon: Zap, - linkLabel: 'See how caching works', - text: 'A front-end query cache plus an optional Redis layer on the API. Pages stop re-asking for what they already know.', - title: 'Cache', - Visual: CacheVisual, - }, - { - heading: 'One action. Many good reactions.', - docs: 'dev/events', - Icon: Radio, - linkLabel: 'Learn how events work', - text: 'Publish a post and let plugins react: send an email, refresh search, ping members. No spaghetti between features.', - title: 'Events', - Visual: EventsVisual, - }, - { - heading: 'AI on your terms.', - docs: 'dev/ai', - Icon: Sparkles, - linkLabel: 'Build AI features', - text: 'Summaries, drafts, streaming answers and embeddings with any provider through the Vercel AI SDK. You pick the model.', - title: 'AI features', - Visual: AiVisual, - }, - { - className: 'lg:col-span-2', - heading: 'Less “where was that?”', - docs: 'dev/search', - Icon: Search, - linkLabel: 'Set up search and discovery', - text: 'Site-wide search and a discovery feed across every plugin. Postgres out of the box, Elasticsearch when you outgrow it.', - title: 'Search engine', - Visual: SearchVisual, - }, - { - className: 'lg:col-span-2', - heading: 'Good news travels live.', - docs: 'dev/websocket', - Icon: Bell, - linkLabel: 'Read the WebSocket docs', - text: 'One authenticated WebSocket shared by every open tab pushes live toasts and updates from your API today. A notification centre with an inbox is planned.', - title: 'WebSockets & live updates', - Visual: RealtimeVisual, - }, -] - -export const FeaturesBentoSection = () => ( - - - Seven building blocks that usually eat a year of roadmap. Yours on day - one, and they already know how to talk to each other. - - -
- {FEATURES.map( - ({ - className, - heading, - docs, - Icon, - linkLabel, - text, - title, - Visual, - }) => ( -
-
- - - -

{title}

-
- -
- -
- -
-

- {heading} -

-

- {text} -

- - {linkLabel} - -
-
- ), - )} -
-
-) diff --git a/apps/web/src/site/home/sections/final-cta.tsx b/apps/web/src/site/home/sections/final-cta.tsx deleted file mode 100644 index 21b72fd6ff..0000000000 --- a/apps/web/src/site/home/sections/final-cta.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { cn } from 'cn' -import { Sparkles } from 'lucide-react' - -import { MarketingActions, SURFACE } from '@/site/marketing/shared' - -export const FinalCtaSection = () => ( -
-
-
- - - - -

- Your next community starts with a little curiosity. -

-

- Bring an idea today. Bring your people when it is ready. The framework - is free, the docs are friendly and the canary is only a little bit - feathery. -

- -
-
-) diff --git a/apps/web/src/site/home/sections/outcomes.tsx b/apps/web/src/site/home/sections/outcomes.tsx deleted file mode 100644 index e0113ad361..0000000000 --- a/apps/web/src/site/home/sections/outcomes.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import type { LucideIcon } from 'lucide-react' - -import { Link } from '@tanstack/react-router' -import { cn } from 'cn' -import { KeyRound, Rocket, TrendingUp } from 'lucide-react' - -import type { Surface } from '@/site/marketing/shared' - -import { - MarketingSection, - SectionHeading, - SURFACE, -} from '@/site/marketing/shared' - -const OUTCOMES: { - Icon: LucideIcon - surface: Surface - text: string - title: string -}[] = [ - { - Icon: Rocket, - surface: 'tint', - text: 'Accounts, roles, admin screens, translations, search and real-time updates are already built. Your team starts at the part your members will actually notice.', - title: 'Launch in days, not quarters', - }, - { - Icon: KeyRound, - surface: 'dark', - text: 'No monthly licence, no per-seat pricing, no rented platform that can change the rules. Your members, your data and your code live with you.', - title: 'Own the platform, keep the relationship', - }, - { - Icon: TrendingUp, - surface: 'primary', - text: 'Every feature is a plugin, written in TypeScript by your team or your agent. Add a shop, a course or a job board next year without a rewrite.', - title: 'Grow without outgrowing it', - }, -] - -const USE_CASES = [ - { label: 'Help center', slug: 'help-center' }, - { label: 'Membership site', slug: 'membership-site' }, - { label: 'Open-source hub', slug: 'open-source-hub' }, - { label: 'Gaming guild hub', slug: 'gaming-guild' }, - { label: 'Multilingual magazine', slug: 'multilingual-magazine' }, -] - -const ICON_BADGE: Record = { - dark: 'bg-primary/15 text-primary', - primary: 'bg-primary-foreground/15 text-primary-foreground', - soft: 'bg-primary/10 text-primary', - tint: 'bg-primary/10 text-primary', -} - -const BODY: Record = { - dark: 'text-muted-foreground', - primary: 'text-primary-foreground/90', - soft: 'text-muted-foreground', - tint: 'text-muted-foreground', -} - -export const OutcomesSection = () => ( - - - Most community projects die in month three, buried under login forms and - admin tables, or end up renting a platform that owns the relationship. - VitNode ships the boring parts done and hands you the keys. - - -
    - {USE_CASES.map(({ label, slug }) => ( -
  • - - {label} - -
  • - ))} -
- -
    - {OUTCOMES.map(({ Icon, surface, text, title }) => ( -
  • - - - -

    - {title} -

    -

    - {text} -

    -
  • - ))} -
-
-) diff --git a/apps/web/src/site/home/sections/pricing.tsx b/apps/web/src/site/home/sections/pricing.tsx deleted file mode 100644 index 4756dc3f77..0000000000 --- a/apps/web/src/site/home/sections/pricing.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import { Link } from '@tanstack/react-router' -import { buttonVariants } from '@vitnode/core/components/ui/button' -import { cn } from 'cn' -import { Check, Coffee, HeartHandshake } from 'lucide-react' - -import { REPOSITORY_URL, SPONSOR_URL } from '@/site/marketing/links' -import { - BUTTON, - Eyebrow, - GitHubIcon, - MarketingSection, - SURFACE, -} from '@/site/marketing/shared' - -const INCLUDED = [ - 'The whole framework, every feature, no tiers', - 'Commercial use under the MIT licence', - 'Unlimited members, admins and plugins', - 'Self-host or bring your own cloud', -] - -export const PricingSection = () => ( - -
-
- - Pricing -

- Just kidding. VitNode is free and open source. -

- -

- - $0 - - - forever · MIT licence - -

- -

- No plans, no seats, no “contact sales” button. The entire framework for - every member you will ever have. You can still make your contribution: a - pull request, a bug report, or a donation that keeps the maintainer - caffeinated. -

- -
- - - Star on GitHub - - - - Donate - - - - Contribute - -
- -
    - {INCLUDED.map((item) => ( -
  • - - {item} -
  • - ))} -
- -

- Servers, databases and coffee still cost money. VitNode does not. - Donations go through GitHub Sponsors, straight to the people building - it. -

-
- -) diff --git a/apps/web/src/site/home/sections/security.tsx b/apps/web/src/site/home/sections/security.tsx deleted file mode 100644 index a3705d2c95..0000000000 --- a/apps/web/src/site/home/sections/security.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import type { LucideIcon } from 'lucide-react' - -import { cn } from 'cn' -import { Bot, Fingerprint, Gauge, KeyRound, ShieldUser } from 'lucide-react' - -import { GatesVisual } from '@/site/home/illustrations/gates' -import { ActionLink, SectionHeading, SURFACE } from '@/site/marketing/shared' - -const GUARDS: { Icon: LucideIcon; text: string; title: string }[] = [ - { - Icon: KeyRound, - text: 'Google, Discord, Facebook or your own OAuth2 provider. One config line, buttons appear.', - title: 'Single sign-on', - }, - { - Icon: Bot, - text: 'Cloudflare Turnstile or reCAPTCHA v3 guard sign-up, password reset and any route you mark.', - title: 'Captcha', - }, - { - Icon: Gauge, - text: 'Brute-force attempts meet a polite 429. Counters share across instances through Redis.', - title: 'Rate limiting', - }, - { - Icon: Fingerprint, - text: 'HttpOnly cookies, hashed opaque tokens and a separate session for the AdminCP.', - title: 'Hardened sessions', - }, - { - Icon: ShieldUser, - text: 'Per-plugin, per-action grants for administrators and moderators, enforced on the API and reflected in the UI.', - title: 'Staff permissions', - }, -] - -export const SecuritySection = () => ( -
-
-
- -
- -
- - -
    - {GUARDS.map(({ Icon, text, title }) => ( -
  • - - - -
    -

    {title}

    -

    - {text} -

    -
    -
  • - ))} -
- - - How sessions and permissions work - -
-
-
-) diff --git a/apps/web/src/site/home/sections/showcase-carousel.tsx b/apps/web/src/site/home/sections/showcase-carousel.tsx deleted file mode 100644 index 3a755412a8..0000000000 --- a/apps/web/src/site/home/sections/showcase-carousel.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import type { CarouselApi } from '@vitnode/core/components/ui/carousel' - -import { buttonVariants } from '@vitnode/core/components/ui/button' -import { - Carousel, - CarouselContent, - CarouselItem, - CarouselNext, - CarouselPrevious, -} from '@vitnode/core/components/ui/carousel' -import { cn } from 'cn' -import { useEffect, useState } from 'react' - -import { SafariFrame } from '@/site/marketing/safari-frame' -import { SCREENS } from '@/site/marketing/screens' - -import { SLIDES } from './showcase-slides' - -export const ShowcaseCarousel = () => { - const [api, setApi] = useState() - const [current, setCurrent] = useState(0) - - useEffect(() => { - if (!api) return - - const onSelect = () => { - setCurrent(api.selectedScrollSnap()) - } - - api.on('select', onSelect) - - return () => { - api.off('select', onSelect) - } - }, [api]) - - return ( -
-
- {SLIDES.map(({ screen }, index) => ( - - ))} -
- - - - {SLIDES.map(({ caption, screen }) => ( - -
- -
- {caption} -
-
-
- ))} -
- - -
-
- ) -} diff --git a/apps/web/src/site/home/sections/showcase-slides.tsx b/apps/web/src/site/home/sections/showcase-slides.tsx deleted file mode 100644 index 7b18c43e78..0000000000 --- a/apps/web/src/site/home/sections/showcase-slides.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import type { ScreenKey } from '@/site/marketing/screens' - -export const SLIDES: { caption: string; screen: ScreenKey }[] = [ - { - caption: - 'A dashboard you rearrange yourself. Plugins bring their own widgets, and yes, that notification widget really pushes a live toast to a member.', - screen: 'dashboard', - }, - { - caption: - 'Every screen here came from one TypeScript definition. Fields, validation, rich text, uploads and a language switch per field, with zero UI code.', - screen: 'contentEditor', - }, - { - caption: - 'AI, WebSockets, Redis, email, storage, cron and queues report their status in one place. Test buttons included, guessing not required.', - screen: 'integrations', - }, - { - caption: - 'Roles with colours, member counts and per-plugin staff permissions. The four defaults are seeded for you; the rest is your call.', - screen: 'roles', - }, - { - caption: - 'The member side is ready too: sign-in, registration, password reset and social login, with captcha waiting in the wings.', - screen: 'login', - }, -] diff --git a/apps/web/src/site/home/sections/showcase.tsx b/apps/web/src/site/home/sections/showcase.tsx deleted file mode 100644 index 21cf64229e..0000000000 --- a/apps/web/src/site/home/sections/showcase.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import { lazy, Suspense, useEffect, useRef, useState } from 'react' - -import { SafariFrame } from '@/site/marketing/safari-frame' -import { SCREENS } from '@/site/marketing/screens' -import { - ActionLink, - MarketingSection, - SectionHeading, - SectionRow, -} from '@/site/marketing/shared' - -import { SLIDES } from './showcase-slides' - -const ShowcaseCarousel = lazy(async () => ({ - default: (await import('./showcase-carousel')).ShowcaseCarousel, -})) - -const ShowcaseStill = () => { - const [{ caption, screen }] = SLIDES - - return ( -
- -
- {caption} -
-
- ) -} - -const useIsNearViewport = ( - ref: React.RefObject, -) => { - const [isNear, setIsNear] = useState(false) - - useEffect(() => { - const element = ref.current - - if (!element || isNear) return - - const observer = new IntersectionObserver( - ([entry]) => { - if (!entry.isIntersecting) return - - setIsNear(true) - observer.disconnect() - }, - { rootMargin: '600px 0px' }, - ) - - observer.observe(element) - - return () => { - observer.disconnect() - } - }, [isNear, ref]) - - return isNear -} - -const ShowcaseStage = () => { - const ref = useRef(null) - const isNear = useIsNearViewport(ref) - - return ( -
- {isNear ? ( - }> - - - ) : ( - - )} -
- ) -} - -export const ShowcaseSection = () => ( - - - Explore the AdminCP - - } - > - - Every plugin gets a home in the Admin Control Panel: users, roles, - staff, content, integrations, cron, queues and logs. One place, fewer - “where do I change this?” messages. Flip through a few real screens. - - - - - -) diff --git a/apps/web/src/site/marketing/marketing.css b/apps/web/src/site/marketing/marketing.css index 7e0d2987f7..90534e21f7 100644 --- a/apps/web/src/site/marketing/marketing.css +++ b/apps/web/src/site/marketing/marketing.css @@ -1,76 +1,3 @@ -@keyframes mk-orbit { - to { - transform: rotate(360deg); - } -} - -@keyframes mk-orbit-reverse { - to { - transform: rotate(-360deg); - } -} - -@keyframes mk-dash { - to { - stroke-dashoffset: -32; - } -} - -@keyframes mk-breathe { - 0%, - 100% { - transform: scale(1); - opacity: 0.55; - } - 50% { - transform: scale(1.1); - opacity: 0.9; - } -} - -@keyframes mk-ring { - 0%, - 55%, - 100% { - transform: rotate(0deg); - } - 8% { - transform: rotate(16deg); - } - 16% { - transform: rotate(-14deg); - } - 24% { - transform: rotate(10deg); - } - 32% { - transform: rotate(-7deg); - } - 40% { - transform: rotate(4deg); - } - 48% { - transform: rotate(-2deg); - } -} - -@keyframes mk-badge { - 0%, - 40%, - 100% { - transform: scale(1); - } - 10% { - transform: scale(1.35); - } - 20% { - transform: scale(0.95); - } - 30% { - transform: scale(1.15); - } -} - @keyframes mk-blink { 0%, 49% { @@ -82,303 +9,123 @@ } } -@keyframes mk-toast { +@keyframes mk-drift { 0%, - 8% { - transform: translateX(48px); - opacity: 0; - } - 18%, - 72% { - transform: translateX(0); - opacity: 1; - } - 82%, 100% { - transform: translateX(48px); - opacity: 0; - } -} - -@keyframes mk-draw { - 0%, - 10% { - stroke-dashoffset: 100; + transform: translate(0, 0) scale(1); } - 45%, - 75% { - stroke-dashoffset: 0; + 33% { + transform: translate(24px, -18px) scale(1.06); } - 100% { - stroke-dashoffset: -100; + 66% { + transform: translate(-18px, 14px) scale(0.96); } } -@keyframes mk-grow { - 0%, - 8% { - transform: scaleX(0); - } - 55%, - 85% { - transform: scaleX(1); - } - 100% { - transform: scaleX(0); - } +.mk-anim-blink { + animation: mk-blink 1s steps(1) infinite; } -@keyframes mk-travel { - to { - offset-distance: 100%; - } +.mk-anim-drift { + animation: mk-drift 14s ease-in-out infinite; } -@keyframes mk-twinkle { - 0%, - 100% { - transform: scale(0.6); - opacity: 0.25; - } - 50% { - transform: scale(1.1); - opacity: 1; - } +.mk-grid { + background-image: + linear-gradient( + to right, + color-mix(in oklab, var(--border) 80%, transparent) 1px, + transparent 1px + ), + linear-gradient( + to bottom, + color-mix(in oklab, var(--border) 80%, transparent) 1px, + transparent 1px + ); + background-size: 44px 44px; + mask-image: radial-gradient( + ellipse 80% 70% at 50% 40%, + black 30%, + transparent 100% + ); } -@keyframes mk-appear { - 0%, - 12% { - transform: translateY(10px); - opacity: 0; - } - 28%, - 82% { - transform: translateY(0); - opacity: 1; - } - 94%, - 100% { - transform: translateY(0); - opacity: 0; - } +.mk-section-anchor { + scroll-margin-top: 5rem; } -@keyframes mk-scan { - 0% { - transform: translateY(-70px); - opacity: 0; - } - 15%, - 85% { - opacity: 1; +@keyframes mk-bento-zap { + from { + stroke-dashoffset: 12; } - 100% { - transform: translateY(70px); - opacity: 0; + to { + stroke-dashoffset: -100; } } -@keyframes mk-led { - 0%, - 100% { - opacity: 0.2; - } - 50% { - opacity: 1; - } +.mk-anim-bento-zap { + animation: mk-bento-zap 700ms cubic-bezier(0.45, 0, 0.55, 1) both; } -@keyframes mk-ripple { +@keyframes mk-bento-trace { 0% { - transform: scale(0.4); - opacity: 0.8; + stroke-dashoffset: 16px; } + 62%, 100% { - transform: scale(2.6); - opacity: 0; + stroke-dashoffset: calc(var(--len) * -1px); } } -@keyframes mk-cycle-3 { - 0%, - 28% { - opacity: 1; - } - 33%, - 100% { - opacity: 0; - } +.mk-anim-bento-trace { + animation: mk-bento-trace var(--dur, 3s) cubic-bezier(0.45, 0, 0.55, 1) + infinite both; } -@keyframes mk-cycle-4 { +@keyframes mk-bento-bob { 0%, - 21% { - opacity: 1; - } - 25%, 100% { - opacity: 0; - } -} - -@keyframes mk-cycle-5 { - 0%, - 16% { - opacity: 1; + transform: translateY(0); } - 20%, - 100% { - opacity: 0; + 50% { + transform: translateY(-5px); } } -@keyframes mk-rise-4 { - 0% { - opacity: 0; - transform: translateY(6px); - } - 4%, - 21% { - opacity: 1; - transform: translateY(0); - } - 25%, - 100% { - opacity: 0; - transform: translateY(-6px); - } +.mk-anim-bento-bob { + animation: mk-bento-bob 3.4s ease-in-out infinite; } -@keyframes mk-drift { +@keyframes mk-bento-swing { 0%, 100% { - transform: translate(0, 0) scale(1); + transform: rotate(0); } - 33% { - transform: translate(24px, -18px) scale(1.06); + 15% { + transform: rotate(14deg); } - 66% { - transform: translate(-18px, 14px) scale(0.96); + 35% { + transform: rotate(-12deg); + } + 55% { + transform: rotate(8deg); + } + 75% { + transform: rotate(-4deg); } } -.mk-origin-center { - transform-box: fill-box; - transform-origin: center; -} - -.mk-origin-top { - transform-box: fill-box; - transform-origin: 50% 0%; -} - -.mk-origin-left { - transform-box: fill-box; - transform-origin: 0% 50%; -} - -.mk-anim-dash { - stroke-dasharray: 6 10; - animation: mk-dash 1.4s linear infinite; -} - -.mk-anim-breathe { - animation: mk-breathe 6s ease-in-out infinite; -} - -.mk-anim-ring { - animation: mk-ring 3.2s ease-in-out infinite; -} - -.mk-anim-badge { - animation: mk-badge 3.2s ease-in-out infinite; -} - -.mk-anim-blink { - animation: mk-blink 1s steps(1) infinite; -} - -.mk-anim-toast { - animation: mk-toast 6s ease-in-out infinite; -} - -.mk-anim-draw { - stroke-dasharray: 100; - animation: mk-draw 4s ease-in-out infinite; -} - -.mk-anim-grow { - animation: mk-grow 5s ease-in-out infinite; -} - -.mk-anim-twinkle { - animation: mk-twinkle 2.4s ease-in-out infinite; -} - -.mk-anim-appear { - animation: mk-appear 5s ease-in-out infinite; -} - -.mk-anim-scan { - animation: mk-scan 3.5s ease-in-out infinite; -} - -.mk-anim-led { - animation: mk-led 1.6s ease-in-out infinite; -} - -.mk-anim-ripple { - animation: mk-ripple 3s ease-out infinite; -} - -.mk-anim-cycle-3 { - animation: mk-cycle-3 6s ease-in-out infinite; -} - -.mk-anim-cycle-4 { - animation: mk-cycle-4 8s ease-in-out infinite; -} - -.mk-anim-cycle-5 { - animation: mk-cycle-5 10s ease-in-out infinite; -} - -.mk-anim-rise-4 { - animation: mk-rise-4 8s ease-in-out infinite; -} - -.mk-anim-drift { - animation: mk-drift 14s ease-in-out infinite; -} - -.mk-anim-travel { - offset-rotate: 0deg; - animation: mk-travel 9s ease-in-out infinite; -} - -.mk-grid { - background-image: - linear-gradient( - to right, - color-mix(in oklab, var(--border) 80%, transparent) 1px, - transparent 1px - ), - linear-gradient( - to bottom, - color-mix(in oklab, var(--border) 80%, transparent) 1px, - transparent 1px - ); - background-size: 44px 44px; - mask-image: radial-gradient( - ellipse 80% 70% at 50% 40%, - black 30%, - transparent 100% - ); +.mk-anim-bento-swing { + transform-origin: 50% 2px; + animation: mk-bento-swing 900ms ease-in-out both; } -.mk-section-anchor { - scroll-margin-top: 5rem; +@media (prefers-reduced-motion: reduce) { + .mk-anim-bento-trace, + .mk-anim-bento-zap { + opacity: 0; + animation: none; + } } [data-offscreen] [class*='mk-anim-'] { @@ -435,47 +182,6 @@ } } -@keyframes mk-cycle-2 { - 0%, - 46% { - opacity: 1; - } - 50%, - 100% { - opacity: 0; - } -} - -@keyframes mk-cycle-7 { - 0%, - 12% { - opacity: 1; - } - 14.5%, - 100% { - opacity: 0; - } -} - -.mk-anim-cycle-7 { - animation: mk-cycle-7 14s ease-in-out infinite; -} - -@keyframes mk-cycle-6 { - 0%, - 14% { - opacity: 1; - } - 16.6%, - 100% { - opacity: 0; - } -} - -.mk-anim-cycle-6 { - animation: mk-cycle-6 12s ease-in-out infinite; -} - @keyframes mk-plug { 0% { offset-distance: 0%; @@ -520,315 +226,6 @@ animation: mk-plug-nav 12s ease-in-out infinite; } -@keyframes mk-scroll-log { - to { - transform: translateY(-104px); - } -} - -.mk-anim-scroll-log { - animation: mk-scroll-log 8s linear infinite; -} - -@keyframes mk-step-1 { - 0%, - 4% { - opacity: 0; - transform: translateY(6px); - } - 8%, - 90% { - opacity: 1; - transform: translateY(0); - } - 95%, - 100% { - opacity: 0; - } -} - -@keyframes mk-step-2 { - 0%, - 18% { - opacity: 0; - transform: translateY(6px); - } - 22%, - 90% { - opacity: 1; - transform: translateY(0); - } - 95%, - 100% { - opacity: 0; - } -} - -@keyframes mk-step-3 { - 0%, - 32% { - opacity: 0; - transform: translateY(6px); - } - 36%, - 90% { - opacity: 1; - transform: translateY(0); - } - 95%, - 100% { - opacity: 0; - } -} - -@keyframes mk-step-4 { - 0%, - 46% { - opacity: 0; - transform: translateY(6px); - } - 50%, - 90% { - opacity: 1; - transform: translateY(0); - } - 95%, - 100% { - opacity: 0; - } -} - -@keyframes mk-step-5 { - 0%, - 60% { - opacity: 0; - transform: translateY(6px); - } - 64%, - 90% { - opacity: 1; - transform: translateY(0); - } - 95%, - 100% { - opacity: 0; - } -} - -@keyframes mk-step-6 { - 0%, - 74% { - opacity: 0; - transform: translateY(6px); - } - 78%, - 90% { - opacity: 1; - transform: translateY(0); - } - 95%, - 100% { - opacity: 0; - } -} - -.mk-anim-step-1 { - animation: mk-step-1 14s ease-out infinite; -} - -.mk-anim-step-2 { - animation: mk-step-2 14s ease-out infinite; -} - -.mk-anim-step-3 { - animation: mk-step-3 14s ease-out infinite; -} - -.mk-anim-step-4 { - animation: mk-step-4 14s ease-out infinite; -} - -.mk-anim-step-5 { - animation: mk-step-5 14s ease-out infinite; -} - -.mk-anim-step-6 { - animation: mk-step-6 14s ease-out infinite; -} - -@keyframes mk-progress { - 0%, - 4% { - transform: scaleX(0); - } - 88%, - 94% { - transform: scaleX(1); - } - 100% { - transform: scaleX(0); - } -} - -.mk-anim-progress { - animation: mk-progress 14s linear infinite; -} - -@keyframes mk-file-1 { - 0%, - 4% { - opacity: 0; - } - 8%, - 20% { - opacity: 1; - } - 24%, - 100% { - opacity: 0; - } -} - -@keyframes mk-file-2 { - 0%, - 32% { - opacity: 0; - } - 36%, - 48% { - opacity: 1; - } - 52%, - 100% { - opacity: 0; - } -} - -@keyframes mk-file-3 { - 0%, - 46% { - opacity: 0; - } - 50%, - 62% { - opacity: 1; - } - 66%, - 100% { - opacity: 0; - } -} - -@keyframes mk-file-4 { - 0%, - 60% { - opacity: 0; - } - 64%, - 76% { - opacity: 1; - } - 80%, - 100% { - opacity: 0; - } -} - -.mk-anim-file-1 { - animation: mk-file-1 14s ease-out infinite; -} - -.mk-anim-file-2 { - animation: mk-file-2 14s ease-out infinite; -} - -.mk-anim-file-3 { - animation: mk-file-3 14s ease-out infinite; -} - -.mk-anim-file-4 { - animation: mk-file-4 14s ease-out infinite; -} - -@keyframes mk-spin { - to { - transform: rotate(360deg); - } -} - -.mk-anim-spin { - animation: mk-spin 1.2s linear infinite; -} - -@keyframes mk-gate { - 0%, - 20% { - opacity: 0.2; - } - 24%, - 60% { - opacity: 1; - } - 64%, - 100% { - opacity: 0.2; - } -} - -.mk-anim-gate { - animation: mk-gate 6s ease-in-out infinite; -} - -@keyframes mk-bot-bounce { - 0% { - transform: translateY(-64px); - opacity: 0; - } - 8% { - opacity: 1; - } - 34% { - transform: translateY(0); - } - 42% { - transform: translateY(-12px); - } - 48% { - transform: translateY(-4px); - } - 62% { - transform: translateY(-80px); - opacity: 0; - } - 100% { - transform: translateY(-80px); - opacity: 0; - } -} - -.mk-anim-bot-bounce { - animation: mk-bot-bounce 6s ease-in-out infinite; -} - -@keyframes mk-bot-block { - 0%, - 32% { - opacity: 0; - } - 36%, - 58% { - opacity: 1; - } - 64%, - 100% { - opacity: 0; - } -} - -.mk-anim-bot-block { - animation: mk-bot-block 6s ease-in-out infinite; -} - .mk-integration-comb { --hex-w: min(8rem, (100vw - 2rem) / 4.35); --hex-gap: calc(var(--hex-w) * 0.1); diff --git a/apps/web/src/site/marketing/safari-frame.tsx b/apps/web/src/site/marketing/safari-frame.tsx deleted file mode 100644 index 3f059b565f..0000000000 --- a/apps/web/src/site/marketing/safari-frame.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import { cn } from 'cn' -import { - ChevronLeft, - ChevronRight, - Copy, - Lock, - PanelLeft, - Plus, - RotateCw, - Share, -} from 'lucide-react' - -import type { Screen } from './screens' - -import { ScreenShot } from './screen-frame' - -const TRAFFIC_LIGHTS = [ - 'bg-red-400 ring-red-500/40', - 'bg-amber-400 ring-amber-500/40', - 'bg-emerald-400 ring-emerald-500/40', -] - -const ToolbarIcon = ({ Icon }: { Icon: React.ElementType }) => ( - -) - -export const SafariFrame = ({ - className, - priority = false, - screen, -}: { - className?: string - priority?: boolean - screen: Screen -}) => ( -
-
-
- - {TRAFFIC_LIGHTS.map((light) => ( - - ))} - - - - - - - - -
- -
- - - {screen.url} - - -
- -
- - - -
-
- - -
-) diff --git a/packages/vitnode/src/api/lib/updates/status.test.ts b/packages/vitnode/src/api/lib/updates/status.test.ts index 2b5fdc05fc..5f7695b36c 100644 --- a/packages/vitnode/src/api/lib/updates/status.test.ts +++ b/packages/vitnode/src/api/lib/updates/status.test.ts @@ -315,6 +315,7 @@ describe("summarizeUpdates", () => { checkedComponents: 4, core: { latestVersion: "2.1.0" }, pluginUpdatesAvailable: 2, + totalComponents: 4, unconfirmedComponents: 0, updatesAvailable: 3, }); @@ -354,6 +355,7 @@ describe("summarizeUpdates", () => { notCheckedComponents: 1, oldestSuccessAt: null, stale: true, + totalComponents: 6, unconfirmedComponents: 3, }); }); diff --git a/packages/vitnode/src/api/lib/updates/status.ts b/packages/vitnode/src/api/lib/updates/status.ts index d5cf37e4a0..3ae1ac0eea 100644 --- a/packages/vitnode/src/api/lib/updates/status.ts +++ b/packages/vitnode/src/api/lib/updates/status.ts @@ -160,6 +160,7 @@ export interface UpdatesSummary { pluginUpdatesAvailable: number; running: boolean; stale: boolean; + totalComponents: number; unconfirmedComponents: number; updatesAvailable: number; } @@ -211,6 +212,7 @@ export const summarizeUpdates = (overview: UpdatesOverview): UpdatesSummary => { pluginUpdatesAvailable: plugins.filter(isUpdateAvailable).length, running: overview.running, stale: components.some(component => component.stale), + totalComponents: components.length, unconfirmedComponents: components.filter( component => component.result?.status !== "up_to_date" && diff --git a/packages/vitnode/src/api/modules/admin/plugins/routes/updates.schema.ts b/packages/vitnode/src/api/modules/admin/plugins/routes/updates.schema.ts index 4bd83277e9..d720e671b1 100644 --- a/packages/vitnode/src/api/modules/admin/plugins/routes/updates.schema.ts +++ b/packages/vitnode/src/api/modules/admin/plugins/routes/updates.schema.ts @@ -74,6 +74,7 @@ export const serializeComponentUpdate = ( export const updatesSummarySchema = z.object({ enabled: z.boolean(), running: z.boolean(), + totalComponents: z.number(), checkedComponents: z.number(), updatesAvailable: z.number(), pluginUpdatesAvailable: z.number(), diff --git a/packages/vitnode/src/components/side-nav.tsx b/packages/vitnode/src/components/side-nav.tsx index 35780dc2cd..32de015c55 100644 --- a/packages/vitnode/src/components/side-nav.tsx +++ b/packages/vitnode/src/components/side-nav.tsx @@ -45,7 +45,7 @@ export const SideNavGroup = ({ children: React.ReactNode; title?: string; }) => ( -
+
{title ? (

{title} diff --git a/packages/vitnode/src/locales/en.json b/packages/vitnode/src/locales/en.json index d308a1890c..8bd07f85b4 100644 --- a/packages/vitnode/src/locales/en.json +++ b/packages/vitnode/src/locales/en.json @@ -2665,32 +2665,67 @@ }, "system-updates": { "title": "System updates", - "desc": "Core version, available updates and security warnings from the stored update checks.", + "desc": "Newer releases and security fixes for VitNode and your plugins.", "rows": { - "status": "Status", "core": "VitNode", "plugin_updates": "Plugin updates", - "security": "Security", - "last_checked": "Last checked" + "security": "Security fixes", + "no_info": "No update info" }, - "status": { - "security": "Security fix needed", - "updates": "Updates available", - "up_to_date": "Up to date", - "unconfirmed": "{count, plural, one {# package not confirmed} other {# packages not confirmed}}", - "running": "Checking...", - "not_checked": "Not checked yet", - "unavailable": "No update sources", - "disabled": "Checks turned off" + "verdict": { + "security": { + "title": "Security fix needed", + "desc": "{count, plural, one {# package has a known security issue that a newer release fixes. Update as soon as you can.} other {# packages have known security issues that a newer release fixes. Update as soon as you can.}}" + }, + "updates": { + "title": "Updates available", + "desc": "{count, plural, one {# newer release is ready to install.} other {# newer releases are ready to install.}}" + }, + "up_to_date": { + "title": "Everything is up to date", + "desc": "{count, plural, one {Your package runs its newest release.} other {All # packages run their newest release.}}" + }, + "unconfirmed": { + "title": "No updates found", + "desc": "{verified} of {total} packages are up to date. {count, plural, one {The other one publishes no update info, so VitNode can't check it.} other {The other # publish no update info, so VitNode can't check them.}}" + }, + "no_info": { + "title": "Can't check for updates", + "desc": "{count, plural, one {Your package publishes no update info, so VitNode can't tell whether a newer version exists.} other {None of your # packages publishes update info, so VitNode can't tell whether newer versions exist.}}" + }, + "unavailable": { + "title": "Can't check for updates", + "desc": "{count, plural, one {Your package publishes no update info, so VitNode can't tell whether a newer version exists.} other {None of your # packages publishes update info, so VitNode can't tell whether newer versions exist.}}" + }, + "running": { + "title": "Checking for updates...", + "desc": "Results show up here when the check finishes." + }, + "not_checked": { + "title": "Not checked yet", + "desc": "VitNode checks once a day. To check right now, open Plugins." + }, + "disabled": { + "title": "Update checks are off", + "desc": "This site sets updates.enabled to false in its API config, so nothing is checked." + } }, "core_update": "update to {version} available", - "security_affected": "{count, plural, one {# package} other {# packages}}", - "security_none": "None", - "last_checked": { - "running": "In progress", - "failed": "Failed ", - "stale": "Out of date", - "never": "Never" + "plugin_updates": { + "none": "None", + "available": "{count} available" + }, + "security": { + "affected": "{count, plural, one {Needed for # package} other {Needed for # packages}}", + "none": "None found" + }, + "no_info_count": "{total, plural, one {{count} of # package} other {{count} of # packages}}", + "checked": { + "running": "Checking now...", + "failed": "Last check failed ", + "stale": "Checked , needs a recheck", + "never": "Never checked", + "at": "Checked " }, "unknown": "Unknown", "load_error": "Could not load the update summary. The Plugins page still shows every result.", diff --git a/packages/vitnode/src/routes.tsx b/packages/vitnode/src/routes.tsx index 71d79028cf..14ed175470 100644 --- a/packages/vitnode/src/routes.tsx +++ b/packages/vitnode/src/routes.tsx @@ -7,10 +7,17 @@ import { import { contentListRouteSearch } from "./tanstack/admin/content/route-search"; import { normalizeCronRouteSearch } from "./tanstack/admin/cron/route-search"; import { - DebugFramePending, + DebugAiPending, + DebugCachePending, + DebugCronPending, DebugLayoutPending, + DebugLogsPending, DebugOverviewPending, - DebugTablePending, + DebugPluginsPending, + DebugQueuePending, + DebugSearchPending, + DebugServicesPending, + DebugVitnodePending, } from "./tanstack/admin/debug/pending"; import { ADMIN_DEBUG_NAMESPACES } from "./tanstack/admin/debug/route"; import { normalizeDebugLogsSearch } from "./tanstack/admin/debug/route-search"; @@ -280,43 +287,43 @@ export const routes = defineRoutes([ }), page("vitnode", { component: lazy(() => import("./pages/admin/debug/vitnode")), - pendingComponent: DebugFramePending, + pendingComponent: DebugVitnodePending, }), page("plugins", { component: lazy(() => import("./pages/admin/debug/plugins")), - pendingComponent: DebugFramePending, + pendingComponent: DebugPluginsPending, }), page("cron", { component: lazy(() => import("./pages/admin/debug/cron")), - pendingComponent: DebugTablePending, + pendingComponent: DebugCronPending, search: normalizeCronRouteSearch, }), page("cache", { component: lazy(() => import("./pages/admin/debug/cache")), - pendingComponent: DebugFramePending, + pendingComponent: DebugCachePending, }), page("search", { component: lazy(() => import("./pages/admin/debug/search")), - pendingComponent: DebugTablePending, + pendingComponent: DebugSearchPending, search: normalizeSearchIndexRouteSearch, }), page("services", { component: lazy(() => import("./pages/admin/debug/services")), - pendingComponent: DebugFramePending, + pendingComponent: DebugServicesPending, }), page("logs", { component: lazy(() => import("./pages/admin/debug/logs")), - pendingComponent: DebugTablePending, + pendingComponent: DebugLogsPending, search: normalizeDebugLogsSearch, }), page("queue", { component: lazy(() => import("./pages/admin/debug/queue")), - pendingComponent: DebugTablePending, + pendingComponent: DebugQueuePending, search: normalizeQueueRouteSearch, }), page("ai", { component: lazy(() => import("./pages/admin/debug/ai")), - pendingComponent: DebugTablePending, + pendingComponent: DebugAiPending, search: normalizeAiHistoryRouteSearch, }), ], diff --git a/packages/vitnode/src/tanstack/admin/debug/pending.tsx b/packages/vitnode/src/tanstack/admin/debug/pending.tsx index 3766f3952c..24e8c116c5 100644 --- a/packages/vitnode/src/tanstack/admin/debug/pending.tsx +++ b/packages/vitnode/src/tanstack/admin/debug/pending.tsx @@ -1,122 +1,366 @@ +import { useRouterState } from "@tanstack/react-router"; import { cn } from "cn"; +import { DataTableSkeleton } from "@/components/table/data-table-content"; import { Skeleton } from "@/components/ui/skeleton"; +import type { DebugTab } from "./route-search"; + import { PendingFrame, - PendingHeading, pendingRowKeys, } from "../../pending/route-pending-skeleton"; +import { debugTabFromPath } from "./route-search"; + +type FrameRowShape = "check" | "fact" | "issue" | "item" | "link" | "service"; + +const FRAME_ROW_WIDTHS = ["w-24", "w-32", "w-20", "w-28", "w-36", "w-16"]; + +const rowWidth = (key: number) => + FRAME_ROW_WIDTHS[key % FRAME_ROW_WIDTHS.length]; + +const Line = ({ className }: { className: string }) => ( + + + +); + +const TwoLines = ({ index }: { index: number }) => ( + + + + + + +); + +const FramePendingRow = ({ + index, + shape, +}: { + index: number; + shape: FrameRowShape; +}) => { + if (shape === "fact") { + return ( +

+ + +
+ ); + } + + if (shape === "check") { + return ( +
+ + +
+ ); + } + + if (shape === "link") { + return ( +
+ + + + + + + + +
+ ); + } -const SectionHeadingPending = () => ( -