- {(icon || sectionPath.length > 0) && (
+ {(Boolean(icon) || sectionPath.length > 0) && (
{icon && (
{
- const { query, search, setSearch } = useDocsSearch({
+ const { data, isLoading, onSearchChange, search } = useFetchSearch({
api: DOCS_SEARCH_PATH,
- type: 'fetch',
})
return (
@@ -33,7 +32,7 @@ const DocsSearchDialog = (props: SharedProps) => {
-
+
)
diff --git a/apps/web/src/locales/site/en.json b/apps/web/src/locales/site/en.json
index 0d468d065f..77bcc27301 100644
--- a/apps/web/src/locales/site/en.json
+++ b/apps/web/src/locales/site/en.json
@@ -5,18 +5,227 @@
"canary": "VitNode 2.0 is an early canary",
"description": "Publish content, bring members together and build the features your community needs on an open-source platform you control.",
"explore": "Explore what you can build",
- "preview": {
- "alt": "The AdminCP dashboard with a sidebar listing Core, Blog and Example plugin sections, a private notes widget and a send-notification widget.",
- "members": "{count, plural, one {# member} other {# members}}",
- "toast_meta": "Live toast · just now",
- "toast_title": "Welcome aboard, {name}!"
- },
"see_product": "See the product",
"title": "A home for your people , built around your idea.",
"trust": {
"agents": "Made for AI coding agents",
"license": "MIT licence",
"open_source": "Open source"
+ },
+ "wall": {
+ "ai": {
+ "alt_text": "Alt text for images",
+ "budget": "Budget left",
+ "desc": "Points your members spent on AI features this month.",
+ "models": "{count, plural, one {# model} other {# models}}",
+ "points": "Points used",
+ "requests": "Requests",
+ "summaries": "Summaries",
+ "translate": "Translate articles"
+ },
+ "article": {
+ "back": "Back",
+ "choose": "Choose file",
+ "content": "Content",
+ "content_value": "Forty-two members, one rooftop and far too much pizza. Here is everything we shipped together…",
+ "cover": "Cover image",
+ "cover_desc": "JPG, PNG, WEBP, AVIF · max 5 MB",
+ "desc": "Add a new Article.",
+ "draft": "Save as draft",
+ "drop": "Drop a file here",
+ "publish": "Publish",
+ "title": "Create Article",
+ "title_label": "Title",
+ "title_value": "Our summer meetup recap",
+ "url": "Friendly URL"
+ },
+ "categories": {
+ "announcements": "Announcements",
+ "count": "{count, plural, one {# article} other {# articles}}",
+ "create": "New category",
+ "desc": "Group your blog articles so readers find their way.",
+ "events": "Events",
+ "guides": "Guides & tutorials",
+ "releases": "Release notes",
+ "spotlight": "Community spotlight"
+ },
+ "common": {
+ "active": "Active",
+ "english": "English",
+ "optional": "Optional",
+ "read_more": "Read more"
+ },
+ "cron": {
+ "daily": "Daily",
+ "desc": "Background tasks and when they run next.",
+ "digest": "Send weekly digest",
+ "failed": "Failed",
+ "files": "Purge deleted files",
+ "healthy": "Healthy",
+ "hourly": "Every hour",
+ "in_hours": "in {count} h",
+ "in_minutes": "in {count} min",
+ "job": "Job",
+ "last_tick": "Last tick {count, plural, one {# second} other {# seconds}} ago",
+ "next_run": "Next run",
+ "running": "Running",
+ "scheduler": "Scheduler is running",
+ "search": "Rebuild search index",
+ "sessions": "Clear expired sessions",
+ "six_hours": "Every 6 hours",
+ "status": "Status",
+ "tomorrow": "Tomorrow",
+ "weekly": "Weekly"
+ },
+ "dashboard": {
+ "dev_mode": "Development Mode",
+ "edit_layout": "Edit layout",
+ "message": "Title",
+ "message_value": "Hello from the admin 👋",
+ "notes_desc": "A private scratchpad. Only you can see it.",
+ "notes_placeholder": "Anything worth remembering…",
+ "notes_title": "Notes",
+ "notify_desc": "Pushes a toast to the user in real time, on every browser where they are signed in.",
+ "notify_title": "Send a notification",
+ "send": "Send",
+ "user_id": "User ID",
+ "version": "Version: {version}"
+ },
+ "files": {
+ "desc": "Everything your members and staff have uploaded.",
+ "upload": "Upload"
+ },
+ "integrations": {
+ "ai": "Text generation, structured output, and embeddings via the Vercel AI SDK.",
+ "cron": "Scheduled background tasks such as clearing expired sessions and tokens.",
+ "desc": "Status of the core services that power this instance.",
+ "email": "Transactional emails such as sign-up confirmations and password resets.",
+ "email_title": "Email",
+ "queue": "Background jobs enqueued in the database and drained by the cron worker.",
+ "redis": "Shared cache and rate-limiter storage. Falls back to in-memory when off.",
+ "title": "Integrations",
+ "unreachable": "Unreachable",
+ "websocket": "Live updates and notifications pushed to connected clients in real time."
+ },
+ "login": {
+ "desc": "Welcome back! Sign in to your account.",
+ "email": "Email",
+ "forgot": "Forgot password?",
+ "no_account": "Don’t have an account?",
+ "password": "Password",
+ "sign_up": "Sign up.",
+ "submit": "Login",
+ "title": "Login"
+ },
+ "moderators": {
+ "articles": "Manage articles",
+ "articles_desc": "Create, edit and publish articles",
+ "desc": "Choose what moderators can do in the AdminCP.",
+ "layout": "Edit layout",
+ "layout_desc": "Change the menu and dashboard widgets",
+ "members": "Moderate members",
+ "members_desc": "Ban, warn and edit member profiles",
+ "roles": "Manage roles",
+ "roles_desc": "Change roles and staff access",
+ "usage": "View AI usage",
+ "usage_desc": "See points and budgets"
+ },
+ "nav": {
+ "admins": "Administrators",
+ "advanced": "Advanced",
+ "ai": "Artificial Intelligence (AI)",
+ "ai_actions": "Actions",
+ "ai_history": "History",
+ "ai_overview": "Overview",
+ "articles": "Articles",
+ "blog": "Blog",
+ "categories": "Categories",
+ "core": "Core",
+ "cron": "Cron Jobs",
+ "dashboard": "Dashboard",
+ "files": "Files",
+ "integrations": "Integrations",
+ "moderators": "Moderators",
+ "navigation": "Navigation",
+ "notifications": "Notifications",
+ "queue": "Queue Tasks",
+ "roles": "Roles",
+ "search": "Search",
+ "staff": "Staff",
+ "system": "System",
+ "user_list": "User List",
+ "users": "Users"
+ },
+ "navigation": {
+ "add": "Add link",
+ "desc": "Choose where your menu links lead and in what order.",
+ "discord": "Discord",
+ "home": "Home",
+ "support": "Support us"
+ },
+ "notifications": {
+ "desc": "What happened in your community while you were away.",
+ "digest": "Weekly digest sent to {count, number} members",
+ "hours_ago": "{count, plural, one {# hour ago} other {# hours ago}}",
+ "joined": "{name} joined your community",
+ "mark_all": "Mark all as read",
+ "minutes_ago": "{count, plural, one {# minute ago} other {# minutes ago}}",
+ "new": "{count} new",
+ "published": "{name} published “{title}”",
+ "published_title": "Self-hosting on a Raspberry Pi",
+ "role": "Moderator role updated by {name}",
+ "translated": "AI translated {count, plural, one {# article} other {# articles}} into Polish",
+ "yesterday": "Yesterday"
+ },
+ "roles": {
+ "administrator": "Administrator",
+ "create": "Create role",
+ "desc": "Manage the roles of your application.",
+ "guest": "Guest",
+ "member": "Member",
+ "moderator": "Moderator",
+ "role": "Role",
+ "search": "Search roles by name…",
+ "updated": "Updated At",
+ "users_count": "Number of users"
+ },
+ "search": {
+ "desc": "Everything your members can find, kept in one index.",
+ "documents": "Indexed documents",
+ "engine": "Engine",
+ "engine_value": "Postgres full-text",
+ "rebuilt": "Last rebuilt {count, plural, one {# hour} other {# hours}} ago",
+ "reindex": "Rebuild index",
+ "type": "Type"
+ },
+ "settings": {
+ "active_now": "Active now",
+ "ai": "AI usage",
+ "days_ago": "{count, plural, one {# day ago} other {# days ago}}",
+ "devices": "Devices",
+ "edit_profile": "Edit profile",
+ "email": "Email",
+ "language": "Language",
+ "member_since": "Member since {date}",
+ "notifications": "Notifications",
+ "overview": "Overview",
+ "role": "Role",
+ "security": "Security",
+ "sign_out": "Sign out",
+ "sign_out_all": "Sign out of all other devices",
+ "sso": "Connected accounts",
+ "this_device": "This device",
+ "time_zone": "Time zone",
+ "time_zone_value": "Europe/Warsaw (GMT+2)",
+ "title": "Settings"
+ },
+ "users": {
+ "add": "Add user",
+ "desc": "Everyone who has signed up to your community.",
+ "joined": "Joined",
+ "search": "Search by name or email…",
+ "user": "User"
+ }
}
}
}
diff --git a/apps/web/src/locales/site/pl.json b/apps/web/src/locales/site/pl.json
index e3970df817..3c0883bba3 100644
--- a/apps/web/src/locales/site/pl.json
+++ b/apps/web/src/locales/site/pl.json
@@ -5,18 +5,227 @@
"canary": "VitNode 2.0 to wczesna wersja canary",
"description": "Publikuj treści, łącz ludzi i twórz funkcje, których potrzebuje Twoja społeczność, na platformie open source, nad którą masz pełną kontrolę.",
"explore": "Odkryj, co możesz zbudować",
- "preview": {
- "alt": "Pulpit AdminCP z paskiem bocznym z sekcjami Core, Blog i wtyczki Example, widżetem prywatnych notatek i widżetem wysyłania powiadomień.",
- "members": "{count, plural, one {# członek} few {# członkowie} many {# członków} other {# członka}}",
- "toast_meta": "Komunikat na żywo · przed chwilą",
- "toast_title": "Witaj na pokładzie, {name}!"
- },
"see_product": "Zobacz produkt",
"title": "Dom dla Twojej społeczności , zbudowany wokół Twojego pomysłu.",
"trust": {
"agents": "Stworzony dla agentów AI do kodowania",
"license": "Licencja MIT",
"open_source": "Open source"
+ },
+ "wall": {
+ "ai": {
+ "alt_text": "Teksty alternatywne obrazów",
+ "budget": "Pozostały budżet",
+ "desc": "Punkty wydane przez członków na funkcje AI w tym miesiącu.",
+ "models": "{count, plural, one {# model} few {# modele} many {# modeli} other {# modelu}}",
+ "points": "Wykorzystane punkty",
+ "requests": "Żądania",
+ "summaries": "Podsumowania",
+ "translate": "Tłumaczenie artykułów"
+ },
+ "article": {
+ "back": "Wróć",
+ "choose": "Wybierz plik",
+ "content": "Treść",
+ "content_value": "Czterdziestu dwóch członków, jeden dach i zdecydowanie za dużo pizzy. Oto wszystko, co razem zbudowaliśmy…",
+ "cover": "Obraz okładki",
+ "cover_desc": "JPG, PNG, WEBP, AVIF · maks. 5 MB",
+ "desc": "Dodaj nowy artykuł.",
+ "draft": "Zapisz jako szkic",
+ "drop": "Przeciągnij plik tutaj",
+ "publish": "Opublikuj",
+ "title": "Utwórz artykuł",
+ "title_label": "Tytuł",
+ "title_value": "Podsumowanie letniego spotkania",
+ "url": "Przyjazny adres URL"
+ },
+ "categories": {
+ "announcements": "Ogłoszenia",
+ "count": "{count, plural, one {# artykuł} few {# artykuły} many {# artykułów} other {# artykułu}}",
+ "create": "Nowa kategoria",
+ "desc": "Grupuj artykuły na blogu, aby czytelnicy łatwo trafiali tam, gdzie chcą.",
+ "events": "Wydarzenia",
+ "guides": "Poradniki",
+ "releases": "Informacje o wydaniach",
+ "spotlight": "W centrum społeczności"
+ },
+ "common": {
+ "active": "Aktywna",
+ "english": "Angielski",
+ "optional": "Opcjonalne",
+ "read_more": "Czytaj więcej"
+ },
+ "cron": {
+ "daily": "Codziennie",
+ "desc": "Zadania w tle i czas ich następnego uruchomienia.",
+ "digest": "Wyślij cotygodniowe podsumowanie",
+ "failed": "Błąd",
+ "files": "Usuń skasowane pliki",
+ "healthy": "Działa",
+ "hourly": "Co godzinę",
+ "in_hours": "za {count} godz.",
+ "in_minutes": "za {count} min",
+ "job": "Zadanie",
+ "last_tick": "Ostatnie uruchomienie {count, plural, one {# sekundę} few {# sekundy} many {# sekund} other {# sekundy}} temu",
+ "next_run": "Następne uruchomienie",
+ "running": "W trakcie",
+ "scheduler": "Harmonogram działa",
+ "search": "Przebuduj indeks wyszukiwania",
+ "sessions": "Wyczyść wygasłe sesje",
+ "six_hours": "Co 6 godzin",
+ "status": "Status",
+ "tomorrow": "Jutro",
+ "weekly": "Co tydzień"
+ },
+ "dashboard": {
+ "dev_mode": "Tryb deweloperski",
+ "edit_layout": "Edytuj układ",
+ "message": "Tytuł",
+ "message_value": "Pozdrowienia od administratora 👋",
+ "notes_desc": "Prywatny notatnik. Widzisz go tylko Ty.",
+ "notes_placeholder": "Wszystko, co warto zapamiętać…",
+ "notes_title": "Notatki",
+ "notify_desc": "Wysyła powiadomienie do użytkownika w czasie rzeczywistym, na każdej przeglądarce, na której jest zalogowany.",
+ "notify_title": "Wyślij powiadomienie",
+ "send": "Wyślij",
+ "user_id": "ID użytkownika",
+ "version": "Wersja: {version}"
+ },
+ "files": {
+ "desc": "Wszystko, co przesłali członkowie i zespół.",
+ "upload": "Prześlij"
+ },
+ "integrations": {
+ "ai": "Generowanie tekstu, uporządkowane dane wyjściowe i osadzenia dzięki Vercel AI SDK.",
+ "cron": "Zaplanowane zadania w tle, takie jak czyszczenie wygasłych sesji i tokenów.",
+ "desc": "Status podstawowych usług obsługujących tę instancję.",
+ "email": "Wiadomości transakcyjne, takie jak potwierdzenia rejestracji i resetowanie hasła.",
+ "email_title": "E-mail",
+ "queue": "Zadania w tle umieszczone w kolejce w bazie danych i przetwarzane przez proces cron.",
+ "redis": "Współdzielona pamięć podręczna i magazyn ogranicznika żądań. Przy wyłączeniu przechodzi na pamięć wewnętrzną.",
+ "title": "Integracje",
+ "unreachable": "Nieosiągalna",
+ "websocket": "Aktualizacje na żywo i powiadomienia wysyłane do połączonych klientów w czasie rzeczywistym."
+ },
+ "login": {
+ "desc": "Witaj ponownie! Zaloguj się na swoje konto.",
+ "email": "Adres e-mail",
+ "forgot": "Nie pamiętasz hasła?",
+ "no_account": "Nie masz konta?",
+ "password": "Hasło",
+ "sign_up": "Zarejestruj się.",
+ "submit": "Zaloguj się",
+ "title": "Zaloguj się"
+ },
+ "moderators": {
+ "articles": "Zarządzanie artykułami",
+ "articles_desc": "Tworzenie, edycja i publikowanie artykułów",
+ "desc": "Wybierz, co moderatorzy mogą robić w AdminCP.",
+ "layout": "Edycja układu",
+ "layout_desc": "Zmiana menu i widżetów pulpitu",
+ "members": "Moderowanie członków",
+ "members_desc": "Banowanie, ostrzeganie i edycja profili",
+ "roles": "Zarządzanie rolami",
+ "roles_desc": "Zmiana ról i dostępu zespołu",
+ "usage": "Podgląd użycia AI",
+ "usage_desc": "Punkty i budżety"
+ },
+ "nav": {
+ "admins": "Administratorzy",
+ "advanced": "Zaawansowane",
+ "ai": "Sztuczna Inteligencja (AI)",
+ "ai_actions": "Akcje",
+ "ai_history": "Historia",
+ "ai_overview": "Przegląd",
+ "articles": "Artykuły",
+ "blog": "Blog",
+ "categories": "Kategorie",
+ "core": "Rdzeń",
+ "cron": "Zadania cron",
+ "dashboard": "Pulpit",
+ "files": "Pliki",
+ "integrations": "Integracje",
+ "moderators": "Moderatorzy",
+ "navigation": "Nawigacja",
+ "notifications": "Powiadomienia",
+ "queue": "Zadania w kolejce",
+ "roles": "Role",
+ "search": "Szukaj",
+ "staff": "Zespół",
+ "system": "System",
+ "user_list": "Lista użytkowników",
+ "users": "Użytkownicy"
+ },
+ "navigation": {
+ "add": "Dodaj link",
+ "desc": "Wybierz, do czego prowadzą Twoje menu i w jakiej kolejności.",
+ "discord": "Discord",
+ "home": "Strona główna",
+ "support": "Wesprzyj nas"
+ },
+ "notifications": {
+ "desc": "Co działo się w Twojej społeczności podczas Twojej nieobecności.",
+ "digest": "Wysłano cotygodniowe podsumowanie do {count, number} członków",
+ "hours_ago": "{count, plural, one {# godzinę temu} few {# godziny temu} many {# godzin temu} other {# godziny temu}}",
+ "joined": "{name} dołącza do Twojej społeczności",
+ "mark_all": "Oznacz wszystkie jako przeczytane",
+ "minutes_ago": "{count, plural, one {# minutę temu} few {# minuty temu} many {# minut temu} other {# minuty temu}}",
+ "new": "Nowe: {count}",
+ "published": "{name} publikuje „{title}”",
+ "published_title": "Self-hosting na Raspberry Pi",
+ "role": "{name} aktualizuje rolę Moderator",
+ "translated": "AI przetłumaczyło {count, plural, one {# artykuł} few {# artykuły} many {# artykułów} other {# artykułu}} na polski",
+ "yesterday": "Wczoraj"
+ },
+ "roles": {
+ "administrator": "Administrator",
+ "create": "Utwórz rolę",
+ "desc": "Zarządzaj rolami w swojej aplikacji.",
+ "guest": "Gość",
+ "member": "Członek",
+ "moderator": "Moderator",
+ "role": "Rola",
+ "search": "Szukaj ról po nazwie…",
+ "updated": "Zaktualizowano",
+ "users_count": "Liczba użytkowników"
+ },
+ "search": {
+ "desc": "Wszystko, co mogą znaleźć Twoi członkowie, w jednym indeksie.",
+ "documents": "Zaindeksowane dokumenty",
+ "engine": "Silnik",
+ "engine_value": "Wyszukiwanie pełnotekstowe Postgres",
+ "rebuilt": "Ostatnio przebudowano {count, plural, one {# godzinę} few {# godziny} many {# godzin} other {# godziny}} temu",
+ "reindex": "Przebuduj indeks",
+ "type": "Typ"
+ },
+ "settings": {
+ "active_now": "Aktywne teraz",
+ "ai": "Użycie AI",
+ "days_ago": "{count, plural, one {# dzień temu} few {# dni temu} many {# dni temu} other {# dnia temu}}",
+ "devices": "Urządzenia",
+ "edit_profile": "Edytuj profil",
+ "email": "Adres e-mail",
+ "language": "Język",
+ "member_since": "Członek od {date}",
+ "notifications": "Powiadomienia",
+ "overview": "Przegląd",
+ "role": "Rola",
+ "security": "Bezpieczeństwo",
+ "sign_out": "Wyloguj",
+ "sign_out_all": "Wyloguj ze wszystkich innych urządzeń",
+ "sso": "Połączone konta",
+ "this_device": "To urządzenie",
+ "time_zone": "Strefa czasowa",
+ "time_zone_value": "Europa/Warszawa (GMT+2)",
+ "title": "Ustawienia"
+ },
+ "users": {
+ "add": "Dodaj użytkownika",
+ "desc": "Wszyscy, którzy dołączyli do Twojej społeczności.",
+ "joined": "Dołączył",
+ "search": "Szukaj po nazwie lub adresie e-mail…",
+ "user": "Użytkownik"
+ }
}
}
}
diff --git a/apps/web/src/proto/ai-unlimited/breakdown.tsx b/apps/web/src/proto/ai-unlimited/breakdown.tsx
deleted file mode 100644
index c68523bc99..0000000000
--- a/apps/web/src/proto/ai-unlimited/breakdown.tsx
+++ /dev/null
@@ -1,83 +0,0 @@
-import { SETTINGS_ROW, SettingsGroup } from '@vitnode/core/views/auth/settings/settings-group'
-import { cn } from 'cn'
-
-import { formatDay, formatPoints, type ProtoUsage, usedFeatures } from './data'
-import { DetailsLink, InfinityTile } from './shared'
-
-const SEGMENT_TONES = ['bg-primary', 'bg-primary/65', 'bg-primary/40', 'bg-primary/20']
-const MAX_SEGMENTS = 3
-
-const percent = new Intl.NumberFormat('en', { maximumFractionDigits: 0, style: 'percent' })
-
-export const Breakdown = ({ usage }: { usage: ProtoUsage }) => {
- const features = usedFeatures(usage)
- const top = features.slice(0, MAX_SEGMENTS)
- const rest = features.slice(MAX_SEGMENTS)
- const segments = [
- ...top.map(feature => ({ key: feature.key, label: feature.title, points: feature.monthPoints })),
- ...(rest.length > 0
- ? [
- {
- key: 'other',
- label: `${rest.length} other ${rest.length === 1 ? 'feature' : 'features'}`,
- points: rest.reduce((sum, feature) => sum + feature.monthPoints, 0),
- },
- ]
- : []),
- ]
-
- return (
-
-
-
-
- Unlimited points
-
- No monthly cap. Your usage count starts over on {formatDay(usage.resetsAt)}.
-
-
-
- {formatPoints(usage.used)}
- pts this month
-
-
-
- {segments.length === 0 ? (
-
- Nothing used since {formatDay(usage.periodStart)}. Once you run an AI feature, you'll see where your points go.
-
- ) : (
- <>
-
- {segments.map((segment, index) => (
-
- ))}
-
-
- {segments.map((segment, index) => (
-
-
- {segment.label}
- {formatPoints(segment.points)} pts
-
- {percent.format(segment.points / usage.used)}
-
-
- ))}
-
- >
- )}
-
-
-
-
-
- )
-}
diff --git a/apps/web/src/proto/ai-unlimited/current.tsx b/apps/web/src/proto/ai-unlimited/current.tsx
deleted file mode 100644
index 6ce9dfdf5a..0000000000
--- a/apps/web/src/proto/ai-unlimited/current.tsx
+++ /dev/null
@@ -1,18 +0,0 @@
-import { SETTINGS_ROW, SettingsGroup } from '@vitnode/core/views/auth/settings/settings-group'
-
-import { formatPoints, type ProtoUsage } from './data'
-import { DetailsLink } from './shared'
-
-export const Current = ({ usage }: { usage: ProtoUsage }) => (
-
-
-
- Unlimited points {' '}
- {formatPoints(usage.used)} points used this month
-
-
-
-
-
-
-)
diff --git a/apps/web/src/proto/ai-unlimited/daily-limits.tsx b/apps/web/src/proto/ai-unlimited/daily-limits.tsx
deleted file mode 100644
index 12b9c7446c..0000000000
--- a/apps/web/src/proto/ai-unlimited/daily-limits.tsx
+++ /dev/null
@@ -1,63 +0,0 @@
-import { AiActionIcon } from '@vitnode/core/components/ai/action-icon'
-import { Badge } from '@vitnode/core/components/ui/badge'
-import { SETTINGS_ROW, SettingsGroup } from '@vitnode/core/views/auth/settings/settings-group'
-import { cn } from 'cn'
-
-import { formatPoints, type ProtoUsage } from './data'
-import { DetailsLink, InfinityTile } from './shared'
-
-export const DailyLimits = ({ usage }: { usage: ProtoUsage }) => {
- const limited = usage.features.filter(feature => feature.dailyLimit !== null)
- const open = usage.features.length - limited.length
-
- return (
-
-
-
-
- No monthly cap on your points
-
- Only daily limits can pause a feature. They reset at midnight.
-
-
-
- {limited.map(feature => {
- const limit = feature.dailyLimit ?? 0
- const isFull = feature.usedToday >= limit
- const share = Math.min(feature.usedToday / limit, 1)
-
- return (
-
-
- {feature.title}
- {isFull ? (
- Limit reached
- ) : (
-
-
- = 0.8 ? 'bg-warn' : 'bg-primary')}
- style={{ width: `${share * 100}%` }}
- />
-
-
- {feature.usedToday} / {limit} today
-
-
- )}
-
- )
- })}
-
-
- {open > 0 ? `${open} more ${open === 1 ? 'feature has' : 'features have'} no daily limit · ` : ''}
- {formatPoints(usage.used)} pts used this month
-
- All features
-
-
- )
-}
diff --git a/apps/web/src/proto/ai-unlimited/data.ts b/apps/web/src/proto/ai-unlimited/data.ts
deleted file mode 100644
index bb7d35e32d..0000000000
--- a/apps/web/src/proto/ai-unlimited/data.ts
+++ /dev/null
@@ -1,64 +0,0 @@
-export interface ProtoFeature {
- dailyLimit: null | number
- icon: string
- key: string
- monthPoints: number
- title: string
- usedToday: number
-}
-
-export interface ProtoUsage {
- features: ProtoFeature[]
- periodStart: Date
- resetsAt: Date
- used: number
-}
-
-const periodStart = new Date(2026, 9, 1)
-const resetsAt = new Date(2026, 10, 1)
-
-const heavy: ProtoFeature[] = [
- { dailyLimit: 20, icon: 'text-quote', key: 'summarize', monthPoints: 612.4, title: 'Summarize thread', usedToday: 14 },
- { dailyLimit: null, icon: 'languages', key: 'translate', monthPoints: 388.9, title: 'Translate article into another language while keeping the formatting', usedToday: 9 },
- { dailyLimit: 10, icon: 'wand-sparkles', key: 'rewrite', monthPoints: 171.2, title: 'Rewrite with a friendlier tone', usedToday: 10 },
- { dailyLimit: 50, icon: 'image', key: 'alt', monthPoints: 64.3, title: 'Generate alt text', usedToday: 3 },
- { dailyLimit: null, icon: 'tags', key: 'tags', monthPoints: 31.6, title: 'Suggest tags', usedToday: 1 },
- { dailyLimit: null, icon: 'message-square-reply', key: 'reply', monthPoints: 16.2, title: 'Draft reply', usedToday: 0 },
-]
-
-const light: ProtoFeature[] = [
- { dailyLimit: 20, icon: 'text-quote', key: 'summarize', monthPoints: 0.2, title: 'Summarize thread', usedToday: 1 },
- { dailyLimit: null, icon: 'languages', key: 'translate', monthPoints: 0.1, title: 'Translate article into another language while keeping the formatting', usedToday: 0 },
- { dailyLimit: 10, icon: 'wand-sparkles', key: 'rewrite', monthPoints: 0, title: 'Rewrite with a friendlier tone', usedToday: 0 },
-]
-
-const fresh: ProtoFeature[] = light.map(feature => ({ ...feature, monthPoints: 0, usedToday: 0 }))
-
-const build = (features: ProtoFeature[]): ProtoUsage => ({
- features,
- periodStart,
- resetsAt,
- used: features.reduce((sum, feature) => sum + feature.monthPoints, 0),
-})
-
-export const SCENARIOS = {
- heavy: { label: 'Heavy use', usage: build(heavy) },
- light: { label: 'Light use (0.3 pts)', usage: build(light) },
- fresh: { label: 'Nothing used yet', usage: build(fresh) },
-} as const
-
-export type ScenarioKey = keyof typeof SCENARIOS
-
-export const formatPoints = (value: number) => {
- if (value > 0 && value < 0.1) return '<0.1'
- return new Intl.NumberFormat('en', { maximumFractionDigits: 1 }).format(Math.floor(value * 10) / 10)
-}
-
-export const formatDay = (date: Date) =>
- new Intl.DateTimeFormat('en', { day: 'numeric', month: 'long' }).format(date)
-
-export const runsToday = (usage: ProtoUsage) =>
- usage.features.reduce((sum, feature) => sum + feature.usedToday, 0)
-
-export const usedFeatures = (usage: ProtoUsage) =>
- [...usage.features].filter(feature => feature.monthPoints > 0).sort((a, b) => b.monthPoints - a.monthPoints)
diff --git a/apps/web/src/proto/ai-unlimited/harness.tsx b/apps/web/src/proto/ai-unlimited/harness.tsx
deleted file mode 100644
index aa434df4c0..0000000000
--- a/apps/web/src/proto/ai-unlimited/harness.tsx
+++ /dev/null
@@ -1,177 +0,0 @@
-import { PageTitle } from '@vitnode/core/components/ui/page-title'
-import { RouteMessages } from '@vitnode/core/tanstack/i18n'
-import { SETTINGS_NAMESPACES, SETTINGS_NAV_ITEMS } from '@vitnode/core/tanstack/settings'
-import { SettingsNavContent } from '@vitnode/core/views/auth/settings/nav-content'
-import { SETTINGS_ROW, SETTINGS_ROW_LABEL, SettingsGroup } from '@vitnode/core/views/auth/settings/settings-group'
-import React from 'react'
-
-import { Breakdown } from './breakdown'
-import { Current } from './current'
-import { DailyLimits } from './daily-limits'
-import { type ProtoUsage, SCENARIOS, type ScenarioKey } from './data'
-import { Stats } from './stats'
-import { Statement } from './statement'
-
-const VARIANTS: { Component: React.ComponentType<{ usage: ProtoUsage }>; name: string }[] = [
- { Component: Current, name: 'Current' },
- { Component: Breakdown, name: 'Breakdown' },
- { Component: DailyLimits, name: 'Daily limits' },
- { Component: Stats, name: 'Stats' },
- { Component: Statement, name: 'Statement' },
-]
-
-const PICKER_CSS = `
-.proto-picker{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);z-index:2147483647;display:flex;align-items:center;gap:2px;padding:4px;border-radius:999px;background:rgba(10,10,10,.82);-webkit-backdrop-filter:blur(12px) saturate(1.4);backdrop-filter:blur(12px) saturate(1.4);box-shadow:0 0 0 1px rgba(255,255,255,.08) inset,0 8px 24px rgba(0,0,0,.24),0 2px 6px rgba(0,0,0,.12);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;font-size:13px;line-height:1;-webkit-font-smoothing:antialiased;user-select:none;-webkit-user-select:none}
-.proto-picker-highlight{position:absolute;top:4px;left:0;height:28px;border-radius:999px;background:rgba(255,255,255,.12);will-change:transform}
-.proto-picker[data-ready] .proto-picker-highlight{transition:transform 250ms cubic-bezier(.23,1,.32,1),width 250ms cubic-bezier(.23,1,.32,1)}
-@media (prefers-reduced-motion:reduce){.proto-picker[data-ready] .proto-picker-highlight{transition:none}}
-.proto-picker-item{position:relative;display:flex;align-items:center;height:28px;padding:0 12px;border:0;border-radius:999px;background:transparent;color:rgba(255,255,255,.55);font:inherit;cursor:pointer;transition:color 150ms ease-out}
-.proto-picker-item:hover{color:rgba(255,255,255,.85)}
-.proto-picker-item:active{transform:scale(.97)}
-.proto-picker-item:focus-visible{outline:2px solid rgba(255,255,255,.4);outline-offset:2px}
-.proto-picker-item[data-active]{color:#fff}
-.proto-scenario{position:fixed;top:88px;right:16px;z-index:2147483647;display:flex;align-items:center;gap:8px;padding:4px 4px 4px 12px;border-radius:999px;background:rgba(10,10,10,.82);color:rgba(255,255,255,.55);font:13px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
-.proto-scenario select{height:28px;border:0;border-radius:999px;padding:0 8px;background:rgba(255,255,255,.12);color:#fff;font:inherit}
-`
-
-const readParam = (key: string) =>
- typeof window === 'undefined' ? null : new URLSearchParams(window.location.search).get(key)
-
-const writeParam = (key: string, value: string) => {
- const url = new URL(window.location.href)
- url.searchParams.set(key, value)
- window.history.replaceState(window.history.state, '', url)
-}
-
-const Picker = ({ active, onChange }: { active: number; onChange: (index: number) => void }) => {
- const navRef = React.useRef(null)
- const highlightRef = React.useRef(null)
- const itemRefs = React.useRef<(HTMLButtonElement | null)[]>([])
- const [ready, setReady] = React.useState(false)
-
- React.useLayoutEffect(() => {
- const move = () => {
- const item = itemRefs.current[active]
- const highlight = highlightRef.current
- if (!item || !highlight) return
- highlight.style.width = `${item.offsetWidth}px`
- highlight.style.transform = `translateX(${item.offsetLeft}px)`
- }
- move()
- window.addEventListener('resize', move)
- return () => window.removeEventListener('resize', move)
- }, [active])
-
- React.useEffect(() => {
- const frame = requestAnimationFrame(() => requestAnimationFrame(() => setReady(true)))
- return () => cancelAnimationFrame(frame)
- }, [])
-
- React.useEffect(() => {
- const onKey = (event: KeyboardEvent) => {
- const target = event.target as HTMLElement
- if (/^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName) || target.isContentEditable) return
- if (event.metaKey || event.ctrlKey || event.altKey) return
- const num = Number.parseInt(event.key, 10)
- if (num >= 1 && num <= VARIANTS.length) onChange(num - 1)
- else if (event.key === 'ArrowRight') onChange((active + 1) % VARIANTS.length)
- else if (event.key === 'ArrowLeft') onChange((active - 1 + VARIANTS.length) % VARIANTS.length)
- }
- document.addEventListener('keydown', onKey)
- return () => document.removeEventListener('keydown', onKey)
- }, [active, onChange])
-
- return (
-
-
- {VARIANTS.map((variant, index) => (
- onChange(index)}
- ref={element => {
- itemRefs.current[index] = element
- }}
- type="button"
- >
- {variant.name}
-
- ))}
-
- )
-}
-
-const ContextGroups = () => (
-
-
- Nickname
- Maximilian Wojciechowski-Hartmann
-
-
- Email
-
- maximilian.wojciechowski-hartmann@example.com
-
-
-
- Role
- Administrator
-
-
-)
-
-export const AiUnlimitedProto = () => {
- const [active, setActive] = React.useState(0)
- const [scenario, setScenario] = React.useState('heavy')
-
- React.useEffect(() => {
- const v = Number.parseInt(readParam('v') ?? '1', 10)
- if (v >= 1 && v <= VARIANTS.length) setActive(v - 1)
- const s = readParam('s')
- if (s && s in SCENARIOS) setScenario(s as ScenarioKey)
- }, [])
-
- const change = React.useCallback((index: number) => {
- setActive(index)
- writeParam('v', String(index + 1))
- }, [])
-
- const { Component } = VARIANTS[active]
-
- return (
-
-
-
-
- Data
- {
- setScenario(event.target.value as ScenarioKey)
- writeParam('s', event.target.value)
- }}
- value={scenario}
- >
- {Object.entries(SCENARIOS).map(([key, value]) => (
-
- {value.label}
-
- ))}
-
-
-
-
- )
-}
diff --git a/apps/web/src/proto/ai-unlimited/shared.tsx b/apps/web/src/proto/ai-unlimited/shared.tsx
deleted file mode 100644
index 03ceed7e7e..0000000000
--- a/apps/web/src/proto/ai-unlimited/shared.tsx
+++ /dev/null
@@ -1,21 +0,0 @@
-import { cn } from 'cn'
-import { ChevronRightIcon, InfinityIcon } from 'lucide-react'
-
-export const DetailsLink = ({ children = 'See your AI features' }: { children?: React.ReactNode }) => (
-
- {children}
-
-
-)
-
-export const InfinityTile = ({ className }: { className?: string }) => (
-
-
-
-)
diff --git a/apps/web/src/proto/ai-unlimited/statement.tsx b/apps/web/src/proto/ai-unlimited/statement.tsx
deleted file mode 100644
index 9ad0d2db3d..0000000000
--- a/apps/web/src/proto/ai-unlimited/statement.tsx
+++ /dev/null
@@ -1,35 +0,0 @@
-import { SETTINGS_INTERACTIVE_ROW, SettingsGroup } from '@vitnode/core/views/auth/settings/settings-group'
-import { cn } from 'cn'
-import { ChevronRightIcon } from 'lucide-react'
-
-import { formatDay, formatPoints, type ProtoUsage, usedFeatures } from './data'
-import { InfinityTile } from './shared'
-
-const sentenceFor = (usage: ProtoUsage) => {
- const features = usedFeatures(usage)
- const top = features[0]
- if (!top) {
- return `You haven't used an AI feature since ${formatDay(usage.periodStart)}. Go wild — nothing here runs out.`
- }
-
- const across = features.length === 1 ? '1 feature' : `${features.length} features`
- const mostly = features.length > 1 ? `, mostly on ${top.title}` : ''
-
- return `You've spent ${formatPoints(usage.used)} points on ${across} since ${formatDay(usage.periodStart)}${mostly}.`
-}
-
-export const Statement = ({ usage }: { usage: ProtoUsage }) => (
-
-
-
-
-
- Your AI points never run out
- {sentenceFor(usage)}
- See your AI features
-
-
-
-
-
-)
diff --git a/apps/web/src/proto/ai-unlimited/stats.tsx b/apps/web/src/proto/ai-unlimited/stats.tsx
deleted file mode 100644
index b6c73d574c..0000000000
--- a/apps/web/src/proto/ai-unlimited/stats.tsx
+++ /dev/null
@@ -1,60 +0,0 @@
-import { SETTINGS_ROW, SettingsGroup } from '@vitnode/core/views/auth/settings/settings-group'
-import { cn } from 'cn'
-import { InfinityIcon } from 'lucide-react'
-
-import { formatDay, formatPoints, type ProtoUsage, runsToday, usedFeatures } from './data'
-import { DetailsLink } from './shared'
-
-const Stat = ({ hint, label, value }: { hint: string; label: string; value: React.ReactNode }) => (
-
-
{label}
-
- {value}
- {hint}
-
-
-)
-
-export const Stats = ({ usage }: { usage: ProtoUsage }) => {
- const features = usedFeatures(usage)
- const top = features[0]
- const today = runsToday(usage)
- const activeToday = usage.features.filter(feature => feature.usedToday > 0).length
-
- return (
-
-
-
-
-
- Unlimited
-
- }
- />
-
-
-
-
-
-
- {top ? (
- <>
- Most used: {top.title}
- >
- ) : (
- 'No features used this month'
- )}
-
-
-
-
- )
-}
diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts
index b0712ef24b..4aade34210 100644
--- a/apps/web/src/routeTree.gen.ts
+++ b/apps/web/src/routeTree.gen.ts
@@ -21,7 +21,6 @@ import { Route as DocsSearchRouteImport } from './routes/docs.search'
import { Route as DocsChar123Char125DotmdRouteImport } from './routes/docs.{$}[.]md'
import { Route as DocsDocsIndexRouteImport } from './routes/_docs/docs.index'
import { Route as DocsDocsSplatRouteImport } from './routes/_docs/docs.$'
-import { Route as MainProtoAiUnlimitedRouteImport } from './routes/_main/proto/ai-unlimited'
import { Route as MainSolutionsIndexRouteImport } from './routes/_main/solutions/index'
import { Route as MainSolutionsSlugRouteImport } from './routes/_main/solutions/$slug'
import { Route as AdminAdminCoreIndexRouteImport } from './routes/_admin/admin.core.index'
@@ -83,11 +82,6 @@ const DocsDocsSplatRoute = DocsDocsSplatRouteImport.update({
path: '/docs/$',
getParentRoute: () => DocsRoute,
} as any)
-const MainProtoAiUnlimitedRoute = MainProtoAiUnlimitedRouteImport.update({
- id: '/proto/ai-unlimited',
- path: '/proto/ai-unlimited',
- getParentRoute: () => MainRoute,
-} as any)
const MainSolutionsIndexRoute = MainSolutionsIndexRouteImport.update({
id: '/solutions/',
path: '/solutions/',
@@ -113,7 +107,6 @@ export interface FileRoutesByFullPath {
'/docs/search': typeof DocsSearchRoute
'/docs/{$}.md': typeof DocsChar123Char125DotmdRoute
'/docs/$': typeof DocsDocsSplatRoute
- '/proto/ai-unlimited': typeof MainProtoAiUnlimitedRoute
'/solutions/$slug': typeof MainSolutionsSlugRoute
'/docs/': typeof DocsDocsIndexRoute
'/solutions/': typeof MainSolutionsIndexRoute
@@ -128,7 +121,6 @@ export interface FileRoutesByTo {
'/docs/search': typeof DocsSearchRoute
'/docs/{$}.md': typeof DocsChar123Char125DotmdRoute
'/docs/$': typeof DocsDocsSplatRoute
- '/proto/ai-unlimited': typeof MainProtoAiUnlimitedRoute
'/solutions/$slug': typeof MainSolutionsSlugRoute
'/docs': typeof DocsDocsIndexRoute
'/solutions': typeof MainSolutionsIndexRoute
@@ -147,7 +139,6 @@ export interface FileRoutesById {
'/docs/{$}.md': typeof DocsChar123Char125DotmdRoute
'/_main/': typeof MainIndexRoute
'/_docs/docs/$': typeof DocsDocsSplatRoute
- '/_main/proto/ai-unlimited': typeof MainProtoAiUnlimitedRoute
'/_main/solutions/$slug': typeof MainSolutionsSlugRoute
'/_docs/docs/': typeof DocsDocsIndexRoute
'/_main/solutions/': typeof MainSolutionsIndexRoute
@@ -164,7 +155,6 @@ export interface FileRouteTypes {
| '/docs/search'
| '/docs/{$}.md'
| '/docs/$'
- | '/proto/ai-unlimited'
| '/solutions/$slug'
| '/docs/'
| '/solutions/'
@@ -179,7 +169,6 @@ export interface FileRouteTypes {
| '/docs/search'
| '/docs/{$}.md'
| '/docs/$'
- | '/proto/ai-unlimited'
| '/solutions/$slug'
| '/docs'
| '/solutions'
@@ -197,7 +186,6 @@ export interface FileRouteTypes {
| '/docs/{$}.md'
| '/_main/'
| '/_docs/docs/$'
- | '/_main/proto/ai-unlimited'
| '/_main/solutions/$slug'
| '/_docs/docs/'
| '/_main/solutions/'
@@ -301,13 +289,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DocsDocsSplatRouteImport
parentRoute: typeof DocsRoute
}
- '/_main/proto/ai-unlimited': {
- id: '/_main/proto/ai-unlimited'
- path: '/proto/ai-unlimited'
- fullPath: '/proto/ai-unlimited'
- preLoaderRoute: typeof MainProtoAiUnlimitedRouteImport
- parentRoute: typeof MainRoute
- }
'/_main/solutions/': {
id: '/_main/solutions/'
path: '/solutions'
@@ -357,7 +338,6 @@ const DocsRouteWithChildren = DocsRoute._addFileChildren(DocsRouteChildren)
interface MainRouteChildren {
MainPluginsRoute: typeof MainPluginsRoute
MainIndexRoute: typeof MainIndexRoute
- MainProtoAiUnlimitedRoute: typeof MainProtoAiUnlimitedRoute
MainSolutionsSlugRoute: typeof MainSolutionsSlugRoute
MainSolutionsIndexRoute: typeof MainSolutionsIndexRoute
}
@@ -365,7 +345,6 @@ interface MainRouteChildren {
const MainRouteChildren: MainRouteChildren = {
MainPluginsRoute: MainPluginsRoute,
MainIndexRoute: MainIndexRoute,
- MainProtoAiUnlimitedRoute: MainProtoAiUnlimitedRoute,
MainSolutionsSlugRoute: MainSolutionsSlugRoute,
MainSolutionsIndexRoute: MainSolutionsIndexRoute,
}
diff --git a/apps/web/src/routes/_main/proto/ai-unlimited.tsx b/apps/web/src/routes/_main/proto/ai-unlimited.tsx
deleted file mode 100644
index 22487d1c5b..0000000000
--- a/apps/web/src/routes/_main/proto/ai-unlimited.tsx
+++ /dev/null
@@ -1,8 +0,0 @@
-import { createFileRoute } from '@tanstack/react-router'
-
-import { AiUnlimitedProto } from '@/proto/ai-unlimited/harness'
-
-export const Route = createFileRoute('/_main/proto/ai-unlimited')({
- head: () => ({ meta: [{ title: 'AI unlimited prototype' }, { name: 'robots', content: 'noindex' }] }),
- component: AiUnlimitedProto,
-})
diff --git a/apps/web/src/routes/llms[.]txt.ts b/apps/web/src/routes/llms[.]txt.ts
index 91b8fedbce..b8e2c610d2 100644
--- a/apps/web/src/routes/llms[.]txt.ts
+++ b/apps/web/src/routes/llms[.]txt.ts
@@ -6,8 +6,8 @@ import { source } from '@/docs/source.server'
export const Route = createFileRoute('/llms.txt')({
server: {
handlers: {
- GET: async () =>
- new Response(await llms(source).index(), {
+ GET: () =>
+ new Response(llms(source).index(), {
headers: { 'content-type': 'text/plain; charset=utf-8' },
}),
},
diff --git a/apps/web/src/site/home/illustrations/hero-icon-field.tsx b/apps/web/src/site/home/illustrations/hero-icon-field.tsx
deleted file mode 100644
index 4d80b79294..0000000000
--- a/apps/web/src/site/home/illustrations/hero-icon-field.tsx
+++ /dev/null
@@ -1,74 +0,0 @@
-import type { LucideIcon } from 'lucide-react'
-
-import {
- Bell,
- Blocks,
- Bot,
- CalendarDays,
- Code,
- Database,
- FileText,
- Globe,
- Image as ImageIcon,
- Languages,
- LayoutDashboard,
- Lock,
- Mail,
- Plug,
- Search,
- ShieldCheck,
- Sparkles,
- Timer,
- Users,
- Zap,
-} from 'lucide-react'
-
-const ICONS: LucideIcon[] = [
- Bell,
- Blocks,
- Bot,
- CalendarDays,
- Code,
- Database,
- FileText,
- Globe,
- ImageIcon,
- Languages,
- LayoutDashboard,
- Lock,
- Mail,
- Plug,
- Search,
- ShieldCheck,
- Sparkles,
- Timer,
- Users,
- Zap,
-]
-
-const FIELD = Array.from({ length: 26 }, (_, index) => ({
- Icon: ICONS[index % ICONS.length],
- rotate: ((index * 47) % 30) - 15,
- x: (index * 29.7 + 7) % 96,
- y: (index * 53.3 + 11) % 92,
-}))
-
-export const HeroIconField = () => (
-
- {FIELD.map(({ Icon, rotate, x, y }) => (
-
- ))}
-
-)
diff --git a/apps/web/src/site/home/illustrations/hero-preview.tsx b/apps/web/src/site/home/illustrations/hero-preview.tsx
deleted file mode 100644
index 3f17a64c6c..0000000000
--- a/apps/web/src/site/home/illustrations/hero-preview.tsx
+++ /dev/null
@@ -1,163 +0,0 @@
-import { cn } from 'cn'
-import { FileCode2, Sparkles } from 'lucide-react'
-import { useTranslations } from 'use-intl'
-
-import { SafariFrame } from '@/site/marketing/safari-frame'
-import { SCREENS } from '@/site/marketing/screens'
-
-const MEMBERS = ['MA', 'KW', 'OL']
-const MEMBER_COUNT = 1285
-
-const depthStyle = (depth: number) =>
- ({ '--depth': `${depth}` }) as React.CSSProperties
-
-const Floating = ({
- children,
- className,
- delay,
- depth,
-}: {
- children: React.ReactNode
- className?: string
- delay: number
- depth: number
-}) => (
-
-)
-
-const ToastCard = () => {
- const t = useTranslations('site.home.hero.preview')
-
- return (
-
-
-
-
-
-
- {t('toast_title', { name: 'Maya' })}
-
-
- {t('toast_meta')}
-
-
-
- )
-}
-
-const ConfigCard = () => (
-
-
-
- vitnode.config.ts
-
-
- plugins: [
-
-
- blogPlugin(),
-
-
- eventsPlugin(),
-
-
]
-
-)
-
-const MembersCard = () => {
- const t = useTranslations('site.home.hero.preview')
-
- return (
-
-
- {MEMBERS.map((initials, index) => (
-
- {initials}
-
- ))}
-
-
- {t.rich('members', {
- count: MEMBER_COUNT,
- strong: (chunks) => (
- {chunks}
- ),
- })}
-
-
- )
-}
-
-export const HeroPreview = ({ ref }: { ref?: React.Ref
}) => {
- const t = useTranslations('site.home.hero.preview')
-
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- )
-}
diff --git a/apps/web/src/site/home/illustrations/hero-strands.tsx b/apps/web/src/site/home/illustrations/hero-strands.tsx
deleted file mode 100644
index 7ff6e22fc2..0000000000
--- a/apps/web/src/site/home/illustrations/hero-strands.tsx
+++ /dev/null
@@ -1,69 +0,0 @@
-import { cn } from 'cn'
-
-const STRANDS = Array.from({ length: 22 }, (_, index) => {
- const t = index / 21
-
- return `M-60 ${250 + t * 70} C 380 ${40 + t * 170}, 1040 ${440 - t * 150}, 1500 ${170 + t * 90}`
-})
-
-const PULSES = [3, 9, 14, 19]
-
-const timing = (delay: number, duration: number): React.CSSProperties => ({
- animationDelay: `${delay}s`,
- animationDuration: `${duration}s`,
-})
-
-export const HeroStrands = ({ className }: { className?: string }) => (
-
-
-
-
-
-
-
-
-
-
-
- {STRANDS.filter((_, index) => index % 2 === 0).map((d) => (
-
- ))}
-
-
- {STRANDS.filter((_, index) => index % 2 === 1).map((d) => (
-
- ))}
-
- {PULSES.map((index, order) => (
-
- ))}
-
-)
diff --git a/apps/web/src/site/home/illustrations/hero-wall/content-screens.tsx b/apps/web/src/site/home/illustrations/hero-wall/content-screens.tsx
new file mode 100644
index 0000000000..8f383658fe
--- /dev/null
+++ b/apps/web/src/site/home/illustrations/hero-wall/content-screens.tsx
@@ -0,0 +1,362 @@
+import { Badge } from '@vitnode/core/components/ui/badge'
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from '@vitnode/core/components/ui/card'
+import { Input } from '@vitnode/core/components/ui/input'
+import { Textarea } from '@vitnode/core/components/ui/textarea'
+import { cn } from 'cn'
+import {
+ ArrowLeft,
+ Bold,
+ ChevronRight,
+ FileImage,
+ FileText,
+ Globe,
+ GripVertical,
+ Heart,
+ Image as ImageIcon,
+ Italic,
+ Link2,
+ List,
+ ListOrdered,
+ MessageSquare,
+ NotebookPen,
+ Pencil,
+ Plus,
+ Redo2,
+ Save,
+ Send,
+ TriangleAlert,
+ Underline,
+ Undo2,
+ Upload,
+} from 'lucide-react'
+import { useTranslations } from 'use-intl'
+
+import { AdminFrame } from './frames'
+import { Field, LanguageInput, ListRow, StaticButton } from './parts'
+
+export const DashboardScreen = () => {
+ const t = useTranslations('site.home.hero.wall.dashboard')
+
+ return (
+
+
+ {t('edit_layout')}
+
+ }
+ badge={
+
+
+ {t('dev_mode')}
+
+ }
+ description={t('version', { version: '2.0.0-canary.4' })}
+ page="dashboard"
+ title="VitNode"
+ >
+
+
+ )
+}
+
+const TOOLBAR = [
+ { Icon: Undo2, key: 'undo' },
+ { Icon: Redo2, key: 'redo' },
+ { Icon: Bold, key: 'bold' },
+ { Icon: Italic, key: 'italic' },
+ { Icon: Underline, key: 'underline' },
+ { Icon: List, key: 'list' },
+ { Icon: ListOrdered, key: 'ordered' },
+]
+
+export const ArticleScreen = () => {
+ const t = useTranslations('site.home.hero.wall.article')
+
+ return (
+
+
+
+ {t('back')}
+
+
+
+ {t('draft')}
+
+
+
+ {t('publish')}
+
+ >
+ }
+ description={t('desc')}
+ page="articles"
+ title={t('title')}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+ {TOOLBAR.map(({ Icon, key }) => (
+
+ ))}
+
+
+ {t('content_value')}
+
+
+
+
+
+
+
+ {t('cover')}
+ {t('cover_desc')}
+
+
+
+
+ {t('drop')}
+ {t('choose')}
+
+
+
+
+
+ )
+}
+
+const CATEGORIES = [
+ { color: 'bg-primary', count: 24, key: 'announcements' },
+ { color: 'bg-emerald-500', count: 61, key: 'guides' },
+ { color: 'bg-amber-400', count: 17, key: 'spotlight' },
+ { color: 'bg-red-500', count: 9, key: 'releases' },
+ { color: 'bg-sky-500', count: 33, key: 'events' },
+] as const
+
+export const CategoriesScreen = () => {
+ const t = useTranslations('site.home.hero.wall')
+
+ return (
+
+
+ {t('categories.create')}
+
+ }
+ description={t('categories.desc')}
+ page="categories"
+ title={t('nav.categories')}
+ >
+
+ {CATEGORIES.map(({ color, count, key }) => (
+
+
+
+
+ {t(`categories.${key}`)}
+
+
+ {t('categories.count', { count })}
+
+
+
+ ))}
+
+
+ )
+}
+
+const LINKS = [
+ { child: false, Icon: Globe, label: 'home', url: '/' },
+ { child: false, Icon: FileText, label: 'blog', url: '/blog' },
+ {
+ child: true,
+ Icon: FileText,
+ label: 'announcements',
+ url: '/blog/announcements',
+ },
+ { child: true, Icon: FileText, label: 'guides', url: '/blog/guides' },
+ {
+ child: false,
+ Icon: MessageSquare,
+ label: 'discord',
+ url: 'discord.gg/vitnode',
+ },
+ { child: false, Icon: Heart, label: 'support', url: '/donate' },
+] as const
+
+export const NavigationScreen = () => {
+ const t = useTranslations('site.home.hero.wall')
+ const label = (key: (typeof LINKS)[number]['label']) => {
+ if (key === 'blog') return t('nav.blog')
+ if (key === 'announcements' || key === 'guides')
+ return t(`categories.${key}`)
+
+ return t(`navigation.${key}`)
+ }
+
+ return (
+
+
+ {t('navigation.add')}
+
+ }
+ description={t('navigation.desc')}
+ page="navigation"
+ title={t('nav.navigation')}
+ >
+
+ {LINKS.map(({ child, Icon, label: key, url }) => (
+
+
+
+ {label(key)}
+
+
+ {url}
+
+
+ ))}
+
+
+ )
+}
+
+const FILES = [
+ {
+ Icon: ImageIcon,
+ name: 'summer-meetup.webp',
+ size: '412 KB',
+ tint: 'bg-sky-500/15 text-sky-600 dark:text-sky-400',
+ },
+ {
+ Icon: ImageIcon,
+ name: 'avatar-maya.png',
+ size: '86 KB',
+ tint: 'bg-amber-400/20 text-amber-700 dark:text-amber-300',
+ },
+ {
+ Icon: FileText,
+ name: 'community-guidelines.pdf',
+ size: '1.2 MB',
+ tint: 'bg-red-500/15 text-red-600 dark:text-red-400',
+ },
+ {
+ Icon: FileImage,
+ name: 'cover-release-notes.avif',
+ size: '233 KB',
+ tint: 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-400',
+ },
+ {
+ Icon: ImageIcon,
+ name: 'banner-events.webp',
+ size: '540 KB',
+ tint: 'bg-primary/15 text-primary',
+ },
+ {
+ Icon: FileText,
+ name: 'press-kit.zip',
+ size: '8.4 MB',
+ tint: 'bg-muted text-muted-foreground',
+ },
+]
+
+export const FilesScreen = () => {
+ const t = useTranslations('site.home.hero.wall')
+
+ return (
+
+
+ {t('files.upload')}
+
+ }
+ description={t('files.desc')}
+ page="files"
+ title={t('nav.files')}
+ >
+
+ {FILES.map(({ Icon, name, size, tint }) => (
+
+
+
+
+
+ {name}
+
+ {size}
+
+
+
+ ))}
+
+
+ )
+}
diff --git a/apps/web/src/site/home/illustrations/hero-wall/frames.tsx b/apps/web/src/site/home/illustrations/hero-wall/frames.tsx
new file mode 100644
index 0000000000..e115d79722
--- /dev/null
+++ b/apps/web/src/site/home/illustrations/hero-wall/frames.tsx
@@ -0,0 +1,227 @@
+import { cn } from 'cn'
+import {
+ Bell,
+ ChevronDown,
+ ChevronRight,
+ FilePenLine,
+ KeyRound,
+ LayoutDashboard,
+ Link2,
+ List,
+ Monitor,
+ Server,
+ ShieldCheck,
+ Sparkles,
+ UserRound,
+ Wrench,
+} from 'lucide-react'
+import { useTranslations } from 'use-intl'
+
+export type AdminPage =
+ | 'ai_overview'
+ | 'articles'
+ | 'categories'
+ | 'cron'
+ | 'dashboard'
+ | 'files'
+ | 'integrations'
+ | 'moderators'
+ | 'navigation'
+ | 'notifications'
+ | 'roles'
+ | 'search'
+ | 'user_list'
+
+const CORE_NAV = [
+ { children: [], Icon: LayoutDashboard, key: 'dashboard' },
+ { children: ['user_list', 'roles'], Icon: UserRound, key: 'users' },
+ { children: ['moderators', 'admins'], Icon: ShieldCheck, key: 'staff' },
+ {
+ children: ['integrations', 'navigation', 'files', 'notifications'],
+ Icon: Server,
+ key: 'system',
+ },
+ { children: ['cron', 'queue', 'search'], Icon: Wrench, key: 'advanced' },
+ {
+ children: ['ai_overview', 'ai_actions', 'ai_history'],
+ Icon: Sparkles,
+ key: 'ai',
+ },
+] as const
+
+const BLOG_NAV = [
+ { Icon: FilePenLine, key: 'articles' },
+ { Icon: List, key: 'categories' },
+] as const
+
+const NavItem = ({
+ active,
+ children,
+ expandable = false,
+ Icon,
+ open = false,
+}: {
+ active: boolean
+ children: React.ReactNode
+ expandable?: boolean
+ Icon: React.ElementType
+ open?: boolean
+}) => (
+
+
+ {children}
+ {expandable &&
+ (open ? (
+
+ ) : (
+
+ ))}
+
+)
+
+const NavGroup = ({
+ children,
+ title,
+}: {
+ children: React.ReactNode
+ title: string
+}) => (
+
+ {title}
+ {children}
+
+)
+
+const AdminSidebar = ({ page }: { page: AdminPage }) => {
+ const t = useTranslations('site.home.hero.wall.nav')
+
+ return (
+
+
+ {CORE_NAV.map(({ children, Icon, key }) => {
+ const open = children.some((child) => child === page)
+
+ return (
+
+ 0}
+ Icon={Icon}
+ open={open}
+ >
+ {t(key)}
+
+ {open &&
+ children.map((child) => (
+
+ {t(child)}
+
+ ))}
+
+ )
+ })}
+
+
+ {BLOG_NAV.map(({ Icon, key }) => (
+
+ {t(key)}
+
+ ))}
+
+
+ )
+}
+
+export const SCREEN =
+ 'bg-background text-foreground flex h-130 w-240 overflow-hidden text-left'
+
+export const AdminFrame = ({
+ actions,
+ badge,
+ children,
+ description,
+ page,
+ title,
+}: {
+ actions?: React.ReactNode
+ badge?: React.ReactNode
+ children: React.ReactNode
+ description: string
+ page: AdminPage
+ title: string
+}) => (
+
+
+
+
+
+
+
+ {title}
+
+ {badge}
+
+
{description}
+
+ {actions &&
{actions}
}
+
+ {children}
+
+
+)
+
+const SETTINGS_NAV = [
+ { Icon: UserRound, key: 'overview' },
+ { Icon: KeyRound, key: 'security' },
+ { Icon: Monitor, key: 'devices' },
+ { Icon: Link2, key: 'sso' },
+ { Icon: Bell, key: 'notifications' },
+ { Icon: Sparkles, key: 'ai' },
+] as const
+
+export const SettingsFrame = ({
+ children,
+ page,
+}: {
+ children: React.ReactNode
+ page: 'devices' | 'overview'
+}) => {
+ const t = useTranslations('site.home.hero.wall.settings')
+
+ return (
+
+
+ {t('title')}
+ {t(page)}
+
+
+
+ {SETTINGS_NAV.map(({ Icon, key }) => (
+
+
+ {t(key)}
+
+ ))}
+
+
{children}
+
+
+ )
+}
diff --git a/apps/web/src/site/home/illustrations/hero-wall/hero-wall.tsx b/apps/web/src/site/home/illustrations/hero-wall/hero-wall.tsx
new file mode 100644
index 0000000000..f8e19bed53
--- /dev/null
+++ b/apps/web/src/site/home/illustrations/hero-wall/hero-wall.tsx
@@ -0,0 +1,107 @@
+import { useScrollProgress } from '@/site/home/use-scroll-progress'
+
+import {
+ ArticleScreen,
+ CategoriesScreen,
+ DashboardScreen,
+ FilesScreen,
+ NavigationScreen,
+} from './content-screens'
+import { DevicesScreen, LoginScreen, ProfileScreen } from './member-screens'
+import {
+ ModeratorsScreen,
+ NotificationsScreen,
+ RolesScreen,
+ UsersScreen,
+} from './people-screens'
+import {
+ AiScreen,
+ CronScreen,
+ IntegrationsScreen,
+ SearchScreen,
+} from './system-screens'
+
+const COLUMNS = [
+ {
+ key: 'content',
+ screens: [
+ { key: 'categories', Screen: CategoriesScreen },
+ { key: 'login', Screen: LoginScreen },
+ { key: 'notifications', Screen: NotificationsScreen },
+ { key: 'cron', Screen: CronScreen },
+ ],
+ },
+ {
+ key: 'overview',
+ screens: [
+ { key: 'ai', Screen: AiScreen },
+ { key: 'dashboard', Screen: DashboardScreen },
+ { key: 'users', Screen: UsersScreen },
+ { key: 'files', Screen: FilesScreen },
+ ],
+ },
+ {
+ key: 'publishing',
+ screens: [
+ { key: 'navigation', Screen: NavigationScreen },
+ { key: 'article', Screen: ArticleScreen },
+ { key: 'integrations', Screen: IntegrationsScreen },
+ { key: 'devices', Screen: DevicesScreen },
+ ],
+ },
+ {
+ key: 'people',
+ screens: [
+ { key: 'moderators', Screen: ModeratorsScreen },
+ { key: 'roles', Screen: RolesScreen },
+ { key: 'profile', Screen: ProfileScreen },
+ { key: 'search', Screen: SearchScreen },
+ ],
+ },
+]
+
+const slide = (index: number) => {
+ const direction = index % 2 === 0 ? -1 : 1
+ const offset = index % 2 === 0 ? 4 : -8
+
+ return `translateY(calc(var(--progress, 0) * ${direction * 14}% + ${offset}%))`
+}
+
+export const HeroWall = () => {
+ const ref = useScrollProgress(0.9)
+
+ return (
+
+
+ {COLUMNS.map(({ key, screens }, index) => (
+
+ {screens.map(({ key: screenKey, Screen }) => (
+
+
+
+ ))}
+
+ ))}
+
+
+ )
+}
diff --git a/apps/web/src/site/home/illustrations/hero-wall/member-screens.tsx b/apps/web/src/site/home/illustrations/hero-wall/member-screens.tsx
new file mode 100644
index 0000000000..57776fb5c4
--- /dev/null
+++ b/apps/web/src/site/home/illustrations/hero-wall/member-screens.tsx
@@ -0,0 +1,155 @@
+import { Badge } from '@vitnode/core/components/ui/badge'
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from '@vitnode/core/components/ui/card'
+import { Input } from '@vitnode/core/components/ui/input'
+import { cn } from 'cn'
+import { Laptop, Monitor, Smartphone } from 'lucide-react'
+import { useFormatter, useTranslations } from 'use-intl'
+
+import { SCREEN, SettingsFrame } from './frames'
+import { Field, Initials, ListRow, StaticButton, utcDate } from './parts'
+
+export const LoginScreen = () => {
+ const t = useTranslations('site.home.hero.wall.login')
+
+ return (
+
+ )
+}
+
+const Row = ({
+ children,
+ label,
+}: {
+ children: React.ReactNode
+ label: string
+}) => (
+
+ {label}
+
+ {children}
+
+
+)
+
+export const ProfileScreen = () => {
+ const t = useTranslations('site.home.hero.wall')
+ const format = useFormatter()
+
+ return (
+
+
+
+
+ Maya Andersen
+
+ {t('settings.member_since', {
+ date: format.dateTime(utcDate('2025-03-14'), {
+ month: 'long',
+ timeZone: 'UTC',
+ year: 'numeric',
+ }),
+ })}
+
+
+ {t('settings.edit_profile')}
+
+
+ maya@yourcommunity.com
+ {t('roles.administrator')}
+ {t('common.english')}
+
+ {t('settings.time_zone_value')}
+
+
+
+ )
+}
+
+export const DevicesScreen = () => {
+ const t = useTranslations('site.home.hero.wall.settings')
+ const devices = [
+ {
+ current: true,
+ Icon: Laptop,
+ meta: `Warsaw, Poland · ${t('active_now')}`,
+ name: 'Chrome · macOS',
+ },
+ {
+ current: false,
+ Icon: Smartphone,
+ meta: `Kraków, Poland · ${t('days_ago', { count: 1 })}`,
+ name: 'Safari · iOS',
+ },
+ {
+ current: false,
+ Icon: Monitor,
+ meta: `Berlin, Germany · ${t('days_ago', { count: 3 })}`,
+ name: 'Firefox · Windows',
+ },
+ ]
+
+ return (
+
+
+ {devices.map(({ current, Icon, meta, name }) => (
+
+
+
+
+
+
+ {name}
+ {current && {t('this_device')} }
+
+ {meta}
+
+ {!current && (
+ {t('sign_out')}
+ )}
+
+ ))}
+
+ {t('sign_out_all')}
+
+ )
+}
diff --git a/apps/web/src/site/home/illustrations/hero-wall/parts.tsx b/apps/web/src/site/home/illustrations/hero-wall/parts.tsx
new file mode 100644
index 0000000000..a2cefb211a
--- /dev/null
+++ b/apps/web/src/site/home/illustrations/hero-wall/parts.tsx
@@ -0,0 +1,125 @@
+import { buttonVariants } from '@vitnode/core/components/ui/button'
+import { Input } from '@vitnode/core/components/ui/input'
+import { Table } from '@vitnode/core/components/ui/table'
+import { cn } from 'cn'
+import { ChevronDown, Search } from 'lucide-react'
+import { useTranslations } from 'use-intl'
+
+export const StaticButton = ({
+ children,
+ className,
+ size = 'sm',
+ variant = 'outline',
+}: {
+ children: React.ReactNode
+ className?: string
+ size?: 'default' | 'sm' | 'xs'
+ variant?: 'default' | 'ghost' | 'outline'
+}) => (
+
+ {children}
+
+)
+
+export const Field = ({
+ children,
+ label,
+ optional = false,
+}: {
+ children: React.ReactNode
+ label: string
+ optional?: boolean
+}) => {
+ const t = useTranslations('site.home.hero.wall.common')
+
+ return (
+
+
+ {label}
+ {optional && (
+
+ {t('optional')}
+
+ )}
+
+ {children}
+
+ )
+}
+
+export const LanguageInput = ({ value }: { value: string }) => {
+ const t = useTranslations('site.home.hero.wall.common')
+
+ return (
+
+
+
+ {t('english')}
+
+
+
+ )
+}
+
+export const SearchField = ({ placeholder }: { placeholder: string }) => (
+
+
+
+
+)
+
+export const TableCard = ({ children }: { children: React.ReactNode }) => (
+
+)
+
+export const Initials = ({
+ className,
+ initials,
+}: {
+ className: string
+ initials: string
+}) => (
+
+ {initials}
+
+)
+
+export const Meter = ({ value }: { value: number }) => (
+
+
+
+)
+
+export const ToggleLook = ({ on }: { on: boolean }) => (
+
+
+
+)
+
+export const ListRow = ({ children }: { children: React.ReactNode }) => (
+
+ {children}
+
+)
+
+export const utcDate = (iso: string) => new Date(`${iso}T12:00:00Z`)
diff --git a/apps/web/src/site/home/illustrations/hero-wall/people-screens.tsx b/apps/web/src/site/home/illustrations/hero-wall/people-screens.tsx
new file mode 100644
index 0000000000..cc80897b7a
--- /dev/null
+++ b/apps/web/src/site/home/illustrations/hero-wall/people-screens.tsx
@@ -0,0 +1,316 @@
+import { Badge } from '@vitnode/core/components/ui/badge'
+import { Card } from '@vitnode/core/components/ui/card'
+import {
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@vitnode/core/components/ui/table'
+import { cn } from 'cn'
+import {
+ ArrowDown,
+ Bell,
+ Bot,
+ ExternalLink,
+ MessageSquare,
+ Pencil,
+ Plus,
+ ShieldCheck,
+ UserPlus,
+} from 'lucide-react'
+import { useFormatter, useTranslations } from 'use-intl'
+
+import { AdminFrame } from './frames'
+import {
+ Initials,
+ ListRow,
+ SearchField,
+ StaticButton,
+ TableCard,
+ ToggleLook,
+ utcDate,
+} from './parts'
+
+const MEMBERS = [
+ {
+ avatar: 'bg-primary text-primary-foreground',
+ email: 'maya@yourcommunity.com',
+ initials: 'MA',
+ joined: '2026-10-02',
+ name: 'Maya Andersen',
+ role: 'administrator',
+ },
+ {
+ avatar: 'bg-sky-500 text-white',
+ email: 'k.wojciechowski-hartmann@example.com',
+ initials: 'KW',
+ joined: '2026-09-28',
+ name: 'Maximilian Wojciechowski-Hartmann',
+ role: 'moderator',
+ },
+ {
+ avatar: 'bg-amber-400 text-amber-950',
+ email: 'olu@studio.dev',
+ initials: 'OL',
+ joined: '2026-09-14',
+ name: 'Olu Lawal',
+ role: 'member',
+ },
+ {
+ avatar: 'bg-emerald-500 text-white',
+ email: 'jun.sato@example.jp',
+ initials: 'JS',
+ joined: '2026-08-30',
+ name: 'Jun Sato',
+ role: 'member',
+ },
+] as const
+
+export const UsersScreen = () => {
+ const t = useTranslations('site.home.hero.wall')
+ const format = useFormatter()
+
+ return (
+
+
+ {t('users.add')}
+
+ }
+ description={t('users.desc')}
+ page="user_list"
+ title={t('nav.users')}
+ >
+
+
+
+
+
+ {t('users.user')}
+ {t('roles.role')}
+ {t('users.joined')}
+
+
+
+ {MEMBERS.map(({ avatar, email, initials, joined, name, role }) => (
+
+
+
+
+
+
+ {name}
+
+
+ {email}
+
+
+
+
+
+
+ {t(`roles.${role}`)}
+
+
+
+ {format.dateTime(utcDate(joined), {
+ dateStyle: 'medium',
+ timeZone: 'UTC',
+ })}
+
+
+ ))}
+
+
+
+
+ )
+}
+
+const ROLES = [
+ {
+ className: 'text-emerald-600 dark:text-emerald-400',
+ key: 'moderator',
+ updated: '2026-07-24',
+ users: 0,
+ },
+ {
+ className: 'text-red-600 dark:text-red-400',
+ key: 'administrator',
+ updated: '2026-07-24',
+ users: 2,
+ },
+ { className: '', key: 'member', updated: '2026-07-17', users: 1285 },
+ { className: '', key: 'guest', updated: '2025-11-15', users: 0 },
+] as const
+
+export const RolesScreen = () => {
+ const t = useTranslations('site.home.hero.wall')
+ const format = useFormatter()
+
+ return (
+
+
+ {t('roles.create')}
+
+ }
+ description={t('roles.desc')}
+ page="roles"
+ title={t('nav.roles')}
+ >
+
+
+
+
+
+ {t('roles.role')}
+ {t('roles.users_count')}
+
+
+ {t('roles.updated')}
+
+
+
+
+
+
+
+ {ROLES.map(({ className, key, updated, users }) => (
+
+
+ {t(`roles.${key}`)}
+
+
+ {users > 0 ? (
+
+ {format.number(users)}
+
+
+ ) : (
+
+ 0
+
+ )}
+
+
+ {format.dateTime(utcDate(updated), {
+ dateStyle: 'medium',
+ timeZone: 'UTC',
+ })}
+
+
+
+
+
+ ))}
+
+
+
+
+ )
+}
+
+const PERMISSIONS = [
+ { key: 'articles', on: true },
+ { key: 'members', on: true },
+ { key: 'roles', on: false },
+ { key: 'layout', on: true },
+ { key: 'usage', on: false },
+] as const
+
+export const ModeratorsScreen = () => {
+ const t = useTranslations('site.home.hero.wall')
+
+ return (
+ {t('roles.moderator')}}
+ description={t('moderators.desc')}
+ page="moderators"
+ title={t('nav.moderators')}
+ >
+
+ {PERMISSIONS.map(({ key, on }) => (
+
+
+
+ {t(`moderators.${key}`)}
+
+
+ {t(`moderators.${key}_desc`)}
+
+
+
+
+ ))}
+
+
+ )
+}
+
+export const NotificationsScreen = () => {
+ const t = useTranslations('site.home.hero.wall.notifications')
+ const nav = useTranslations('site.home.hero.wall.nav')
+ const activity = [
+ {
+ Icon: UserPlus,
+ meta: t('minutes_ago', { count: 2 }),
+ title: t('joined', { name: 'Olu Lawal' }),
+ unread: true,
+ },
+ {
+ Icon: MessageSquare,
+ meta: t('minutes_ago', { count: 18 }),
+ title: t('published', { name: 'Jun Sato', title: t('published_title') }),
+ unread: true,
+ },
+ {
+ Icon: ShieldCheck,
+ meta: t('hours_ago', { count: 1 }),
+ title: t('role', { name: 'Maya Andersen' }),
+ unread: false,
+ },
+ {
+ Icon: Bell,
+ meta: t('yesterday'),
+ title: t('digest', { count: 1285 }),
+ unread: false,
+ },
+ {
+ Icon: Bot,
+ meta: t('yesterday'),
+ title: t('translated', { count: 12 }),
+ unread: false,
+ },
+ ]
+
+ return (
+ {t('mark_all')}}
+ badge={{t('new', { count: 2 })} }
+ description={t('desc')}
+ page="notifications"
+ title={nav('notifications')}
+ >
+
+ {activity.map(({ Icon, meta, title, unread }) => (
+
+
+
+
+
+
+ {title}
+
+ {meta}
+
+ {unread && }
+
+ ))}
+
+
+ )
+}
diff --git a/apps/web/src/site/home/illustrations/hero-wall/system-screens.tsx b/apps/web/src/site/home/illustrations/hero-wall/system-screens.tsx
new file mode 100644
index 0000000000..a3039f81eb
--- /dev/null
+++ b/apps/web/src/site/home/illustrations/hero-wall/system-screens.tsx
@@ -0,0 +1,320 @@
+import { Badge } from '@vitnode/core/components/ui/badge'
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from '@vitnode/core/components/ui/card'
+import {
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@vitnode/core/components/ui/table'
+import { cn } from 'cn'
+import {
+ ArrowUpRight,
+ Clock,
+ Database,
+ ListChecks,
+ Mail,
+ Radio,
+ RefreshCw,
+ Sparkles,
+} from 'lucide-react'
+import { useFormatter, useTranslations } from 'use-intl'
+
+import { AdminFrame } from './frames'
+import { Meter, StaticButton, TableCard } from './parts'
+
+export const IntegrationsScreen = () => {
+ const t = useTranslations('site.home.hero.wall')
+ const services = [
+ { description: t('integrations.ai'), Icon: Sparkles, name: 'AI', ok: true },
+ {
+ description: t('integrations.websocket'),
+ Icon: Radio,
+ name: 'WebSocket',
+ ok: true,
+ },
+ {
+ description: t('integrations.redis'),
+ Icon: Database,
+ name: 'Redis',
+ ok: true,
+ },
+ {
+ description: t('integrations.email'),
+ Icon: Mail,
+ name: t('integrations.email_title'),
+ ok: true,
+ },
+ {
+ description: t('integrations.cron'),
+ Icon: Clock,
+ name: t('nav.cron'),
+ ok: false,
+ },
+ {
+ description: t('integrations.queue'),
+ Icon: ListChecks,
+ name: t('nav.queue'),
+ ok: true,
+ },
+ ]
+
+ return (
+
+
+ {services.map(({ description, Icon, name, ok }) => (
+
+
+
+
+
+
+
+ {name}
+
+
+ {ok ? t('common.active') : t('integrations.unreachable')}
+
+
+
+ {description}
+
+
+
+
+ {t('common.read_more')}
+
+
+
+
+ ))}
+
+
+ )
+}
+
+const FEATURES = [
+ { key: 'translate', share: 46 },
+ { key: 'summaries', share: 28 },
+ { key: 'alt_text', share: 17 },
+] as const
+
+export const AiScreen = () => {
+ const t = useTranslations('site.home.hero.wall.ai')
+ const nav = useTranslations('site.home.hero.wall.nav')
+ const format = useFormatter()
+ const stats = [
+ { label: t('points'), value: format.number(18_420) },
+ { label: t('requests'), value: format.number(3912) },
+ { label: t('budget'), value: format.number(0.62, { style: 'percent' }) },
+ ]
+
+ return (
+
+
+ {t('models', { count: 2 })}
+
+ }
+ description={t('desc')}
+ page="ai_overview"
+ title={nav('ai')}
+ >
+
+ {stats.map(({ label, value }) => (
+
+
+ {label}
+
+ {value}
+
+
+
+ ))}
+
+
+
+ {FEATURES.map(({ key, share }) => (
+
+
+ {t(key)}
+
+ {format.number(share / 100, { style: 'percent' })}
+
+
+
+
+ ))}
+
+
+
+ )
+}
+
+const CRON_BADGE = {
+ failed: 'destructive',
+ healthy: 'success',
+ running: 'secondary',
+} as const
+
+export const CronScreen = () => {
+ const t = useTranslations('site.home.hero.wall')
+ const jobs = [
+ {
+ name: t('cron.sessions'),
+ next: t('cron.in_minutes', { count: 12 }),
+ schedule: t('cron.hourly'),
+ status: 'healthy',
+ },
+ {
+ name: t('cron.digest'),
+ next: t('cron.tomorrow'),
+ schedule: t('cron.weekly'),
+ status: 'healthy',
+ },
+ {
+ name: t('cron.search'),
+ next: t('cron.in_hours', { count: 3 }),
+ schedule: t('cron.six_hours'),
+ status: 'running',
+ },
+ {
+ name: t('cron.files'),
+ next: t('cron.tomorrow'),
+ schedule: t('cron.daily'),
+ status: 'failed',
+ },
+ ] as const
+
+ return (
+
+
+
+
+ {t('cron.scheduler')}
+
+
+ {t('cron.last_tick', { count: 14 })}
+
+
+
+
+
+ {t('cron.job')}
+ {t('cron.next_run')}
+ {t('cron.status')}
+
+
+
+ {jobs.map(({ name, next, schedule, status }) => (
+
+
+
+ {name}
+
+ {schedule}
+
+
+
+ {next}
+
+
+ {t(`cron.${status}`)}
+
+
+
+ ))}
+
+
+
+ )
+}
+
+const INDEXED = [
+ { count: 1240, key: 'articles' },
+ { count: 1285, key: 'users' },
+ { count: 38, key: 'categories' },
+] as const
+
+const LARGEST_INDEX = Math.max(...INDEXED.map(({ count }) => count))
+
+export const SearchScreen = () => {
+ const t = useTranslations('site.home.hero.wall')
+ const format = useFormatter()
+
+ return (
+
+
+ {t('search.reindex')}
+
+ }
+ description={t('search.desc')}
+ page="search"
+ title={t('nav.search')}
+ >
+
+
+
+ {t('search.documents')}
+
+ {format.number(2563)}
+
+
+
+
+
+ {t('search.engine')}
+
+
+ {t('search.engine_value')}
+
+
+
+
+
+
+
+ {t('search.type')}
+ {t('search.documents')}
+
+
+
+ {INDEXED.map(({ count, key }) => (
+
+ {t(`nav.${key}`)}
+
+
+
+
+ {format.number(count)}
+
+
+
+
+ ))}
+
+
+
+ {t('search.rebuilt', { count: 3 })}
+
+
+ )
+}
diff --git a/apps/web/src/site/home/sections/hero.tsx b/apps/web/src/site/home/sections/hero.tsx
index 6c8e7c7527..c8dfbb0049 100644
--- a/apps/web/src/site/home/sections/hero.tsx
+++ b/apps/web/src/site/home/sections/hero.tsx
@@ -4,10 +4,7 @@ import { cn } from 'cn'
import { ArrowRight, Bird, Bot, CodeXml, Scale } from 'lucide-react'
import { useTranslations } from 'use-intl'
-import { HeroIconField } from '@/site/home/illustrations/hero-icon-field'
-import { HeroPreview } from '@/site/home/illustrations/hero-preview'
-import { HeroStrands } from '@/site/home/illustrations/hero-strands'
-import { usePointerParallax } from '@/site/home/use-pointer-parallax'
+import { HeroWall } from '@/site/home/illustrations/hero-wall/hero-wall'
import { BUTTON } from '@/site/marketing/shared'
const TRUST = [
@@ -18,19 +15,14 @@ const TRUST = [
export const HeroSection = () => {
const t = useTranslations('site.home.hero')
- const { handlers, targetRef } = usePointerParallax()
return (
-
-
-
-
-
+
+
{t('canary')}
@@ -81,7 +73,7 @@ export const HeroSection = () => {
-
+
)
diff --git a/apps/web/src/site/home/use-pointer-parallax.ts b/apps/web/src/site/home/use-pointer-parallax.ts
deleted file mode 100644
index ae213754a9..0000000000
--- a/apps/web/src/site/home/use-pointer-parallax.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-import { useRef } from 'react'
-
-const prefersReducedMotion = () =>
- window.matchMedia('(prefers-reduced-motion: reduce)').matches
-
-export const usePointerParallax =
() => {
- const targetRef = useRef(null)
- const frameRef = useRef(0)
-
- const setOffset = (x: number, y: number) => {
- cancelAnimationFrame(frameRef.current)
- frameRef.current = requestAnimationFrame(() => {
- targetRef.current?.style.setProperty('--px', `${x}`)
- targetRef.current?.style.setProperty('--py', `${y}`)
- })
- }
-
- const onPointerMove = (event: React.PointerEvent) => {
- if (event.pointerType !== 'mouse' || prefersReducedMotion()) return
-
- const bounds = event.currentTarget.getBoundingClientRect()
-
- setOffset(
- -((event.clientX - bounds.left) / bounds.width - 0.5) * 2,
- -((event.clientY - bounds.top) / bounds.height - 0.5) * 2,
- )
- }
-
- const onPointerLeave = () => {
- setOffset(0, 0)
- }
-
- return { handlers: { onPointerLeave, onPointerMove }, targetRef }
-}
diff --git a/apps/web/src/site/home/use-scroll-progress.ts b/apps/web/src/site/home/use-scroll-progress.ts
new file mode 100644
index 0000000000..bde30e04ad
--- /dev/null
+++ b/apps/web/src/site/home/use-scroll-progress.ts
@@ -0,0 +1,39 @@
+import { useEffect, useRef } from 'react'
+
+const prefersReducedMotion = () =>
+ window.matchMedia('(prefers-reduced-motion: reduce)').matches
+
+export const useScrollProgress = (viewports: number) => {
+ const ref = useRef(null)
+
+ useEffect(() => {
+ const node = ref.current
+
+ if (!node || prefersReducedMotion()) return
+
+ let frame = 0
+
+ const update = () => {
+ frame = 0
+ const distance = Math.max(window.innerHeight * viewports, 360)
+ const progress = Math.min(Math.max(window.scrollY / distance, 0), 1)
+ node.style.setProperty('--progress', progress.toFixed(4))
+ }
+
+ const schedule = () => {
+ if (!frame) frame = requestAnimationFrame(update)
+ }
+
+ update()
+ window.addEventListener('scroll', schedule, { passive: true })
+ window.addEventListener('resize', schedule)
+
+ return () => {
+ cancelAnimationFrame(frame)
+ window.removeEventListener('scroll', schedule)
+ window.removeEventListener('resize', schedule)
+ }
+ }, [viewports])
+
+ return ref
+}
diff --git a/apps/web/src/site/marketing/marketing.css b/apps/web/src/site/marketing/marketing.css
index 893dde2c6e..06a81e039d 100644
--- a/apps/web/src/site/marketing/marketing.css
+++ b/apps/web/src/site/marketing/marketing.css
@@ -1,13 +1,3 @@
-@keyframes mk-float {
- 0%,
- 100% {
- transform: translateY(0);
- }
- 50% {
- transform: translateY(-8px);
- }
-}
-
@keyframes mk-orbit {
to {
transform: rotate(360deg);
@@ -288,10 +278,6 @@
transform-origin: 0% 50%;
}
-.mk-anim-float {
- animation: mk-float 4.5s ease-in-out infinite;
-}
-
.mk-anim-dash {
stroke-dasharray: 6 10;
animation: mk-dash 1.4s linear infinite;
@@ -799,75 +785,6 @@
animation: mk-bot-block 6s ease-in-out infinite;
}
-@keyframes mk-sway {
- 0%,
- 100% {
- transform: translateY(0) scaleY(1);
- }
- 50% {
- transform: translateY(-16px) scaleY(1.1);
- }
-}
-
-.mk-anim-sway {
- transform-box: fill-box;
- transform-origin: center;
- animation: mk-sway 14s ease-in-out infinite;
-}
-
-@keyframes mk-flow {
- to {
- stroke-dashoffset: -1;
- }
-}
-
-.mk-anim-flow {
- stroke-dasharray: 0.07 0.93;
- animation: mk-flow 5s linear infinite;
-}
-
-@keyframes mk-enter-fade {
- from {
- opacity: 0;
- transform: translateY(6px);
- }
-}
-
-.mk-enter-fade {
- animation: mk-enter-fade 600ms cubic-bezier(0.23, 1, 0.32, 1) both;
-}
-
-@keyframes mk-enter-pop {
- from {
- opacity: 0;
- transform: scale(0.94);
- }
-}
-
-.mk-enter-pop {
- animation: mk-enter-pop 500ms cubic-bezier(0.23, 1, 0.32, 1) both;
-}
-
-.mk-parallax {
- transform: translate3d(
- calc(var(--px, 0) * var(--depth, 10) * 1px),
- calc(var(--py, 0) * var(--depth, 10) * 1px),
- 0
- );
- transition: transform 700ms cubic-bezier(0.23, 1, 0.32, 1);
-}
-
-@media (prefers-reduced-motion: reduce) {
- .mk-enter-fade,
- .mk-enter-pop {
- animation: none;
- }
-
- .mk-parallax {
- transition: none;
- }
-}
-
.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/styles.css b/apps/web/src/styles.css
index a01404d819..f61664ff42 100644
--- a/apps/web/src/styles.css
+++ b/apps/web/src/styles.css
@@ -90,12 +90,14 @@
--dev-color: oklch(0.55 0.145 50);
--ui-color: oklch(0.52 0.102 170);
--guides-color: oklch(0.45 0.16 264);
+ --brand-color: oklch(0.55 0.17 10);
}
.dark {
--dev-color: oklch(0.75 0.166 50);
--ui-color: oklch(0.7 0.138 170);
--guides-color: oklch(0.65 0.16 264);
+ --brand-color: oklch(0.72 0.15 10);
}
.dev {
@@ -110,6 +112,10 @@
--color-fd-primary: var(--ui-color);
}
+.brand {
+ --color-fd-primary: var(--brand-color);
+}
+
@layer base {
* {
@apply border-border outline-ring/50;
diff --git a/packages/vitnode/scripts/cli/builder/report.ts b/packages/vitnode/scripts/cli/builder/report.ts
index fedb66e5ca..d76f82de61 100644
--- a/packages/vitnode/scripts/cli/builder/report.ts
+++ b/packages/vitnode/scripts/cli/builder/report.ts
@@ -313,11 +313,13 @@ interface RouteLine {
}
const routeLines = (ui: Ui, routes: readonly BuildRoute[]): RouteLine[] => {
- const collapsed = ui.verbose
- ? []
- : routes.filter(route => isAdminRoute(route) && route.mode === "dynamic");
+ const collapsed = new Set(
+ ui.verbose
+ ? []
+ : routes.filter(route => isAdminRoute(route) && route.mode === "dynamic"),
+ );
const lines: RouteLine[] = routes
- .filter(route => !collapsed.includes(route))
+ .filter(route => !collapsed.has(route))
.map(route => ({
children:
route.staticPaths.length === 1 && route.staticPaths[0] === route.path
@@ -327,10 +329,10 @@ const routeLines = (ui: Ui, routes: readonly BuildRoute[]): RouteLine[] => {
path: route.path,
}));
- if (collapsed.length > 0) {
+ if (collapsed.size > 0) {
lines.push({
children: [],
- detail: `${plural(collapsed.length, "AdminCP route")} (--verbose lists all)`,
+ detail: `${plural(collapsed.size, "AdminCP route")} (--verbose lists all)`,
mode: "dynamic",
path: `${ADMIN_PREFIX}/*`,
});
diff --git a/packages/vitnode/src/components/pet/export.ts b/packages/vitnode/src/components/pet/export.ts
new file mode 100644
index 0000000000..39de057bf2
--- /dev/null
+++ b/packages/vitnode/src/components/pet/export.ts
@@ -0,0 +1,80 @@
+import { PET_CSS } from "./styles";
+
+const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
+const EXPORT_BOX = { height: 488, width: 496, x: -48, y: -24 };
+const PNG_WIDTH = 1024;
+
+export const getPetSvgMarkup = (
+ svg: SVGSVGElement,
+ { animated = true }: { animated?: boolean } = {},
+) => {
+ const clone = svg.cloneNode(true) as SVGSVGElement;
+
+ clone.setAttribute("class", "vitnode-pet");
+ clone.removeAttribute("aria-hidden");
+ clone.removeAttribute("data-pet-state");
+ clone.setAttribute(
+ "viewBox",
+ `${EXPORT_BOX.x} ${EXPORT_BOX.y} ${EXPORT_BOX.width} ${EXPORT_BOX.height}`,
+ );
+ clone.setAttribute("width", String(EXPORT_BOX.width));
+ clone.setAttribute("height", String(EXPORT_BOX.height));
+
+ if (animated) {
+ const style = document.createElementNS(SVG_NAMESPACE, "style");
+ style.textContent = PET_CSS;
+ clone.prepend(style);
+ }
+
+ return new XMLSerializer().serializeToString(clone);
+};
+
+const saveBlob = (blob: Blob, fileName: string) => {
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement("a");
+ link.href = url;
+ link.download = fileName;
+ link.click();
+ setTimeout(() => {
+ URL.revokeObjectURL(url);
+ }, 1000);
+};
+
+export const downloadPetSvg = (svg: SVGSVGElement, fileName: string) => {
+ saveBlob(
+ new Blob([getPetSvgMarkup(svg)], { type: "image/svg+xml" }),
+ `${fileName}.svg`,
+ );
+};
+
+export const downloadPetPng = async (svg: SVGSVGElement, fileName: string) => {
+ const url = URL.createObjectURL(
+ new Blob([getPetSvgMarkup(svg, { animated: false })], {
+ type: "image/svg+xml",
+ }),
+ );
+
+ try {
+ const image = new Image();
+ image.src = url;
+ await image.decode();
+
+ const canvas = document.createElement("canvas");
+ canvas.width = PNG_WIDTH;
+ canvas.height = Math.round(
+ (PNG_WIDTH * EXPORT_BOX.height) / EXPORT_BOX.width,
+ );
+ const context = canvas.getContext("2d");
+ if (!context) throw new Error("Canvas 2D context is not available");
+ context.drawImage(image, 0, 0, canvas.width, canvas.height);
+
+ const blob = await new Promise(resolve => {
+ canvas.toBlob(resolve, "image/png");
+ });
+ if (!blob) throw new Error("PNG encoding failed");
+
+ saveBlob(blob, `${fileName}.png`);
+ } finally {
+ URL.revokeObjectURL(url);
+ }
+};
diff --git a/packages/vitnode/src/components/pet/parts.tsx b/packages/vitnode/src/components/pet/parts.tsx
new file mode 100644
index 0000000000..29a19d012b
--- /dev/null
+++ b/packages/vitnode/src/components/pet/parts.tsx
@@ -0,0 +1,320 @@
+import React from "react";
+
+const BODY_PATH =
+ "M180.601 65.195C192.605 58.268 207.395 58.268 219.399 65.195L348.601 139.745C360.605 146.671 368 159.472 368 173.325V322.425C368 336.278 360.605 349.079 348.601 356.005L219.399 430.555C207.395 437.482 192.605 437.482 180.601 430.555L51.399 356.005C39.395 349.079 32 336.278 32 322.425V173.325C32 159.472 39.395 146.671 51.399 139.745L180.601 65.195Z";
+
+export const MARK_PATH =
+ "M109.649 106.082L187.5 61.1613L265.351 106.082L187.5 241.071L109.649 106.082ZM77.6969 148.394V251.232L166.585 302.521L77.6969 148.394ZM208.415 302.521L297.303 251.232V148.394L208.415 302.521Z";
+
+export const HEART_PATH =
+ "M0 7C-11 -1 -13 -9 -7 -13C-3 -15 0 -12 0 -9C0 -12 3 -15 7 -13C13 -9 11 -1 0 7Z";
+
+export const DROP_PATH = "M0 -10C6 -2 8 2 8 6A8 8 0 0 1 -8 6C-8 2 -6 -2 0 -10Z";
+
+export const BLUE = "#3261BF";
+export const INDIGO = "#363795";
+export const NAVY = "#1B2140";
+export const WHITE = "#FDFEFF";
+export const AMBER = "#F5B544";
+export const PINK = "#F59AB0";
+
+export const RAISED_TAIL =
+ "M334 384C392 384 404 330 404 236C404 186 404 160 424 150";
+export const LIMP_TAIL = "M330 400C370 424 402 432 424 424";
+const CURLED_TAIL = "M330 384C390 384 392 300 360 288";
+
+const useGradientId = () =>
+ `vn-pet-${React.useId().replace(/[^a-zA-Z0-9_-]/g, "")}`;
+
+export const Feet = () => (
+ <>
+
+
+ >
+);
+
+export const ShinyEye = ({ cx, cy }: { cx: number; cy: number }) => (
+
+
+
+
+
+);
+
+export const OpenEyes = () => (
+ <>
+
+
+ >
+);
+
+export const HappyEyes = () => (
+
+);
+
+export const LookUpEyes = () => (
+
+
+
+
+
+
+);
+
+export const CatMouth = () => (
+
+);
+
+export const OpenMouth = () => (
+
+);
+
+export const FlatMouth = () => (
+
+);
+
+const LeftEar = () => (
+ <>
+
+
+ >
+);
+
+const RightEar = () => (
+ <>
+
+
+ >
+);
+
+export const Ears = ({ twitch = true }: { twitch?: boolean }) => (
+ <>
+
+
+
+
+ >
+);
+
+export const DroopyEars = () => (
+ <>
+
+
+
+
+
+
+ >
+);
+
+export const Paw = ({
+ cx,
+ cy,
+ r = 15,
+}: {
+ cx: number;
+ cy: number;
+ r?: number;
+}) => (
+
+);
+
+export const LeftArm = () => (
+
+);
+
+export const RightArm = () => (
+
+);
+
+const ArmsDown = () => (
+ <>
+
+
+ >
+);
+
+export const StripedTail = ({ d }: { d: string }) => (
+ <>
+
+
+ >
+);
+
+export const TabbyTail = ({
+ d = CURLED_TAIL,
+ duration,
+ origin = "330px 384px",
+}: {
+ d?: string;
+ duration?: string;
+ origin?: string;
+}) => (
+
+
+
+);
+
+export const Tabby = ({
+ arms = ,
+ back,
+ belly = true,
+ ears = ,
+ eyes = ,
+ feet = ,
+ front,
+ motion,
+ mouth = ,
+ tail = ,
+}: {
+ arms?: React.ReactNode;
+ back?: React.ReactNode;
+ belly?: boolean;
+ ears?: React.ReactNode;
+ eyes?: React.ReactNode;
+ feet?: React.ReactNode;
+ front?: React.ReactNode;
+ motion?: string;
+ mouth?: React.ReactNode;
+ tail?: React.ReactNode;
+}) => {
+ const gradientId = useGradientId();
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+ {back}
+ {tail}
+ {ears}
+ {arms}
+ {feet}
+
+
+
+
+ {eyes}
+
+
+
+ {mouth}
+
+ {belly ? (
+
+ ) : null}
+ {front}
+
+ >
+ );
+};
diff --git a/packages/vitnode/src/components/pet/pet.test.tsx b/packages/vitnode/src/components/pet/pet.test.tsx
new file mode 100644
index 0000000000..67c8c9d454
--- /dev/null
+++ b/packages/vitnode/src/components/pet/pet.test.tsx
@@ -0,0 +1,56 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { getPetSvgMarkup } from "./export";
+import { Pet, PET_STATE_NAMES } from "./pet";
+
+const petOf = (container: HTMLElement) => {
+ const svg = container.querySelector("svg.vitnode-pet");
+ if (!(svg instanceof SVGSVGElement)) throw new Error("Pet SVG not rendered");
+
+ return svg;
+};
+
+describe("Pet", () => {
+ it("hides a decorative pet from assistive technology", () => {
+ const { container } = render( );
+
+ expect(petOf(container).getAttribute("aria-hidden")).toBe("true");
+ expect(screen.queryByRole("img")).toBeNull();
+ });
+
+ it("names the pet when it carries meaning", () => {
+ render( );
+
+ expect(
+ screen.getByRole("img", { name: "Tabby waves hello" }),
+ ).toBeDefined();
+ });
+
+ it.each(PET_STATE_NAMES)("draws the %s state", state => {
+ const { container } = render( );
+
+ expect(petOf(container).dataset.petState).toBe(state);
+ expect(petOf(container).childElementCount).toBeGreaterThan(0);
+ });
+});
+
+describe("getPetSvgMarkup", () => {
+ it("exports a standalone animated SVG with room for tails and props", () => {
+ const { container } = render( );
+ const markup = getPetSvgMarkup(petOf(container));
+
+ expect(markup).toContain('xmlns="http://www.w3.org/2000/svg"');
+ expect(markup).toContain('viewBox="-48 -24 496 488"');
+ expect(markup).toContain("@keyframes vn-pet-confetti");
+ expect(markup).not.toContain("aria-hidden");
+ });
+
+ it("leaves the animation styles out of a static export", () => {
+ const { container } = render( );
+
+ expect(
+ getPetSvgMarkup(petOf(container), { animated: false }),
+ ).not.toContain("@keyframes");
+ });
+});
diff --git a/packages/vitnode/src/components/pet/pet.tsx b/packages/vitnode/src/components/pet/pet.tsx
new file mode 100644
index 0000000000..5b441f98c5
--- /dev/null
+++ b/packages/vitnode/src/components/pet/pet.tsx
@@ -0,0 +1,40 @@
+import { cn } from "cn";
+
+import { PET_STATES, type PetState } from "./states";
+import { PET_CSS } from "./styles";
+
+export type { PetState } from "./states";
+
+export const PET_STATE_NAMES = Object.keys(PET_STATES) as PetState[];
+
+export const Pet = ({
+ className,
+ label,
+ state = "idle",
+}: {
+ className?: string;
+ label?: string;
+ state?: PetState;
+}) => {
+ const State = PET_STATES[state];
+
+ return (
+ <>
+
+
+
+
+ >
+ );
+};
diff --git a/packages/vitnode/src/components/pet/states.tsx b/packages/vitnode/src/components/pet/states.tsx
new file mode 100644
index 0000000000..9827d907f3
--- /dev/null
+++ b/packages/vitnode/src/components/pet/states.tsx
@@ -0,0 +1,687 @@
+import {
+ AMBER,
+ BLUE,
+ CatMouth,
+ DroopyEars,
+ DROP_PATH,
+ Ears,
+ FlatMouth,
+ HappyEyes,
+ HEART_PATH,
+ INDIGO,
+ LeftArm,
+ LIMP_TAIL,
+ LookUpEyes,
+ MARK_PATH,
+ NAVY,
+ OpenEyes,
+ OpenMouth,
+ Paw,
+ PINK,
+ RAISED_TAIL,
+ RightArm,
+ ShinyEye,
+ StripedTail,
+ Tabby,
+ TabbyTail,
+ WHITE,
+} from "./parts";
+
+const Idle = () => ;
+
+const Hello = () => (
+
+
+
+
+
+ >
+ }
+ mouth={ }
+ tail={ }
+ />
+);
+
+const Coding = () => (
+
+
+
+ {">"}
+
+ >
+ }
+ belly={false}
+ eyes={
+ <>
+
+
+ >
+ }
+ feet={null}
+ front={
+ <>
+
+
+
+
+
+
+
+
+
+ >
+ }
+ />
+);
+
+const Coffee = () => (
+ }
+ front={
+ <>
+ {[186, 200, 214].map((x, index) => (
+
+ ))}
+
+
+
+
+
+ >
+ }
+ />
+);
+
+const Love = () => (
+ }
+ front={
+ <>
+
+
+
+
+
+ {[
+ { delay: "0s", x: 110, y: 84 },
+ { delay: "0.9s", x: 296, y: 74 },
+ { delay: "1.8s", x: 70, y: 150 },
+ ].map(heart => (
+
+
+
+ ))}
+ >
+ }
+ />
+);
+
+const Music = () => (
+ }
+ front={
+ <>
+
+
+
+
+
+
+
+ ♪
+
+
+ ♫
+
+
+ ♪
+
+
+ >
+ }
+ motion="vn-pet-groove"
+ mouth={
+
+ }
+ tail={ }
+ />
+);
+
+const Sleeping = () => (
+ }
+ eyes={
+
+ }
+ feet={null}
+ front={
+ <>
+
+
+
+
+
+
+ z
+
+
+ z
+
+
+ z
+
+
+ >
+ }
+ motion="vn-pet-breathe"
+ tail={null}
+ />
+);
+
+const Thinking = () => (
+ }
+ back={
+ <>
+
+
+
+ {[346, 364, 382].map((cx, index) => (
+
+ ))}
+ >
+ }
+ eyes={ }
+ front={
+ <>
+
+
+ >
+ }
+ mouth={ }
+ />
+);
+
+const CONFETTI = [
+ { color: AMBER, delay: "0s", x: 60, y: 30 },
+ { color: PINK, delay: "0.3s", x: 120, y: -10 },
+ { color: BLUE, delay: "0.6s", x: 176, y: 10 },
+ { color: AMBER, delay: "0.9s", x: 236, y: -6 },
+ { color: PINK, delay: "1.2s", x: 292, y: 20 },
+ { color: BLUE, delay: "1.5s", x: 344, y: 0 },
+ { color: AMBER, delay: "0.45s", x: 30, y: 90 },
+ { color: PINK, delay: "1.05s", x: 376, y: 70 },
+];
+
+const Celebrate = () => (
+
+
+
+
+
+
+
+ >
+ }
+ eyes={ }
+ front={CONFETTI.map(piece => (
+
+
+
+ ))}
+ mouth={
+ <>
+
+
+ >
+ }
+ tail={ }
+ />
+);
+
+const Searching = () => (
+ }
+ eyes={ }
+ front={
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+ />
+);
+
+const WIFI_ARCS = [
+ "M-12 0Q0 -12 12 0",
+ "M-24 -12Q0 -34 24 -12",
+ "M-36 -24Q0 -56 36 -24",
+];
+
+const Offline = () => (
+
+
+
+ >
+ }
+ front={
+ <>
+
+
+ {WIFI_ARCS.map((d, index) => (
+
+ ))}
+
+
+
+
+
+
+ >
+ }
+ mouth={ }
+ tail={ }
+ />
+);
+
+const Oops = () => (
+ }
+ eyes={
+ <>
+
+
+
+
+
+ >
+ }
+ mouth={
+
+ }
+ tail={ }
+ />
+);
+
+const STAR_PATH = "M0 -12L3.5 -4L12 -4L5 2L8 11L0 6L-8 11L-5 2L-12 -4L-3.5 -4Z";
+
+const Dizzy = () => (
+ (
+
+ ))}
+ front={
+
+ {["0s", "-0.8s", "-1.6s"].map(delay => (
+
+ ))}
+
+ }
+ motion="vn-pet-woozy"
+ mouth={
+ <>
+
+
+ >
+ }
+ />
+);
+
+const Building = () => (
+ }
+ front={
+ <>
+
+
+
+
+
+
+
+
+
+
+ >
+ }
+ mouth={
+ <>
+
+
+ >
+ }
+ />
+);
+
+export const PET_STATES = {
+ idle: Idle,
+ hello: Hello,
+ coding: Coding,
+ coffee: Coffee,
+ love: Love,
+ music: Music,
+ sleeping: Sleeping,
+ thinking: Thinking,
+ celebrate: Celebrate,
+ searching: Searching,
+ offline: Offline,
+ oops: Oops,
+ dizzy: Dizzy,
+ building: Building,
+};
+
+export type PetState = keyof typeof PET_STATES;
diff --git a/packages/vitnode/src/components/pet/styles.ts b/packages/vitnode/src/components/pet/styles.ts
new file mode 100644
index 0000000000..f71bb8bbea
--- /dev/null
+++ b/packages/vitnode/src/components/pet/styles.ts
@@ -0,0 +1,47 @@
+export const PET_CSS = `
+.vn-pet-blink{transform-box:fill-box;transform-origin:center;animation:vn-pet-blink 4.2s ease-in-out infinite}
+@keyframes vn-pet-blink{0%,90%,100%{transform:scaleY(1)}94%{transform:scaleY(.1)}}
+.vn-pet-ear{animation:vn-pet-ear 5s ease-in-out infinite}
+@keyframes vn-pet-ear{0%,84%,100%{transform:rotate(0)}88%{transform:rotate(-12deg)}92%{transform:rotate(4deg)}}
+.vn-pet-sway{animation:vn-pet-sway 1.6s ease-in-out infinite alternate}
+@keyframes vn-pet-sway{from{transform:rotate(-10deg)}to{transform:rotate(12deg)}}
+.vn-pet-wave{animation:vn-pet-wave .8s ease-in-out infinite alternate}
+@keyframes vn-pet-wave{from{transform:rotate(-12deg)}to{transform:rotate(16deg)}}
+.vn-pet-cheer{animation:vn-pet-cheer 1.2s ease-in-out infinite alternate}
+@keyframes vn-pet-cheer{from{transform:rotate(-8deg)}to{transform:rotate(8deg)}}
+.vn-pet-type{animation:vn-pet-type .36s ease-in-out infinite alternate}
+@keyframes vn-pet-type{from{transform:translateY(0)}to{transform:translateY(-10px)}}
+.vn-pet-heart{transform-box:fill-box;transform-origin:center;opacity:0;animation:vn-pet-heart 2.7s ease-out infinite}
+@keyframes vn-pet-heart{0%{opacity:0;transform:translate(0,0) scale(.5)}20%{opacity:1;transform:translate(-2px,-8px) scale(1)}100%{opacity:0;transform:translate(-12px,-44px) scale(.9)}}
+.vn-pet-rise-note{transform-box:fill-box;opacity:0;animation:vn-pet-rise-note 2.4s ease-out infinite}
+@keyframes vn-pet-rise-note{0%{opacity:0;transform:translate(0,8px)}30%{opacity:1}100%{opacity:0;transform:translate(10px,-24px)}}
+.vn-pet-breathe{transform-box:fill-box;transform-origin:50% 100%;animation:vn-pet-breathe 3.2s ease-in-out infinite}
+@keyframes vn-pet-breathe{0%,100%{transform:scale(1,1)}50%{transform:scale(1.02,1.03)}}
+.vn-pet-dot{transform-box:fill-box;animation:vn-pet-dot 1.2s ease-in-out infinite}
+@keyframes vn-pet-dot{0%,60%,100%{transform:translateY(0);opacity:.4}30%{transform:translateY(-6px);opacity:1}}
+.vn-pet-scan{animation:vn-pet-scan 2.2s ease-in-out infinite alternate}
+@keyframes vn-pet-scan{from{transform:translate(-10px,2px)}to{transform:translate(8px,-4px)}}
+.vn-pet-confetti{transform-box:fill-box;transform-origin:center;opacity:0;animation:vn-pet-confetti 1.8s ease-in infinite}
+@keyframes vn-pet-confetti{0%{opacity:0;transform:translateY(0) rotate(0)}10%{opacity:1}100%{opacity:0;transform:translateY(130px) rotate(540deg)}}
+.vn-pet-pulse{transform-box:fill-box;transform-origin:center;animation:vn-pet-pulse 1.1s ease-in-out infinite}
+@keyframes vn-pet-pulse{0%,30%,100%{transform:scale(1)}15%{transform:scale(1.12)}45%{transform:scale(1.08)}}
+.vn-pet-snore{transform-box:fill-box;transform-origin:0% 50%;animation:vn-pet-snore 3.2s ease-in-out infinite}
+@keyframes vn-pet-snore{0%,100%{transform:scale(.3)}50%{transform:scale(1)}}
+.vn-pet-drip{transform-box:fill-box;animation:vn-pet-drip 1.8s ease-in infinite}
+@keyframes vn-pet-drip{0%{opacity:0;transform:translateY(-4px)}20%{opacity:1}100%{opacity:0;transform:translateY(30px)}}
+.vn-pet-steam{transform-box:fill-box;opacity:0;animation:vn-pet-steam 2.1s ease-out infinite}
+@keyframes vn-pet-steam{0%{opacity:0;transform:translateY(6px)}40%{opacity:.7}100%{opacity:0;transform:translateY(-14px)}}
+.vn-pet-groove{transform-box:fill-box;transform-origin:50% 100%;animation:vn-pet-groove .5s ease-in-out infinite alternate}
+@keyframes vn-pet-groove{from{transform:rotate(-4deg)}to{transform:rotate(4deg)}}
+.vn-pet-signal{animation:vn-pet-signal 1.5s ease-in-out infinite}
+@keyframes vn-pet-signal{0%,100%{opacity:.15}40%{opacity:1}}
+.vn-pet-spin{transform-box:fill-box;transform-origin:center;animation:vn-pet-spin 1.4s linear infinite}
+@keyframes vn-pet-spin{to{transform:rotate(360deg)}}
+.vn-pet-orbit{animation:vn-pet-orbit 2.4s linear infinite}
+@keyframes vn-pet-orbit{0%,100%{transform:translate(112px,0) scale(1)}12.5%{transform:translate(79px,18px) scale(1.15)}25%{transform:translate(0,26px) scale(1.25)}37.5%{transform:translate(-79px,18px) scale(1.15)}50%{transform:translate(-112px,0) scale(1)}62.5%{transform:translate(-79px,-18px) scale(.85)}75%{transform:translate(0,-26px) scale(.75)}87.5%{transform:translate(79px,-18px) scale(.85)}}
+.vn-pet-woozy{transform-box:fill-box;transform-origin:50% 100%;animation:vn-pet-woozy 1.6s ease-in-out infinite alternate}
+@keyframes vn-pet-woozy{from{transform:rotate(-4deg)}to{transform:rotate(4deg)}}
+.vn-pet-hammer{animation:vn-pet-hammer .9s ease-in-out infinite}
+@keyframes vn-pet-hammer{0%,100%{transform:rotate(-10deg)}35%{transform:rotate(40deg)}}
+@media (prefers-reduced-motion:reduce){.vitnode-pet *{animation:none}}
+`;
diff --git a/packages/vitnode/src/components/ui/copy-button.test.tsx b/packages/vitnode/src/components/ui/copy-button.test.tsx
index f07492c0b8..f664fcc66a 100644
--- a/packages/vitnode/src/components/ui/copy-button.test.tsx
+++ b/packages/vitnode/src/components/ui/copy-button.test.tsx
@@ -166,6 +166,17 @@ describe("CopyButton", () => {
).toBeTruthy();
});
+ it("copies text read lazily from a synchronous content function", async () => {
+ const writeText = stubClipboard(async () => {});
+ let markup = " ";
+ render( markup} />);
+ markup = "tabby ";
+
+ await clickAndFlush(screen.getByRole("button"));
+
+ expect(writeText).toHaveBeenCalledWith("tabby ");
+ });
+
it("hands a pending clipboard item to the clipboard when supported", async () => {
class FakeClipboardItem {
readonly items: Record>;
diff --git a/packages/vitnode/src/components/ui/copy-button.tsx b/packages/vitnode/src/components/ui/copy-button.tsx
index 75bce72104..9fbb3e6044 100644
--- a/packages/vitnode/src/components/ui/copy-button.tsx
+++ b/packages/vitnode/src/components/ui/copy-button.tsx
@@ -22,7 +22,7 @@ type CopyButtonProps = Omit<
VariantProps & {
children?: React.ReactNode;
className?: string;
- content: (() => Promise) | string;
+ content: (() => Promise | string) | string;
copied?: boolean;
delay?: number;
onCopiedChange?: (copied: boolean) => void;
@@ -71,9 +71,9 @@ const writeToClipboard = async (content: CopyButtonProps["content"]) => {
await navigator.clipboard.write([
new ClipboardItem({
- "text/plain": content().then(
- text => new Blob([text], { type: "text/plain" }),
- ),
+ "text/plain": Promise.resolve()
+ .then(content)
+ .then(text => new Blob([text], { type: "text/plain" })),
}),
]);
};
diff --git a/packages/vitnode/src/views/error/error-content.test.tsx b/packages/vitnode/src/views/error/error-content.test.tsx
new file mode 100644
index 0000000000..94d614de33
--- /dev/null
+++ b/packages/vitnode/src/views/error/error-content.test.tsx
@@ -0,0 +1,58 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { ErrorContent } from "./error-content";
+
+const numeralsOf = (container: HTMLElement) => {
+ const numerals = container.querySelector("[data-slot=error-numerals]");
+ if (!(numerals instanceof HTMLElement)) throw new Error("No numerals row");
+
+ return numerals;
+};
+
+describe("ErrorContent", () => {
+ it.each([
+ [404, "oops", ["4", "4"]],
+ [500, "dizzy", ["5", "0"]],
+ ] as const)(
+ "puts the %s pet in place of the middle digit",
+ (code, state, digits) => {
+ const { container } = render(
+ ,
+ );
+ const numerals = numeralsOf(container);
+
+ expect(
+ numerals
+ .querySelector("svg.vitnode-pet")
+ ?.getAttribute("data-pet-state"),
+ ).toBe(state);
+ expect(
+ [...numerals.querySelectorAll("span")].map(span => span.textContent),
+ ).toEqual(digits);
+ },
+ );
+
+ it("keeps every digit when the code has no pet", () => {
+ const { container } = render( );
+ const numerals = numeralsOf(container);
+
+ expect(numerals.querySelector("svg.vitnode-pet")).toBeNull();
+ expect(numerals.textContent).toBe("403");
+ });
+
+ it("announces the code with the title and hides the decorative digits", () => {
+ const { container } = render(
+ ,
+ );
+
+ expect(
+ screen.getByRole("heading", { level: 1, name: "404 Page Not Found" }),
+ ).toBeDefined();
+ expect(numeralsOf(container).getAttribute("aria-hidden")).toBe("true");
+ });
+});
diff --git a/packages/vitnode/src/views/error/error-content.tsx b/packages/vitnode/src/views/error/error-content.tsx
index e3dec3c4bb..5411397ecb 100644
--- a/packages/vitnode/src/views/error/error-content.tsx
+++ b/packages/vitnode/src/views/error/error-content.tsx
@@ -1,3 +1,58 @@
+import { Pet, type PetState } from "@/components/pet/pet";
+
+type ErrorCode = 400 | 403 | 404 | 409 | 429 | 500;
+
+const PET_BY_CODE: Partial> = {
+ 404: "oops",
+ 500: "dizzy",
+};
+
+const RISE_CSS = `
+.vn-error-rise{animation:vn-error-rise 520ms cubic-bezier(.32,.72,0,1) both}
+@keyframes vn-error-rise{from{opacity:0;transform:translateY(16px) scale(.97)}to{opacity:1;transform:none}}
+@media (prefers-reduced-motion:reduce){.vn-error-rise{animation:none}}
+`;
+
+const GLYPH_STAGGER_MS = 70;
+const GLYPH_SLOTS = ["start", "middle", "end"] as const;
+
+const Numerals = ({ code }: { code: ErrorCode }) => {
+ const pet = PET_BY_CODE[code];
+
+ return (
+
+ {String(code)
+ .split("")
+ .map((digit, index) => {
+ const slot = GLYPH_SLOTS[index];
+ const delay = { animationDelay: `${index * GLYPH_STAGGER_MS}ms` };
+
+ if (pet && slot === "middle") {
+ return (
+
+ );
+ }
+
+ return (
+
+ {digit}
+
+ );
+ })}
+
+ );
+};
+
export const ErrorContent = ({
actions,
code,
@@ -5,21 +60,37 @@ export const ErrorContent = ({
title,
}: {
actions?: React.ReactNode;
- code: 400 | 403 | 404 | 409 | 429 | 500;
+ code: ErrorCode;
description?: React.ReactNode;
title?: React.ReactNode;
}) => (
-
-
-
-
{code}
-
{title}
-
{description}
-
+
+
+
-
+
+
+ {code} {title}
+
+ {description ? (
+
+ {description}
+
+ ) : null}
+
+
+ {actions ? (
+
{actions}
-
+ ) : null}
);