diff --git a/AGENTS.md b/AGENTS.md index 73323238a9..fc796d0a66 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -123,14 +123,3 @@ npm i x - `vi.mock` is allowed only as a last resort, for a boundary with no seam (a `React.lazy` editor, a child process). Never use it for a module the test could drive for real. - Staff permissions: grant them with `grantStaffPermissions` from `@/tests/staff-permissions` on a `createTestCache()` from `@/tests/cache`, set as `c.set("cache", cache)`. Never mock `check-staff-permission`. Assert on the response (`200` with the permission, `403` without), not on which permission was asked for. - `use-intl` is mocked once for every test in `packages/vitnode/src/tests/setup.ts`: `t("key")` renders `namespace.key`. Don't mock it per file. - - - -# This is NOT the Turborepo you know - -Turborepo configuration, task behavior, and CLI commands can vary between installed versions and may differ from your training data. Resolve the `turbo` package from this file's directory or relevant workspace; in monorepos, it may not be visible from the repository root. For example, run `node -p "require.resolve('turbo/package.json')"` from a workspace that depends on `turbo`. - -Read `docs/README.md` inside that installed package first, then read the relevant pages from its `docs/` directory before changing Turborepo configuration or commands. Heed deprecation notices. These bundled docs match the installed package version and are available without network access. - -This block is written and re-added by `turbo` before repository-scoped commands when an AI agent is detected. In the Turborepo source repository, its template is defined in `crates/turborepo-cli/src/cli/agent_guidance.rs`. Removing the managed block while updates are enabled means a later qualifying invocation will add it again. Set `"agentGuidance": false` in the root `turbo.json` or `turbo.jsonc` to opt out; this does not remove an existing block. Keep the block committed with your work to avoid an uncommitted change on the next agent invocation. - diff --git a/apps/web/content/docs/dev/cli/build.mdx b/apps/web/content/docs/dev/cli/build.mdx index a1a11adfc7..ebea86aed6 100644 --- a/apps/web/content/docs/dev/cli/build.mdx +++ b/apps/web/content/docs/dev/cli/build.mdx @@ -39,13 +39,41 @@ Server .output/server ✓ index.mjs 1.2 MB ✓ Built in 1m 6s - Run vitnode start to serve it. + Run pnpm start to serve it. ``` +The last line names your app's `start` script, run with the package manager you use - `pnpm start`, `bun start`, `yarn start` or `npm start`. An app without a `start` script is told to run `vitnode start` instead. + The steps are the build environments your app really has. For a TanStack Start app with Nitro, that is the client, the server, then Nitro's packaged server. VitNode's Vite plugin generates plugin routes and registries while the configuration loads. The symbols mark how a file compares with its size budget: `✓` is fine, `!` is worth a look and `▲` is large. From the second build on, you also see what grew or shrank since the last one. [Bundle size report](/docs/dev/cli/bundle-size) explains the thresholds, the comparison and `--analyze`. +### See which pages are static + +The build ends with every route your app serves - your own route files and the pages of core and every plugin - marked by how each one is served: + +```txt title="vitnode build (end of the output, shortened)" +Routes + ├ ○ / + │ ├ / + │ └ /pl + ├ ƒ /admin/* 24 AdminCP routes (--verbose lists all) + ├ ƒ /login + ├ ● /solutions/:slug + │ ├ /solutions/help-center + │ ├ /pl/solutions/help-center + │ └ … 8 more paths + └ ƒ /users/:nameCode + + ○ Static prerendered as static HTML + ● Partial some paths prerendered, the rest rendered on demand + ƒ Dynamic rendered on demand +``` + +A route is **static** when every language has a prerendered file, and **partial** when some of its paths do - a route with a parameter, such as `/solutions/:slug`, is always partial once any of its values is prerendered. The paths under a route are the files the build wrote, each page followed by its translations. The AdminCP is never prerendered, so its routes share one line unless you pass `--verbose`. + +Nothing is static until you ask for it. [Prerender static pages](/docs/dev/deployments/prerendering) shows how. + ## Build an API app or a plugin The same command builds other project types: diff --git a/apps/web/content/docs/dev/working-with-users/users.mdx b/apps/web/content/docs/dev/working-with-users/users.mdx index d95e820c31..5a83685c66 100644 --- a/apps/web/content/docs/dev/working-with-users/users.mdx +++ b/apps/web/content/docs/dev/working-with-users/users.mdx @@ -121,9 +121,12 @@ list they appear in. ### Personal information `firstName`, `lastName`, `phone` and `headline` are the user's own to set, at **Settings → -Overview** (`/settings`). All three are optional, all three are stored as `null` -rather than an empty string when cleared, and the session payload carries them -so anything already reading the session can show them without a second request. +Overview** (`/settings`). The Profile card lists them read-only; **Edit profile** +opens one sheet with every field, the **Show my real name** switch and the time +zone, so changing three things takes one save instead of three. Every field is +optional, stored as `null` rather than an empty string when cleared, and the +session payload carries them so anything already reading the session can show +them without a second request. | Method | Path | Who | | :------ | :----------------------------------- | :----------------------------------- | @@ -143,9 +146,9 @@ public profile payload - only the account's own session carries it. ### Time zone -`timeZone` sits under **Region** on **Settings → Overview**. It takes any IANA -name, like `Europe/Warsaw`, and **Use this device's time zone** fills it in from -the browser in one click. `null` means "follow my language", then UTC. +`timeZone` sits under **Region** in the same **Edit profile** sheet. It takes any +IANA name, like `Europe/Warsaw`, and **Use this device's time zone** fills the +field from the browser in one click - it still waits for **Save changes**. `null` means "follow my language", then UTC. Notification digests go out in this time zone, and the session carries it, so `c.get("user")?.timeZone` is there for anything else that wants local time. @@ -200,8 +203,8 @@ administrator who switched it off on one role would find it silently re-granted by every other role the member happens to hold, and the setting would almost never take effect. -The settings card hides its **Edit** button when the answer is no, and -`PATCH /users/me` answers `403` regardless of what the UI showed. +The **Edit profile** sheet then shows only the time zone, which every member may +change, and `PATCH /users/me` answers `403` regardless of what the UI showed. ### Turning fields off for the whole install diff --git a/apps/web/src/locales/@vitnode/core/pl.json b/apps/web/src/locales/@vitnode/core/pl.json index aedff2546f..fd1043c857 100644 --- a/apps/web/src/locales/@vitnode/core/pl.json +++ b/apps/web/src/locales/@vitnode/core/pl.json @@ -1511,22 +1511,27 @@ "overview": { "accountTitle": "Konto", "desc": "Twój awatar i dane Twojego konta.", - "edit": "Edytuj", + "editProfile": "Edytuj profil", + "editProfileDesc": "Zmień dane osobowe i strefę czasową za jednym razem.", "email": "Adres e-mail", "firstName": "Imię", "headline": "Nagłówek", "headlineDesc": "Jedna linijka o Tobie, do {max} znaków.", "lastName": "Nazwisko", + "name": "Imię i nazwisko", "nickname": "Pseudonim", "notSet": "Nie ustawiono", "personalDesc": "Opowiedz społeczności, kim jesteś. Każde pole jest opcjonalne.", "personalTitle": "Informacje osobiste", "phone": "Telefon", "profileTitle": "Profil", + "realName": "Prawdziwe imię", + "realNameHidden": "Ukryte", + "realNameShown": "Widoczne na profilu", "role": "Rola", "save": "Zapisz zmiany", - "saved": "Informacje osobiste zaktualizowane.", - "savedDesc": "Twój profil od razu pokazuje nowe dane.", + "saved": "Zaktualizowano profil", + "savedDesc": "Zmiany są widoczne od razu.", "secondaryRoles": "Role dodatkowe", "showRealName": "Pokazuj moje prawdziwe imię", "showRealNameDesc": "Twój profil pokazuje imię i nazwisko zamiast pseudonimu. Do czasu ich ustawienia używany jest pseudonim.", @@ -1535,9 +1540,7 @@ "timeZone": "Strefa czasowa", "timeZoneDesc": "Szukaj po mieście, np. Warsaw lub New York.", "timeZoneAuto": "Automatycznie - z Twojego języka", - "timeZoneUseDevice": "Użyj strefy tego urządzenia ({timeZone})", - "timeZoneSaved": "Zapisano strefę czasową", - "timeZoneSavedDesc": "Podsumowania i daty są teraz w strefie {timeZone}." + "timeZoneUseDevice": "Użyj strefy tego urządzenia ({timeZone})" }, "passkeys": { "add": "Dodaj klucz dostępu", @@ -1859,7 +1862,14 @@ "used_of": "Wykorzystano {used} z {total} punktów", "renews": "Odnowienie {date}", "unlimited": "Nielimitowane punkty", - "unlimited_used": "W tym miesiącu wykorzystano {used} punktów", + "unlimited_desc": "Bez miesięcznego limitu. Licznik wykorzystania zeruje się {date}.", + "this_month": "pkt w tym miesiącu", + "breakdown": { + "label": "Na co poszły Twoje punkty w tym miesiącu", + "other": "{count, plural, one {# inna funkcja} few {# inne funkcje} many {# innych funkcji} other {# innych funkcji}}", + "points": "{points} pkt", + "empty": "W tym miesiącu nic jeszcze nie wykorzystano. Gdy użyjesz funkcji AI, zobaczysz tutaj, na co idą Twoje punkty." + }, "none": "Brak punktów AI w tym miesiącu" }, "features": { diff --git a/apps/web/src/proto/ai-unlimited/breakdown.tsx b/apps/web/src/proto/ai-unlimited/breakdown.tsx new file mode 100644 index 0000000000..c68523bc99 --- /dev/null +++ b/apps/web/src/proto/ai-unlimited/breakdown.tsx @@ -0,0 +1,83 @@ +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) => ( + + ))} +
    + + + )} +
  • +
  • + +
  • +
    + ) +} diff --git a/apps/web/src/proto/ai-unlimited/current.tsx b/apps/web/src/proto/ai-unlimited/current.tsx new file mode 100644 index 0000000000..6ce9dfdf5a --- /dev/null +++ b/apps/web/src/proto/ai-unlimited/current.tsx @@ -0,0 +1,18 @@ +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 new file mode 100644 index 0000000000..12b9c7446c --- /dev/null +++ b/apps/web/src/proto/ai-unlimited/daily-limits.tsx @@ -0,0 +1,63 @@ +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 new file mode 100644 index 0000000000..bb7d35e32d --- /dev/null +++ b/apps/web/src/proto/ai-unlimited/data.ts @@ -0,0 +1,64 @@ +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 new file mode 100644 index 0000000000..aa434df4c0 --- /dev/null +++ b/apps/web/src/proto/ai-unlimited/harness.tsx @@ -0,0 +1,177 @@ +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 ( + + ) +} + +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 ( + + +
    +
    + +
    + + + +
    +
    +
    + + +
    + ) +} diff --git a/apps/web/src/proto/ai-unlimited/shared.tsx b/apps/web/src/proto/ai-unlimited/shared.tsx new file mode 100644 index 0000000000..03ceed7e7e --- /dev/null +++ b/apps/web/src/proto/ai-unlimited/shared.tsx @@ -0,0 +1,21 @@ +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 new file mode 100644 index 0000000000..9ad0d2db3d --- /dev/null +++ b/apps/web/src/proto/ai-unlimited/statement.tsx @@ -0,0 +1,35 @@ +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 new file mode 100644 index 0000000000..b6c73d574c --- /dev/null +++ b/apps/web/src/proto/ai-unlimited/stats.tsx @@ -0,0 +1,60 @@ +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 4aade34210..b0712ef24b 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -21,6 +21,7 @@ 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' @@ -82,6 +83,11 @@ 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/', @@ -107,6 +113,7 @@ 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 @@ -121,6 +128,7 @@ 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 @@ -139,6 +147,7 @@ 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 @@ -155,6 +164,7 @@ export interface FileRouteTypes { | '/docs/search' | '/docs/{$}.md' | '/docs/$' + | '/proto/ai-unlimited' | '/solutions/$slug' | '/docs/' | '/solutions/' @@ -169,6 +179,7 @@ export interface FileRouteTypes { | '/docs/search' | '/docs/{$}.md' | '/docs/$' + | '/proto/ai-unlimited' | '/solutions/$slug' | '/docs' | '/solutions' @@ -186,6 +197,7 @@ export interface FileRouteTypes { | '/docs/{$}.md' | '/_main/' | '/_docs/docs/$' + | '/_main/proto/ai-unlimited' | '/_main/solutions/$slug' | '/_docs/docs/' | '/_main/solutions/' @@ -289,6 +301,13 @@ 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' @@ -338,6 +357,7 @@ const DocsRouteWithChildren = DocsRoute._addFileChildren(DocsRouteChildren) interface MainRouteChildren { MainPluginsRoute: typeof MainPluginsRoute MainIndexRoute: typeof MainIndexRoute + MainProtoAiUnlimitedRoute: typeof MainProtoAiUnlimitedRoute MainSolutionsSlugRoute: typeof MainSolutionsSlugRoute MainSolutionsIndexRoute: typeof MainSolutionsIndexRoute } @@ -345,6 +365,7 @@ 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 new file mode 100644 index 0000000000..22487d1c5b --- /dev/null +++ b/apps/web/src/routes/_main/proto/ai-unlimited.tsx @@ -0,0 +1,8 @@ +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/site/home/sections/integrations.tsx b/apps/web/src/site/home/sections/integrations.tsx index 4047239e0b..fa6894d93d 100644 --- a/apps/web/src/site/home/sections/integrations.tsx +++ b/apps/web/src/site/home/sections/integrations.tsx @@ -1,5 +1,3 @@ -import type { LucideIcon } from 'lucide-react' - import { Link } from '@tanstack/react-router' import { Tooltip, @@ -7,7 +5,6 @@ import { TooltipProvider, TooltipTrigger, } from '@vitnode/core/components/ui/tooltip' -import { Sparkles } from 'lucide-react' import { AmazonS3Mark, @@ -16,12 +13,15 @@ import { ReCaptchaMark, } from '@/site/home/sections/logos/integration-logos' import { INTEGRATION_MARKS } from '@/site/home/sections/logos/integration-marks' -import { MarketingSection, TextLink } from '@/site/marketing/shared' +import { + MarketingSection, + SectionHeading, + TextLink, +} from '@/site/marketing/shared' type Destination = { docs: string } | { href: string } type Logo = - | { glyph: LucideIcon } | { logo: React.ElementType<{ className: string }> } | { mark: string } @@ -109,7 +109,7 @@ const ELASTICSEARCH: Integration = { const AI_SDK: Integration = { color: FOREGROUND, docs: 'dev/ai', - glyph: Sparkles, + mark: INTEGRATION_MARKS.vercel, name: 'AI SDK', role: 'AI models', } @@ -149,18 +149,18 @@ const DOCKER: Integration = { const WIDE_COLUMNS: Integration[][] = [ [DOCKER], [NODEMAILER, TAILWIND], - [DRIZZLE, TANSTACK_START, TURNSTILE], - [ELASTICSEARCH, HONO], - [POSTGRESQL, REACT, S3], + [REACT, TANSTACK_START, S3], + [HONO, DRIZZLE], + [ELASTICSEARCH, POSTGRESQL, TURNSTILE], [AI_SDK, RECAPTCHA], [NODE_CRON], ] const COMPACT_COLUMNS: Integration[][] = [ [NODEMAILER, TAILWIND], - [DRIZZLE, TANSTACK_START, TURNSTILE], - [NODE_CRON, ELASTICSEARCH, HONO, DOCKER], - [POSTGRESQL, REACT, S3], + [REACT, TANSTACK_START, S3], + [DOCKER, HONO, DRIZZLE, NODE_CRON], + [ELASTICSEARCH, POSTGRESQL, TURNSTILE], [AI_SDK, RECAPTCHA], ] @@ -221,9 +221,6 @@ const TileFace = ({ integration }: { integration: Integration }) => ( const IntegrationMark = ({ integration }: { integration: Integration }) => { const className = 'mk-integration-mark sm:size-8 lg:size-11' - if ('glyph' in integration) - return - if ('logo' in integration) return return ( @@ -318,18 +315,12 @@ export const IntegrationsSection = () => ( id="integrations" labelledBy="integrations-title" > -
    -

    - - Integrations -

    -

    - Made of parts you already know. -

    -
    + @@ -337,10 +328,10 @@ export const IntegrationsSection = () => (
    -

    - TanStack Start renders the pages, Hono serves the API and Drizzle talks - to PostgreSQL. Email, storage, search, captcha, AI and cron are adapters - you pick in config. +

    + The four in the middle are the core: TanStack Start renders the pages, + Hono serves the API and Drizzle talks to PostgreSQL. Everything around + them is an adapter you pick in config.

    Explore the docs diff --git a/apps/web/src/site/home/sections/logos/integration-marks.ts b/apps/web/src/site/home/sections/logos/integration-marks.ts index 49fd823813..91e9f69452 100644 --- a/apps/web/src/site/home/sections/logos/integration-marks.ts +++ b/apps/web/src/site/home/sections/logos/integration-marks.ts @@ -16,4 +16,5 @@ export const INTEGRATION_MARKS = { 'M12.001,4.8c-3.2,0-5.2,1.6-6,4.8c1.2-1.6,2.6-2.2,4.2-1.8c0.913,0.228,1.565,0.89,2.288,1.624 C13.666,10.618,15.027,12,18.001,12c3.2,0,5.2-1.6,6-4.8c-1.2,1.6-2.6,2.2-4.2,1.8c-0.913-0.228-1.565-0.89-2.288-1.624 C16.337,6.182,14.976,4.8,12.001,4.8z M6.001,12c-3.2,0-5.2,1.6-6,4.8c1.2-1.6,2.6-2.2,4.2-1.8c0.913,0.228,1.565,0.89,2.288,1.624 c1.177,1.194,2.538,2.576,5.512,2.576c3.2,0,5.2-1.6,6-4.8c-1.2,1.6-2.6,2.2-4.2,1.8c-0.913-0.228-1.565-0.89-2.288-1.624 C10.337,13.382,8.976,12,6.001,12z', tanstack: 'M12 0c6.627 0 9.166 4.102 9.166 12S18.626 24 12 24s-9.166-4.096-9.166-12c0-7.898 2.54-12 9.166-12m3.031 17.485c-.861 0-1.33.234-1.708.423-.327.164-.582.292-1.148.292-.567 0-.822-.128-1.148-.292-.378-.189-.848-.423-1.71-.423-.86 0-1.33.234-1.708.423-.327.164-.581.292-1.148.292v1.251c.862 0 1.331-.234 1.709-.423.326-.163.581-.292 1.148-.292s.821.129 1.148.292c.378.189.847.423 1.709.423.861 0 1.33-.234 1.709-.423.326-.163.58-.292 1.147-.292s.822.129 1.148.292c.378.189.848.423 1.71.423v-1.25c-.565 0-.822-.13-1.149-.293-.377-.189-.847-.423-1.709-.423m.41-12.536c.65-.586 0-1.648-.813-1.328-.45.18-.873.438-1.251.779a4.2 4.2 0 0 0-1.202 1.94 4.2 4.2 0 0 0-1.203-1.94 4.3 4.3 0 0 0-1.25-.779c-.814-.32-1.463.742-.814 1.328l2.385 2.153a4.86 4.86 0 0 0-2.731-.839c-.552 0-1.082.09-1.58.26-.836.284-.604 1.532.275 1.532h3.326a4.2 4.2 0 0 0-2.012.988 4 4 0 0 0-.9 1.165c-.403.776.588 1.529 1.237.948l2.686-2.42-.2 6.656c0 .08-.047.158-.107.223a5 5 0 0 1-.257-.123c-.378-.189-.848-.423-1.71-.423-.861 0-1.33.234-1.708.423-.327.164-.581.292-1.148.292v1.251c.861 0 1.33-.234 1.709-.423.326-.164.58-.292 1.148-.292.566 0 .821.128 1.148.292.377.189.847.423 1.708.423.862 0 1.332-.234 1.71-.423.326-.164.58-.292 1.147-.292s.822.128 1.148.292c.378.189.848.423 1.71.423v-1.25c-.565 0-.822-.13-1.149-.293v-.005c-.378-.19-.847-.424-1.709-.424-.861 0-1.33.235-1.709.424-.097.045-.189.094-.283.131a.34.34 0 0 1-.12-.232l-.2-6.69 2.722 2.457c.65.587 1.64-.166 1.236-.948a4.11 4.11 0 0 0-2.911-2.153h3.326c.882 0 1.108-1.245.275-1.531a4.88 4.88 0 0 0-4.311.578l2.385-2.152z', + vercel: 'm12 3.686 9.6 16.628H2.4Z', } as const diff --git a/packages/vitnode/scripts/cli/builder/app-build.ts b/packages/vitnode/scripts/cli/builder/app-build.ts index e47a54e594..e600375a20 100644 --- a/packages/vitnode/scripts/cli/builder/app-build.ts +++ b/packages/vitnode/scripts/cli/builder/app-build.ts @@ -5,6 +5,7 @@ import type { OutputCapture } from "../ui/capture-output"; import type { TaskHandle, Ui } from "../ui/ui"; import type { EnvironmentInfo } from "./collector"; import type { MeasuredFile } from "./output-files"; +import type { BuildRoute } from "./routes"; import { isPluginPackage } from "../plugins/discover"; import { importFromProject, readPackageJson } from "../project/packages"; @@ -13,12 +14,13 @@ import { createBuildCollector } from "./collector"; import { describeBuildError } from "./error-context"; import { measureFiles } from "./measure"; import { createPackageOwner } from "./package-owner"; +import { htmlPathsUnder, routeReportApiOf } from "./routes"; /** The slice of Vite's JavaScript API a build needs. */ export interface ViteBuildApi { createBuilder: (config: InlineConfig) => Promise<{ buildApp: () => Promise; - config: { plugins: readonly { name: string }[] }; + config: { plugins: readonly { api?: unknown; name: string }[] }; }>; } @@ -37,6 +39,7 @@ export interface AppBuildResult { /** Environments that wrote what ships, in build order. */ environments: string[]; files: MeasuredFile[]; + reportRoutes: (() => BuildRoute[]) | null; } const ENVIRONMENT_LABELS: Record = { @@ -110,6 +113,7 @@ export const runAppBuild = async ({ const capture = ui.verbose ? null : captureOutput(); const configTask = ui.task("Loading configuration"); + let routeReportApi: ReturnType = null; try { const builder = await vite.createBuilder({ @@ -130,6 +134,8 @@ export const runAppBuild = async ({ : "Configuration loaded", ); + routeReportApi = routeReportApiOf(builder.config.plugins); + await builder.buildApp(); } catch (error) { configTask.fail(); @@ -166,9 +172,17 @@ export const runAppBuild = async ({ ), ); + const publicDir = shipped.find( + environment => environment.consumer === "client", + )?.outDir; + return { bundlerWarnings, environments: shipped.map(environment => environment.name), files, + reportRoutes: + routeReportApi === null || publicDir == null + ? null + : () => routeReportApi.routeReport(htmlPathsUnder(publicDir)), }; }; diff --git a/packages/vitnode/scripts/cli/builder/report.ts b/packages/vitnode/scripts/cli/builder/report.ts index 92073d54ed..fedb66e5ca 100644 --- a/packages/vitnode/scripts/cli/builder/report.ts +++ b/packages/vitnode/scripts/cli/builder/report.ts @@ -1,6 +1,7 @@ import type { Ui } from "../ui/ui"; import type { ChunkAnalysis } from "./analysis"; import type { FileCategory, MeasuredFile } from "./output-files"; +import type { BuildRoute, RouteMode } from "./routes"; import type { SizeSeverity } from "./size-severity"; import type { SnapshotComparison } from "./snapshot"; import type { BuildWarning } from "./warnings"; @@ -266,3 +267,119 @@ export const renderWarnings = (ui: Ui, warnings: readonly BuildWarning[]) => { ui.note("Size warnings are recommendations - they never fail a build."); }; + +const ROUTE_CHILD_LIMIT = 6; + +const ADMIN_PREFIX = "/admin"; + +const isAdminRoute = (route: BuildRoute) => + route.path === ADMIN_PREFIX || route.path.startsWith(`${ADMIN_PREFIX}/`); + +const routeMark = (ui: Ui, mode: RouteMode) => { + switch (mode) { + case "dynamic": + return ui.colors.muted(ui.symbols.fn); + case "partial": + return ui.symbols.dot; + case "static": + return ui.colors.success(ui.symbols.pending); + } +}; + +const ROUTE_LEGEND: { description: string; label: string; mode: RouteMode }[] = + [ + { + description: "prerendered as static HTML", + label: "Static", + mode: "static", + }, + { + description: "some paths prerendered, the rest rendered on demand", + label: "Partial", + mode: "partial", + }, + { + description: "rendered on demand", + label: "Dynamic", + mode: "dynamic", + }, + ]; + +interface RouteLine { + children: string[]; + detail?: string; + mode: RouteMode; + path: string; +} + +const routeLines = (ui: Ui, routes: readonly BuildRoute[]): RouteLine[] => { + const collapsed = ui.verbose + ? [] + : routes.filter(route => isAdminRoute(route) && route.mode === "dynamic"); + const lines: RouteLine[] = routes + .filter(route => !collapsed.includes(route)) + .map(route => ({ + children: + route.staticPaths.length === 1 && route.staticPaths[0] === route.path + ? [] + : route.staticPaths, + mode: route.mode, + path: route.path, + })); + + if (collapsed.length > 0) { + lines.push({ + children: [], + detail: `${plural(collapsed.length, "AdminCP route")} (--verbose lists all)`, + mode: "dynamic", + path: `${ADMIN_PREFIX}/*`, + }); + } + + return lines.sort((a, b) => (a.path < b.path ? -1 : 1)); +}; + +export const renderRoutes = (ui: Ui, routes: readonly BuildRoute[]) => { + if (routes.length === 0) return; + + const { bar, corner, tee } = ui.symbols; + const lines = routeLines(ui, routes); + + ui.section("Routes"); + + lines.forEach((line, index) => { + const isLast = index === lines.length - 1; + const detail = + line.detail === undefined ? "" : ` ${ui.colors.muted(line.detail)}`; + ui.line( + ` ${ui.colors.muted(isLast ? corner : tee)} ${routeMark(ui, line.mode)} ${line.path}${detail}`, + ); + + const shown = ui.verbose + ? line.children + : line.children.slice(0, ROUTE_CHILD_LIMIT); + const hidden = line.children.length - shown.length; + const rail = isLast ? " " : bar; + const children = [ + ...shown, + ...(hidden > 0 ? [`… ${plural(hidden, "more path")}`] : []), + ]; + + children.forEach((child, childIndex) => { + const branch = childIndex === children.length - 1 ? corner : tee; + ui.line( + ` ${ui.colors.muted(rail)} ${ui.colors.muted(branch)} ${ui.colors.muted(child)}`, + ); + }); + }); + + const used = new Set(lines.map(line => line.mode)); + const legend = ROUTE_LEGEND.filter(entry => used.has(entry.mode)); + const labelWidth = Math.max(...legend.map(entry => entry.label.length)); + ui.line(); + legend.forEach(entry => { + ui.line( + ` ${routeMark(ui, entry.mode)} ${padEnd(entry.label, labelWidth)} ${ui.colors.muted(entry.description)}`, + ); + }); +}; diff --git a/packages/vitnode/scripts/cli/builder/routes.test.ts b/packages/vitnode/scripts/cli/builder/routes.test.ts new file mode 100644 index 0000000000..ecb9d415e3 --- /dev/null +++ b/packages/vitnode/scripts/cli/builder/routes.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; + +import { routeReportApiOf, urlPathOfHtmlFile } from "./routes"; + +describe("urlPathOfHtmlFile", () => { + it("serves an index file at its directory", () => { + expect(urlPathOfHtmlFile("index.html")).toBe("/"); + expect(urlPathOfHtmlFile("pl/solutions/index.html")).toBe("/pl/solutions"); + }); + + it("serves any other file at its name", () => { + expect(urlPathOfHtmlFile("404.html")).toBe("/404"); + }); +}); + +describe("routeReportApiOf", () => { + it("finds the report on VitNode's routes plugin", () => { + const routeReport = () => []; + + expect( + routeReportApiOf([ + { name: "nitro:main" }, + { api: { routeReport }, name: "vitnode:plugin-routes" }, + ])?.routeReport, + ).toBe(routeReport); + }); + + it("answers null for a plugin without the report", () => { + expect(routeReportApiOf([{ name: "vitnode:plugin-routes" }])).toBeNull(); + }); +}); diff --git a/packages/vitnode/scripts/cli/builder/routes.ts b/packages/vitnode/scripts/cli/builder/routes.ts new file mode 100644 index 0000000000..80f461efe3 --- /dev/null +++ b/packages/vitnode/scripts/cli/builder/routes.ts @@ -0,0 +1,57 @@ +import { existsSync, readdirSync } from "node:fs"; +import { join } from "node:path"; + +export type RouteMode = "dynamic" | "partial" | "static"; + +export interface BuildRoute { + mode: RouteMode; + path: string; + staticPaths: string[]; +} + +interface RouteReportApi { + routeReport: (htmlPaths: readonly string[]) => BuildRoute[]; +} + +const ROUTES_PLUGIN = "vitnode:plugin-routes"; + +const isRouteReportApi = (api: unknown): api is RouteReportApi => + typeof api === "object" && + api !== null && + typeof (api as { routeReport?: unknown }).routeReport === "function"; + +export const routeReportApiOf = ( + plugins: readonly { api?: unknown; name: string }[], +): null | RouteReportApi => { + const plugin = plugins.find(candidate => candidate.name === ROUTES_PLUGIN); + + return plugin !== undefined && isRouteReportApi(plugin.api) + ? plugin.api + : null; +}; + +const htmlFilesUnder = (directory: string, prefix = ""): string[] => + readdirSync(directory, { withFileTypes: true }).flatMap(entry => { + if (entry.name.startsWith(".")) return []; + + const relative = prefix === "" ? entry.name : `${prefix}/${entry.name}`; + if (entry.isDirectory()) { + return htmlFilesUnder(join(directory, entry.name), relative); + } + + return entry.name.endsWith(".html") ? [relative] : []; + }); + +export const urlPathOfHtmlFile = (file: string): string => { + if (file === "index.html") return "/"; + if (file.endsWith("/index.html")) { + return `/${file.slice(0, -"/index.html".length)}`; + } + + return `/${file.slice(0, -".html".length)}`; +}; + +export const htmlPathsUnder = (directory: string): string[] => + existsSync(directory) + ? htmlFilesUnder(directory).map(urlPathOfHtmlFile).sort() + : []; diff --git a/packages/vitnode/scripts/cli/commands/build.test.ts b/packages/vitnode/scripts/cli/commands/build.test.ts index 1478af6fd6..84d2685c2b 100644 --- a/packages/vitnode/scripts/cli/commands/build.test.ts +++ b/packages/vitnode/scripts/cli/commands/build.test.ts @@ -13,6 +13,7 @@ import { dirname, join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import type { ViteBuildApi } from "../builder/app-build"; +import type { BuildRoute } from "../builder/routes"; import { snapshotPathFor } from "../builder/snapshot"; import { RuntimeError } from "../errors"; @@ -67,7 +68,13 @@ const call = ( */ const fakeVite = ( environments: FakeEnvironment[], - { fail }: { fail?: Error } = {}, + { + fail, + routeReport, + }: { + fail?: Error; + routeReport?: (htmlPaths: readonly string[]) => BuildRoute[]; + } = {}, ): ViteBuildApi & { configs: InlineConfig[] } => { const configs: InlineConfig[] = []; @@ -123,7 +130,14 @@ const fakeVite = ( await call(plugin, "closeBundle", context); } }, - config: { plugins: [{ name: "vitnode:plugin-routes" }] }, + config: { + plugins: [ + { + api: routeReport === undefined ? undefined : { routeReport }, + name: "vitnode:plugin-routes", + }, + ], + }, }; }, }; @@ -447,3 +461,125 @@ describe("a failed build", () => { expect(failure.details).toEqual(["Something odd"]); }); }); + +describe("vitnode build route summary", () => { + const writeHtml = (...files: string[]) => { + files.forEach(file => { + const path = join(root, ".output/public", file); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, ""); + }); + }; + + const ROUTES: BuildRoute[] = [ + { mode: "static", path: "/", staticPaths: ["/", "/pl"] }, + { + mode: "partial", + path: "/solutions/:slug", + staticPaths: ["/solutions/help-center"], + }, + { mode: "dynamic", path: "/login", staticPaths: [] }, + { mode: "dynamic", path: "/admin/core", staticPaths: [] }, + { mode: "dynamic", path: "/admin/core/users", staticPaths: [] }, + ]; + + it("asks the routes plugin about every HTML file the build wrote", async () => { + writeHtml( + "index.html", + "pl/index.html", + "solutions/help-center/index.html", + ); + let received: readonly string[] = []; + + await build( + fakeVite(appBuild({ admin: 1000, index: 1000 }), { + routeReport: htmlPaths => { + received = htmlPaths; + + return ROUTES; + }, + }), + ); + + expect(received).toEqual(["/", "/pl", "/solutions/help-center"]); + }); + + it("ends with every route marked by how it is served", async () => { + const { runtime } = await build( + fakeVite(appBuild({ admin: 1000, index: 1000 }), { + routeReport: () => ROUTES, + }), + ); + const output = runtime.output(); + + expect(output).toContain("Routes"); + expect(output).toContain("├ ○ /\n"); + expect(output).toContain("│ └ /pl"); + expect(output).toContain("● /solutions/:slug"); + expect(output).toContain("ƒ /login"); + expect(output).toContain("ƒ /admin/* 2 AdminCP routes"); + expect(output).not.toContain("/admin/core/users"); + expect(output).toMatch(/○ {2}Static\s+prerendered as static HTML/); + expect(output.indexOf("Routes")).toBeGreaterThan( + output.indexOf("Client JS"), + ); + }); + + it("lists every AdminCP route with --verbose", async () => { + const { runtime } = await build( + fakeVite(appBuild({ admin: 1000, index: 1000 }), { + routeReport: () => ROUTES, + }), + { verbose: true }, + ); + + expect(runtime.output()).toContain("ƒ /admin/core/users"); + }); + + it("skips the section rather than fail a finished build", async () => { + const { code, runtime } = await build( + fakeVite(appBuild({ admin: 1000, index: 1000 }), { + routeReport: () => { + throw new Error("no i18n configuration"); + }, + }), + ); + + expect(code).toBe(0); + expect(runtime.output()).toContain( + "Route summary skipped: no i18n configuration", + ); + }); + + it("leaves the section out for an app VitNode does not route", async () => { + const { runtime } = await build( + fakeVite(appBuild({ admin: 1000, index: 1000 })), + ); + + expect(runtime.output()).not.toContain("Routes"); + }); +}); + +describe("vitnode build start hint", () => { + it("names the start script with the package manager the app uses", async () => { + writeFileSync( + join(root, "package.json"), + JSON.stringify({ name: "web", scripts: { start: "vitnode start" } }), + ); + + const { runtime } = await build( + fakeVite(appBuild({ admin: 1000, index: 1000 })), + { env: { npm_config_user_agent: "pnpm/10.18.0 npm/? node/v24.15.0" } }, + ); + + expect(runtime.output()).toContain("Run pnpm start to serve it."); + }); + + it("falls back to vitnode start for an app without a start script", async () => { + const { runtime } = await build( + fakeVite(appBuild({ admin: 1000, index: 1000 })), + ); + + expect(runtime.output()).toContain("Run vitnode start to serve it."); + }); +}); diff --git a/packages/vitnode/scripts/cli/commands/build.ts b/packages/vitnode/scripts/cli/commands/build.ts index 42564f6040..680197c0eb 100644 --- a/packages/vitnode/scripts/cli/commands/build.ts +++ b/packages/vitnode/scripts/cli/commands/build.ts @@ -1,7 +1,8 @@ import type { AppBuildOptions } from "../builder/app-build"; import type { StepRunner } from "../builder/compiler-build"; import type { MeasuredFile } from "../builder/output-files"; -import type { CommandContext, OutputOptions } from "../context"; +import type { BuildRoute } from "../builder/routes"; +import type { CommandContext, Env, OutputOptions } from "../context"; import type { Project } from "../project/project"; import type { Ui } from "../ui/ui"; @@ -19,6 +20,7 @@ import { renderAnalysis, renderComparison, renderOutput, + renderRoutes, renderWarnings, } from "../builder/report"; import { @@ -31,6 +33,8 @@ import { import { collectWarnings } from "../builder/warnings"; import { errorMessage, EXIT_CODE } from "../errors"; import { isPluginPackage } from "../plugins/discover"; +import { detectPackageManager } from "../project/package-manager"; +import { readPackageJson } from "../project/packages"; import { detectProject } from "../project/project"; import { detectRuntime } from "../project/runtime"; import { formatDuration, plural } from "../ui/format"; @@ -88,7 +92,12 @@ const reportAppBuild = ( { analyze, bundlerWarnings, - }: { analyze: boolean; bundlerWarnings: readonly string[] }, + reportRoutes, + }: { + analyze: boolean; + bundlerWarnings: readonly string[]; + reportRoutes: (() => readonly BuildRoute[]) | null; + }, ) => { const analyses = analyze ? safely(ui, "Bundle analysis", () => @@ -113,6 +122,21 @@ const reportAppBuild = ( `${plural(bundlerWarnings.length, "bundler warning")} hidden - run with --verbose to see them.`, ); } + + if (reportRoutes !== null) { + safely(ui, "Route summary", () => { + renderRoutes(ui, reportRoutes()); + }); + } +}; + +const startCommand = (project: Project, env: Env): string => { + const packageManager = detectPackageManager(project.root, env); + + return packageManager === undefined || + readPackageJson(project.root)?.scripts?.start === undefined + ? "vitnode start" + : `${packageManager} start`; }; export const runBuildCommand = async ( @@ -163,13 +187,16 @@ export const runBuildCommand = async ( reportAppBuild(ui, project, result.files, { analyze, bundlerWarnings: result.bundlerWarnings, + reportRoutes: result.reportRoutes, }); } ui.line(); ui.success(`Built in ${formatDuration(now() - startedAt)}`); if (project.kind !== "package") { - ui.note(`Run ${ui.colors.command("vitnode start")} to serve it.`); + ui.note( + `Run ${ui.colors.command(startCommand(project, env))} to serve it.`, + ); } ui.line(); diff --git a/packages/vitnode/scripts/cli/project/package-manager.test.ts b/packages/vitnode/scripts/cli/project/package-manager.test.ts new file mode 100644 index 0000000000..0712a7fd6e --- /dev/null +++ b/packages/vitnode/scripts/cli/project/package-manager.test.ts @@ -0,0 +1,58 @@ +// @vitest-environment node +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { detectPackageManager } from "./package-manager"; + +let root: string; + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "vitnode-package-manager-")); + mkdirSync(join(root, "apps", "web"), { recursive: true }); + writeFileSync(join(root, "apps", "web", "package.json"), "{}"); +}); + +afterEach(() => { + rmSync(root, { force: true, recursive: true }); +}); + +const web = () => join(root, "apps", "web"); + +describe("detectPackageManager", () => { + it("is the package manager that started the command", () => { + writeFileSync(join(root, "yarn.lock"), ""); + + expect( + detectPackageManager(web(), { + npm_config_user_agent: "pnpm/10.18.0 npm/? node/v24.15.0", + }), + ).toBe("pnpm"); + }); + + it("is the one the workspace declares", () => { + writeFileSync( + join(root, "package.json"), + JSON.stringify({ packageManager: "yarn@4.9.1" }), + ); + + expect(detectPackageManager(web(), {})).toBe("yarn"); + }); + + it("is the one whose lockfile the workspace has", () => { + writeFileSync(join(root, "bun.lock"), ""); + + expect(detectPackageManager(web(), {})).toBe("bun"); + }); + + it("is unknown when nothing names one", () => { + expect(detectPackageManager(web(), {})).toBeUndefined(); + }); + + it("is unknown for a package manager it cannot name a command for", () => { + expect( + detectPackageManager(web(), { npm_config_user_agent: "deno/2.5.0" }), + ).toBeUndefined(); + }); +}); diff --git a/packages/vitnode/scripts/cli/project/package-manager.ts b/packages/vitnode/scripts/cli/project/package-manager.ts new file mode 100644 index 0000000000..2943a668a3 --- /dev/null +++ b/packages/vitnode/scripts/cli/project/package-manager.ts @@ -0,0 +1,59 @@ +import { existsSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; + +import type { Env } from "../context"; + +import { readPackageJson } from "./packages"; + +export type PackageManager = "bun" | "npm" | "pnpm" | "yarn"; + +const PACKAGE_MANAGERS: readonly PackageManager[] = [ + "bun", + "npm", + "pnpm", + "yarn", +]; + +const LOCKFILES: readonly (readonly [string, PackageManager])[] = [ + ["bun.lock", "bun"], + ["bun.lockb", "bun"], + ["pnpm-lock.yaml", "pnpm"], + ["yarn.lock", "yarn"], + ["package-lock.json", "npm"], +]; + +const packageManagerNamed = (name: string): PackageManager | undefined => + PACKAGE_MANAGERS.find(manager => manager === name); + +export const declaredPackageManager = ( + root: string, +): PackageManager | undefined => { + let current = resolve(root); + + for (;;) { + const declared = readPackageJson(current)?.packageManager; + if (declared !== undefined) { + return packageManagerNamed(declared.split("@")[0]); + } + + const lockfile = LOCKFILES.find(([file]) => + existsSync(join(current, file)), + ); + if (lockfile !== undefined) return lockfile[1]; + + const parent = dirname(current); + if (parent === current) return undefined; + current = parent; + } +}; + +export const detectPackageManager = ( + root: string, + env: Env, +): PackageManager | undefined => { + const runningManager = env.npm_config_user_agent?.split("/")[0]; + + return runningManager === undefined + ? declaredPackageManager(root) + : packageManagerNamed(runningManager); +}; diff --git a/packages/vitnode/scripts/cli/project/runtime.ts b/packages/vitnode/scripts/cli/project/runtime.ts index d1b4d9b15b..8695ec1cab 100644 --- a/packages/vitnode/scripts/cli/project/runtime.ts +++ b/packages/vitnode/scripts/cli/project/runtime.ts @@ -1,9 +1,6 @@ -import { existsSync } from "node:fs"; -import { dirname, join, resolve } from "node:path"; - import type { Env } from "../context"; -import { readPackageJson } from "./packages"; +import { declaredPackageManager } from "./package-manager"; /** * What a standalone API app runs on. @@ -15,9 +12,6 @@ import { readPackageJson } from "./packages"; */ export type Runtime = "bun" | "node"; -const OTHER_LOCKFILES = ["pnpm-lock.yaml", "package-lock.json", "yarn.lock"]; -const BUN_LOCKFILES = ["bun.lock", "bun.lockb"]; - /** * Bun when the CLI itself runs under Bun, when Bun started the script that * runs it, or when the project (or its workspace) is a Bun project - a @@ -32,22 +26,7 @@ export const detectRuntime = ( if (versions.bun !== undefined) return "bun"; if (env.npm_config_user_agent?.startsWith("bun/")) return "bun"; - let current = resolve(root); - for (;;) { - const manager = readPackageJson(current)?.packageManager; - if (manager !== undefined) - return manager.startsWith("bun@") ? "bun" : "node"; - if (BUN_LOCKFILES.some(file => existsSync(join(current, file)))) { - return "bun"; - } - if (OTHER_LOCKFILES.some(file => existsSync(join(current, file)))) { - return "node"; - } - - const parent = dirname(current); - if (parent === current) return "node"; - current = parent; - } + return declaredPackageManager(root) === "bun" ? "bun" : "node"; }; /** diff --git a/packages/vitnode/scripts/cli/ui/symbols.ts b/packages/vitnode/scripts/cli/ui/symbols.ts index dc6b0014ac..ccba0e3872 100644 --- a/packages/vitnode/scripts/cli/ui/symbols.ts +++ b/packages/vitnode/scripts/cli/ui/symbols.ts @@ -7,6 +7,7 @@ export interface Symbols { corner: string; dot: string; error: string; + fn: string; large: string; line: string; pending: string; @@ -25,6 +26,7 @@ const UNICODE: Symbols = { corner: "└", dot: "●", error: "✖", + fn: "ƒ", large: "▲", line: "─", pending: "○", @@ -43,6 +45,7 @@ const ASCII: Symbols = { corner: "`-", dot: "*", error: "x", + fn: "f", large: "^", line: "-", pending: "o", diff --git a/packages/vitnode/src/framework/plugin-routes/index.ts b/packages/vitnode/src/framework/plugin-routes/index.ts index 642a509170..78a1d004e6 100644 --- a/packages/vitnode/src/framework/plugin-routes/index.ts +++ b/packages/vitnode/src/framework/plugin-routes/index.ts @@ -39,6 +39,7 @@ export type { } from "./locale-route-paths.js"; export { assertLocaleRoutePaths, + hostRouteSegments, i18nFromLoadedConfig, localeRoutePathTargets, } from "./locale-route-paths.js"; diff --git a/packages/vitnode/src/framework/plugin-routes/locale-route-paths.ts b/packages/vitnode/src/framework/plugin-routes/locale-route-paths.ts index e2c864380d..ad6363ae81 100644 --- a/packages/vitnode/src/framework/plugin-routes/locale-route-paths.ts +++ b/packages/vitnode/src/framework/plugin-routes/locale-route-paths.ts @@ -45,7 +45,9 @@ export const i18nFromLoadedConfig = ( return isLocaleRoutePathsConfig(i18n) ? i18n : undefined; }; -const hostRouteSegments = (path: string): null | PluginRouteSegment[] => { +export const hostRouteSegments = ( + path: string, +): null | PluginRouteSegment[] => { const parts = path.split("/").filter(part => part.length > 0); const segments = parts.map((part): PluginRouteSegment => { if (part === "$") return { kind: "splat" }; diff --git a/packages/vitnode/src/framework/vite/plugin-routes.ts b/packages/vitnode/src/framework/vite/plugin-routes.ts index 831d21f304..c8e8c36c8b 100644 --- a/packages/vitnode/src/framework/vite/plugin-routes.ts +++ b/packages/vitnode/src/framework/vite/plugin-routes.ts @@ -25,6 +25,7 @@ import type { LocaleRoutePathsConfig, PluginRouteCompilerSource, } from "../plugin-routes"; +import type { RouteReportEntry, RouteReportInput } from "./route-report"; import { generateAdminNavSource } from "../admin-nav"; import { generateApiRegistrySource } from "../api-registry"; @@ -47,6 +48,7 @@ import { } from "../plugin-routes"; import { createGenerationQueue } from "./generation-queue"; import { versionedModuleUrl } from "./module-version"; +import { buildRouteReport } from "./route-report"; const ROUTES_SUBPATH = "routes"; @@ -529,12 +531,13 @@ const discover = async ( const contentUrls = await loadPluginContentUrls(contentModules); + const hostRoutes = readHostRoutes( + appRoot, + hostRoutesConfigFor(appRoot, options.hostRoutesDir), + ); const compiled = compilePluginRoutes({ contentUrls, - hostRoutes: readHostRoutes( - appRoot, - hostRoutesConfigFor(appRoot, options.hostRoutesDir), - ), + hostRoutes, i18n, sources: loaded.map(({ source }) => source), }); @@ -558,6 +561,8 @@ const discover = async ( specifier, })), contentRegistry: contentRegistry.modules, + hostRoutes, + i18n, packageMessages, watch, }; @@ -575,12 +580,16 @@ const removeIfPresent = async (path: string): Promise => { await unlink(path); }; +type DiscoveredRoutes = Omit & { + i18n: RouteReportInput["i18n"] | undefined; +}; + /** All seven generated files, from one discovery pass. */ const writeGenerated = async ( appRoot: string, options: VitNodePluginRoutesOptions, onLoaded?: (watch: string[]) => void, -): Promise => { +): Promise => { const paths = pathsFor(appRoot); const { adminNav, @@ -589,6 +598,8 @@ const writeGenerated = async ( compiled, contentModules, contentRegistry, + hostRoutes, + i18n, packageMessages, } = await discover(appRoot, options, onLoaded); @@ -614,29 +625,53 @@ const writeGenerated = async ( ), removeIfPresent(paths.staleManifest), ]); + + return { hostRoutes, i18n, pluginRoutes: compiled.manifest }; }; +export interface VitNodePluginRoutesApi { + routeReport: (htmlPaths: readonly string[]) => RouteReportEntry[]; +} + export const vitNodePluginRoutes = ( options: VitNodePluginRoutesOptions, -): Plugin => { +): Plugin => { const { appRoot } = options; const configPath = pathsFor(appRoot).config; const routesDir = hostRoutesConfigFor(appRoot, options.hostRoutesDir).dir; + let discovered: DiscoveredRoutes | undefined; return { + api: { + routeReport: htmlPaths => { + if (discovered?.i18n === undefined) { + throw new Error( + `${ERROR_PREFIX} The route report needs the i18n configuration from ${relative(appRoot, configPath)}, and none was read.`, + ); + } + + return buildRouteReport({ + ...discovered, + htmlPaths, + i18n: discovered.i18n, + }); + }, + }, + config: async () => { - await writeGenerated(appRoot, options); + discovered = await writeGenerated(appRoot, options); }, configureServer: server => { const watched = new Set([configPath]); const queue = createGenerationQueue( - async () => - writeGenerated(appRoot, options, files => { + async () => { + await writeGenerated(appRoot, options, files => { files.forEach(file => watched.add(file)); server.watcher.add(files); - }), + }); + }, error => { server.config.logger.error(String(error)); }, diff --git a/packages/vitnode/src/framework/vite/route-report.test.ts b/packages/vitnode/src/framework/vite/route-report.test.ts new file mode 100644 index 0000000000..2a10d09c8f --- /dev/null +++ b/packages/vitnode/src/framework/vite/route-report.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from "vitest"; + +import type { PluginRoute } from "@/routing/types"; + +import { parseRoutePath } from "@/routing/path"; + +import { buildRouteReport } from "./route-report"; + +const pluginRoute = ( + path: string, + kind: PluginRoute["kind"] = "page", +): PluginRoute => { + const parsed = parseRoutePath(path); + if (!parsed.ok) throw new Error(`invalid test path ${path}`); + + return { + area: "main", + id: `@vitnode/core:${path}`, + kind, + messages: [], + parentId: null, + path: parsed.path, + pluginId: "@vitnode/core", + requires: null, + routeId: path, + segments: parsed.segments, + }; +}; + +const hostRoute = (path: string) => ({ file: `src/routes${path}.tsx`, path }); + +const i18n = { + defaultLocale: "en", + locales: [ + { code: "en", name: "English" }, + { code: "pl", name: "Polski" }, + ], + routePaths: { pl: { "/discover": "/odkrywaj" } }, +}; + +const report = (htmlPaths: string[]) => + buildRouteReport({ + hostRoutes: [ + hostRoute("/"), + hostRoute("/plugins"), + hostRoute("/solutions/$slug"), + hostRoute("/api/$"), + ], + htmlPaths, + i18n, + pluginRoutes: [ + pluginRoute("/discover"), + pluginRoute("/login"), + pluginRoute("/settings", "layout"), + ], + }); + +const entry = (htmlPaths: string[], path: string) => + report(htmlPaths).find(candidate => candidate.path === path); + +describe("buildRouteReport", () => { + it("calls a route static once every language has a file", () => { + expect(entry(["/plugins", "/pl/plugins"], "/plugins")).toEqual({ + mode: "static", + path: "/plugins", + staticPaths: ["/plugins", "/pl/plugins"], + }); + }); + + it("calls a route partly static when one language still renders on demand", () => { + expect(entry(["/plugins"], "/plugins")?.mode).toBe("partial"); + }); + + it("files a translated path under the route it translates", () => { + expect(entry(["/discover", "/pl/odkrywaj"], "/discover")).toEqual({ + mode: "static", + path: "/discover", + staticPaths: ["/discover", "/pl/odkrywaj"], + }); + }); + + it("lists the prerendered values of a route with a parameter", () => { + expect( + entry( + [ + "/pl/solutions/help-center", + "/pl/solutions/gaming-guild", + "/solutions/help-center", + "/solutions/gaming-guild", + ], + "/solutions/:slug", + ), + ).toEqual({ + mode: "partial", + path: "/solutions/:slug", + staticPaths: [ + "/solutions/gaming-guild", + "/pl/solutions/gaming-guild", + "/solutions/help-center", + "/pl/solutions/help-center", + ], + }); + }); + + it("prefers the route with fixed segments over one with a parameter", () => { + expect(entry(["/plugins"], "/solutions/:slug")?.staticPaths).toEqual([]); + }); + + it("gives a file to the exact route before a catch-all below it", () => { + const [docs] = buildRouteReport({ + hostRoutes: [hostRoute("/docs"), hostRoute("/docs/$")], + htmlPaths: ["/docs", "/pl/docs"], + i18n, + pluginRoutes: [], + }); + + expect(docs).toEqual({ + mode: "static", + path: "/docs", + staticPaths: ["/docs", "/pl/docs"], + }); + }); + + it("reports routes without a file as rendered on demand", () => { + expect(entry([], "/login")?.mode).toBe("dynamic"); + expect(entry([], "/api/*")?.mode).toBe("dynamic"); + }); + + it("leaves layouts out, since they are not pages", () => { + expect(entry([], "/settings")).toBeUndefined(); + }); + + it("still reports a static file no route claims", () => { + expect(entry(["/404"], "/404")).toEqual({ + mode: "static", + path: "/404", + staticPaths: ["/404"], + }); + }); +}); diff --git a/packages/vitnode/src/framework/vite/route-report.ts b/packages/vitnode/src/framework/vite/route-report.ts new file mode 100644 index 0000000000..63510bcb36 --- /dev/null +++ b/packages/vitnode/src/framework/vite/route-report.ts @@ -0,0 +1,160 @@ +import type { LocaleRouting } from "@/lib/i18n/locale-routing"; +import type { PluginRoute, PluginRouteSegment } from "@/routing/types"; + +import { localeRoutingFromConfig } from "@/lib/i18n/locale-routing"; +import { formatRoutePath } from "@/routing/path"; + +import type { HostRoutePath, LocaleRoutePathsConfig } from "../plugin-routes"; + +import { hostRouteSegments } from "../plugin-routes"; + +export type RouteRenderMode = "dynamic" | "partial" | "static"; + +export interface RouteReportEntry { + mode: RouteRenderMode; + path: string; + staticPaths: string[]; +} + +export interface RouteReportInput { + hostRoutes: readonly HostRoutePath[]; + htmlPaths: readonly string[]; + i18n: LocaleRoutePathsConfig; + pluginRoutes: readonly PluginRoute[]; +} + +interface ReportedRoute { + path: string; + segments: PluginRouteSegment[]; + staticPaths: Set; +} + +const segmentRank = (segment: PluginRouteSegment | undefined): number => { + if (segment === undefined) return 2; + + return { param: 1, splat: 3, static: 0 }[segment.kind]; +}; + +const bySpecificity = (a: ReportedRoute, b: ReportedRoute): number => { + const length = Math.max(a.segments.length, b.segments.length); + + for (let index = 0; index < length; index++) { + const difference = + segmentRank(a.segments[index]) - segmentRank(b.segments[index]); + if (difference !== 0) return difference; + } + + return 0; +}; + +const matchesRoute = ( + segments: readonly PluginRouteSegment[], + parts: readonly string[], +): boolean => { + for (const [index, segment] of segments.entries()) { + if (segment.kind === "splat") return parts.length >= index; + if (index >= parts.length) return false; + if ( + segment.kind === "static" && + segment.value.toLowerCase() !== parts[index].toLowerCase() + ) { + return false; + } + } + + return segments.length === parts.length; +}; + +const reportedRoutes = ( + hostRoutes: readonly HostRoutePath[], + pluginRoutes: readonly PluginRoute[], +): ReportedRoute[] => { + const routes = new Map(); + const add = (segments: PluginRouteSegment[]) => { + const path = formatRoutePath(segments); + routes.set(path, { path, segments, staticPaths: new Set() }); + }; + + pluginRoutes + .filter(route => route.kind === "page") + .forEach(route => { + add(route.segments); + }); + hostRoutes.forEach(route => { + const segments = hostRouteSegments(route.path); + if (segments !== null) add(segments); + }); + + return [...routes.values()].sort(bySpecificity); +}; + +const modeOf = ( + route: ReportedRoute, + localeRouting: LocaleRouting, +): RouteRenderMode => { + if (route.staticPaths.size === 0) return "dynamic"; + if (route.segments.some(segment => segment.kind !== "static")) { + return "partial"; + } + + const isEveryLocaleStatic = localeRouting.locales.every(locale => + route.staticPaths.has(localeRouting.localizePathname(route.path, locale)), + ); + + return isEveryLocaleStatic ? "static" : "partial"; +}; + +const byPageThenLocale = + (localeRouting: LocaleRouting) => + (a: string, b: string): number => { + const pageA = localeRouting.deLocalizePathname(a); + const pageB = localeRouting.deLocalizePathname(b); + if (pageA !== pageB) return pageA < pageB ? -1 : 1; + + const localeIndex = (path: string) => + localeRouting.locales.indexOf( + localeRouting.extractLocaleFromPath(path) ?? + localeRouting.defaultLocale, + ); + + return localeIndex(a) - localeIndex(b); + }; + +export const buildRouteReport = ({ + hostRoutes, + htmlPaths, + i18n, + pluginRoutes, +}: RouteReportInput): RouteReportEntry[] => { + const localeRouting = localeRoutingFromConfig(i18n); + const routes = reportedRoutes(hostRoutes, pluginRoutes); + const unclaimed: string[] = []; + + for (const htmlPath of htmlPaths) { + const parts = localeRouting + .deLocalizePathname(htmlPath) + .split("/") + .filter(part => part.length > 0); + const route = routes.find(candidate => + matchesRoute(candidate.segments, parts), + ); + + if (route === undefined) unclaimed.push(htmlPath); + else route.staticPaths.add(htmlPath); + } + + const entries: RouteReportEntry[] = [ + ...routes.map(route => ({ + mode: modeOf(route, localeRouting), + path: route.path, + staticPaths: [...route.staticPaths].sort(byPageThenLocale(localeRouting)), + })), + ...unclaimed.map(path => ({ + mode: "static" as const, + path, + staticPaths: [path], + })), + ]; + + return entries.sort((a, b) => (a.path < b.path ? -1 : 1)); +}; diff --git a/packages/vitnode/src/lib/ai/usage-breakdown.test.ts b/packages/vitnode/src/lib/ai/usage-breakdown.test.ts new file mode 100644 index 0000000000..07c68afad1 --- /dev/null +++ b/packages/vitnode/src/lib/ai/usage-breakdown.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; + +import type { AiUsageSegment } from "./usage-breakdown"; + +import { aiUsageBreakdown } from "./usage-breakdown"; + +const action = (key: string, monthPoints: string) => ({ key, monthPoints }); + +const keysOf = (segments: AiUsageSegment>[]) => + segments.map(segment => + segment.kind === "action" + ? segment.action.key + : `${String(segment.count)} other`, + ); + +describe("aiUsageBreakdown", () => { + it("orders the features by the points they used, leaving unused ones out", () => { + const segments = aiUsageBreakdown([ + action("tags", "10"), + action("reply", "0"), + action("summarize", "30"), + ]); + + expect(keysOf(segments)).toEqual(["summarize", "tags"]); + expect(segments.map(segment => segment.share)).toEqual([0.75, 0.25]); + }); + + it("folds everything after the top three into one segment", () => { + const segments = aiUsageBreakdown([ + action("a", "50"), + action("b", "20"), + action("c", "15"), + action("d", "10"), + action("e", "5"), + ]); + + expect(keysOf(segments)).toEqual(["a", "b", "c", "2 other"]); + expect(segments.at(-1)).toMatchObject({ points: 15, share: 0.15 }); + }); + + it("names a fourth feature instead of calling it one other feature", () => { + const segments = aiUsageBreakdown([ + action("a", "4"), + action("b", "3"), + action("c", "2"), + action("d", "1"), + ]); + + expect(keysOf(segments)).toEqual(["a", "b", "c", "d"]); + }); + + it("has nothing to show before any points were used", () => { + expect(aiUsageBreakdown([action("a", "0")])).toEqual([]); + }); +}); diff --git a/packages/vitnode/src/lib/ai/usage-breakdown.ts b/packages/vitnode/src/lib/ai/usage-breakdown.ts new file mode 100644 index 0000000000..d7c186e68c --- /dev/null +++ b/packages/vitnode/src/lib/ai/usage-breakdown.ts @@ -0,0 +1,40 @@ +export const AI_USAGE_BREAKDOWN_SIZE = 3; + +export type AiUsageSegment = + | { action: TAction; kind: "action"; points: number; share: number } + | { count: number; kind: "other"; points: number; share: number }; + +export const aiUsageBreakdown = ( + actions: readonly TAction[], +): AiUsageSegment[] => { + const used = actions + .map(action => ({ action, points: Number(action.monthPoints) })) + .filter(({ points }) => points > 0) + .sort((a, b) => b.points - a.points); + const total = used.reduce((sum, { points }) => sum + points, 0); + const named = + used.length > AI_USAGE_BREAKDOWN_SIZE + 1 + ? used.slice(0, AI_USAGE_BREAKDOWN_SIZE) + : used; + const others = used.slice(named.length); + const othersPoints = others.reduce((sum, { points }) => sum + points, 0); + + return [ + ...named.map(({ action, points }): AiUsageSegment => ({ + action, + kind: "action", + points, + share: points / total, + })), + ...(others.length > 0 + ? [ + { + count: others.length, + kind: "other" as const, + points: othersPoints, + share: othersPoints / total, + }, + ] + : []), + ]; +}; diff --git a/packages/vitnode/src/locales/en.json b/packages/vitnode/src/locales/en.json index 1237f1d603..aad263bb4f 100644 --- a/packages/vitnode/src/locales/en.json +++ b/packages/vitnode/src/locales/en.json @@ -942,7 +942,12 @@ "desc": "Your avatar and the details on your account.", "profileTitle": "Profile", "accountTitle": "Account", - "edit": "Edit", + "editProfile": "Edit profile", + "editProfileDesc": "Change your personal details and time zone in one go.", + "name": "Name", + "realName": "Real name", + "realNameShown": "Shown on your profile", + "realNameHidden": "Hidden", "personalTitle": "Personal Information", "personalDesc": "Tell the community who you are. Every field is optional.", "nickname": "Nickname", @@ -958,16 +963,14 @@ "headlineDesc": "One line about you, up to {max} characters.", "notSet": "Not set", "save": "Save changes", - "saved": "Personal information updated.", - "savedDesc": "Your profile shows the new details right away.", + "saved": "Profile updated", + "savedDesc": "Your changes show up right away.", "regionTitle": "Region", "regionDesc": "Digest emails and dates follow this time zone.", "timeZone": "Time zone", "timeZoneDesc": "Search by city, like Warsaw or New York.", "timeZoneAuto": "Automatic - from your language", - "timeZoneUseDevice": "Use this device's time zone ({timeZone})", - "timeZoneSaved": "Time zone saved", - "timeZoneSavedDesc": "Digests and dates now follow {timeZone}." + "timeZoneUseDevice": "Use this device's time zone ({timeZone})" }, "devices": { "title": "Devices", @@ -1311,7 +1314,14 @@ "used_of": "{used} of {total} points used", "renews": "Renews {date}", "unlimited": "Unlimited points", - "unlimited_used": "{used} points used this month", + "unlimited_desc": "No monthly cap. Your usage count starts over on {date}.", + "this_month": "pts this month", + "breakdown": { + "label": "Where your points went this month", + "other": "{count, plural, one {# other feature} other {# other features}}", + "points": "{points} pts", + "empty": "Nothing used this month yet. Once you run an AI feature, you'll see where your points go." + }, "none": "No AI points this month" }, "features": { diff --git a/packages/vitnode/src/tanstack/settings/overview.tsx b/packages/vitnode/src/tanstack/settings/overview.tsx index 9994760f6a..b7797d1331 100644 --- a/packages/vitnode/src/tanstack/settings/overview.tsx +++ b/packages/vitnode/src/tanstack/settings/overview.tsx @@ -4,12 +4,18 @@ import { useSuspenseQuery, } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router"; +import { cn } from "cn"; +import { ChevronRightIcon } from "lucide-react"; import React from "react"; import { useTranslations } from "use-intl"; import type { UpdatePersonalInformation } from "@/views/auth/settings/overview/personal-update"; import type { UpdateTimeZone } from "@/views/auth/settings/overview/time-zone-update"; +import { + AiPointsBreakdown, + AiUnlimitedPointsSummary, +} from "@/views/auth/settings/ai/ai-points-breakdown"; import { AiPointsContent } from "@/views/auth/settings/ai/ai-usage-content"; import { aiUsageQueryOptions, @@ -43,15 +49,27 @@ const OverviewAiPoints = ({ userId }: { userId: number }) => { return ( -
  • - -
  • + {usage.points.total === null ? ( + <> +
  • + +
  • +
  • + +
  • + + ) : ( +
  • + +
  • + )}
  • {t("details")} +
  • diff --git a/packages/vitnode/src/views/auth/settings/ai/ai-points-breakdown.tsx b/packages/vitnode/src/views/auth/settings/ai/ai-points-breakdown.tsx new file mode 100644 index 0000000000..0a809f2c9d --- /dev/null +++ b/packages/vitnode/src/views/auth/settings/ai/ai-points-breakdown.tsx @@ -0,0 +1,120 @@ +import { cn } from "cn"; +import { InfinityIcon } from "lucide-react"; +import { useFormatter, useLocale, useTranslations } from "use-intl"; + +import { + type AiActionTranslate, + translateAiActionText, +} from "@/lib/ai/action-text"; +import { formatAiPoints } from "@/lib/ai/format-points"; +import { aiUsageBreakdown } from "@/lib/ai/usage-breakdown"; + +import type { AiUsage, AiUsageAction } from "./ai-usage-query"; + +const SEGMENT_TONES = [ + "bg-primary", + "bg-primary/65", + "bg-primary/40", + "bg-primary/20", +] as const; + +export const AiUnlimitedPointsSummary = ({ usage }: { usage: AiUsage }) => { + const t = useTranslations("core.auth.settings.ai.points"); + const locale = useLocale(); + const format = useFormatter(); + + return ( +
    + + + +
    + {t("unlimited")} + + {t("unlimited_desc", { + date: format.dateTime(new Date(usage.resetsAt), { + day: "numeric", + month: "long", + }), + })} + +
    +
    + + {formatAiPoints(usage.points.used, locale)} + + {t("this_month")} +
    +
    + ); +}; + +export const AiPointsBreakdown = ({ + actions, +}: { + actions: AiUsageAction[]; +}) => { + const t = useTranslations("core.auth.settings.ai.points.breakdown"); + const tAll = useTranslations() as unknown as AiActionTranslate; + const locale = useLocale(); + const format = useFormatter(); + const segments = aiUsageBreakdown(actions).map((segment, index) => ({ + ...segment, + key: segment.kind === "action" ? segment.action.key : "other", + label: + segment.kind === "action" + ? translateAiActionText(tAll, segment.action.title) + : t("other", { count: segment.count }), + tone: SEGMENT_TONES[index], + })); + + if (segments.length === 0) { + return ( +

    + {t("empty")} +

    + ); + } + + return ( +
    +
    + {segments.map(segment => ( + + ))} +
    +
      + {segments.map(segment => ( +
    • + + {segment.label} + + {t("points", { + points: formatAiPoints(String(segment.points), locale), + })} + + + {format.number(segment.share, { + maximumFractionDigits: 0, + style: "percent", + })} + +
    • + ))} +
    +
    + ); +}; diff --git a/packages/vitnode/src/views/auth/settings/ai/ai-usage-content.test.tsx b/packages/vitnode/src/views/auth/settings/ai/ai-usage-content.test.tsx index 7722246515..fe0d08ff89 100644 --- a/packages/vitnode/src/views/auth/settings/ai/ai-usage-content.test.tsx +++ b/packages/vitnode/src/views/auth/settings/ai/ai-usage-content.test.tsx @@ -1,5 +1,11 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { + fireEvent, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; import { IntlProvider } from "use-intl"; import { describe, expect, it } from "vitest"; @@ -125,6 +131,50 @@ describe("AiUsageContent", () => { ).toBeTruthy(); }); + it("breaks an unlimited allowance down by the features that used it", () => { + const base = usage().actions[0]; + renderUsage( + usage({ + actions: [ + { ...base, key: "translate", monthPoints: "4", title: "Translate" }, + base, + { ...base, key: "tags", monthPoints: "0", title: "Suggest tags" }, + ], + points: { available: null, reserved: "0", total: null, used: "16.5" }, + }), + ); + + const breakdown = screen.getByRole("list", { + name: "core.auth.settings.ai.points.breakdown.label", + }); + const rows = within(breakdown).getAllByRole("listitem"); + + expect(rows.map(row => row.textContent)).toEqual([ + expect.stringContaining("Generate excerpt"), + expect.stringContaining("Translate"), + ]); + expect(rows[0].textContent).toContain("76%"); + expect(within(breakdown).queryByText("Suggest tags")).toBeNull(); + }); + + it("says nothing was used yet instead of drawing an empty breakdown", () => { + renderUsage( + usage({ + actions: [{ ...usage().actions[0], monthPoints: "0" }], + points: { available: null, reserved: "0", total: null, used: "0" }, + }), + ); + + expect( + screen.getByText("core.auth.settings.ai.points.breakdown.empty"), + ).toBeTruthy(); + expect( + screen.queryByRole("list", { + name: "core.auth.settings.ai.points.breakdown.label", + }), + ).toBeNull(); + }); + it("lists each feature by its title with today's use", () => { renderUsage(usage()); diff --git a/packages/vitnode/src/views/auth/settings/ai/ai-usage-content.tsx b/packages/vitnode/src/views/auth/settings/ai/ai-usage-content.tsx index eb1035aeef..1a9a492fa1 100644 --- a/packages/vitnode/src/views/auth/settings/ai/ai-usage-content.tsx +++ b/packages/vitnode/src/views/auth/settings/ai/ai-usage-content.tsx @@ -24,6 +24,10 @@ import { AI_USAGE_TONE_BAR, aiUsageTone } from "@/lib/ai/usage-tone"; import type { AiHistoryItem, AiUsage, AiUsageAction } from "./ai-usage-query"; +import { + AiPointsBreakdown, + AiUnlimitedPointsSummary, +} from "./ai-points-breakdown"; import { aiHistoryQueryOptions } from "./ai-usage-query"; const FEATURE_RUNS_PAGE = 5; @@ -94,12 +98,10 @@ export const AiPointsContent = ({ usage }: { usage: AiUsage }) => { if (total === null) { return ( -

    - {t("unlimited")}{" "} - - {t("unlimited_used", { used: usedLabel })} - -

    +
    + + +
    ); } diff --git a/packages/vitnode/src/views/auth/settings/overview/overview.tsx b/packages/vitnode/src/views/auth/settings/overview/overview.tsx index 697909091a..2fec7f5542 100644 --- a/packages/vitnode/src/views/auth/settings/overview/overview.tsx +++ b/packages/vitnode/src/views/auth/settings/overview/overview.tsx @@ -11,8 +11,8 @@ import { PageTitle } from "@/components/ui/page-title"; import { displayNameOf } from "@/lib/user-personal-information"; import { SelfUserImageDialog } from "@/views/profile/images/self-image-dialog"; -import type { PersonalInformationUser } from "./personal-content"; import type { UpdatePersonalInformation } from "./personal-update"; +import type { ProfileSummaryUser } from "./profile-summary"; import type { UpdateTimeZone } from "./time-zone-update"; import { @@ -20,17 +20,14 @@ import { SETTINGS_ROW_LABEL, SettingsGroup, } from "../settings-group"; -import { PersonalInformationContent } from "./personal-content"; -import { RealNameRow } from "./real-name-row"; -import { TimeZoneGroup } from "./time-zone-row"; +import { ProfileSheet } from "./profile-sheet"; +import { ProfileSummary } from "./profile-summary"; -export interface SettingsOverviewUser extends PersonalInformationUser { +export interface SettingsOverviewUser extends ProfileSummaryUser { avatarColor: string; avatarUrl: null | string; - name: string; nameCode: string; role: ProfileRole; - timeZone: null | string; } export const OverviewSettingsContent = ({ @@ -67,7 +64,10 @@ export const OverviewSettingsContent = ({ subtitle={tSettings("title")} /> - +
  • - {editor ? ( - + {editor ? ( + + ) : null} + - ) : null} + - {personalFields.showRealName ? ( - - ) : null} + - - - -
  • {t("nickname")} diff --git a/packages/vitnode/src/views/auth/settings/overview/personal-content.test.tsx b/packages/vitnode/src/views/auth/settings/overview/personal-content.test.tsx deleted file mode 100644 index bfa193ffad..0000000000 --- a/packages/vitnode/src/views/auth/settings/overview/personal-content.test.tsx +++ /dev/null @@ -1,125 +0,0 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { - afterEach, - beforeAll, - beforeEach, - describe, - expect, - it, - vi, -} from "vitest"; - -import type { PersonalInformationFields } from "@/lib/user-personal-information"; - -import type { PersonalInformationUser } from "./personal-content"; - -import { PersonalInformationContent } from "./personal-content"; - -const label = (key: string) => `core.auth.settings.overview.${key}`; - -const finePointer = (media: string): MediaQueryList => ({ - addEventListener: () => undefined, - addListener: () => undefined, - dispatchEvent: () => false, - matches: false, - media, - onchange: null, - removeEventListener: () => undefined, - removeListener: () => undefined, -}); - -beforeAll(async () => { - await import("./personal-field-editor"); -}); - -beforeEach(() => { - vi.stubGlobal("matchMedia", finePointer); -}); - -afterEach(() => { - vi.unstubAllGlobals(); -}); - -const editorFor = async (field: string) => - await screen.findByRole("textbox", { name: label(field) }); - -const user: PersonalInformationUser = { - email: "ada@example.com", - emailVerified: true, - firstName: "Ada", - headline: "Writes the first program", - lastName: "Lovelace", - name: "ada", - phone: null, - secondaryRoles: [], - showRealName: true, -}; - -const fields: PersonalInformationFields = { - firstName: true, - headline: false, - lastName: true, - phone: true, - showRealName: true, -}; - -const renderContent = (canEdit = true) => - render( - , - ); - -describe("PersonalInformationContent", () => { - it("lists one row per enabled field and marks the empty ones", () => { - renderContent(); - - expect(screen.getByText(label("firstName"))).toBeDefined(); - expect(screen.getByText("Lovelace")).toBeDefined(); - expect(screen.getByText(label("notSet"))).toBeDefined(); - expect(screen.queryByText(label("headline"))).toBeNull(); - expect(screen.getByText(label("personalDesc"))).toBeDefined(); - }); - - it("opens a row in place and gives focus back to it when the editor closes", async () => { - renderContent(); - - fireEvent.click(screen.getByRole("button", { name: /firstName/ })); - - expect((await editorFor("firstName")).value).toBe("Ada"); - expect(screen.queryByRole("button", { name: /firstName.*Ada/ })).toBeNull(); - - fireEvent.click(screen.getByRole("button", { name: "core.global.cancel" })); - - await waitFor(() => { - expect(document.activeElement).toBe( - screen.getByRole("button", { name: /firstName/ }), - ); - }); - }); - - it("keeps only one row open at a time", async () => { - renderContent(); - - fireEvent.click(screen.getByRole("button", { name: /firstName/ })); - await editorFor("firstName"); - - fireEvent.click(screen.getByRole("button", { name: /lastName/ })); - - await editorFor("lastName"); - expect( - screen.queryByRole("textbox", { name: label("firstName") }), - ).toBeNull(); - }); - - it("shows plain rows without an edit permission", () => { - renderContent(false); - - expect(screen.queryAllByRole("button")).toHaveLength(0); - expect(screen.getByText("Ada")).toBeDefined(); - expect(screen.queryByText(label("personalDesc"))).toBeNull(); - }); -}); diff --git a/packages/vitnode/src/views/auth/settings/overview/personal-content.tsx b/packages/vitnode/src/views/auth/settings/overview/personal-content.tsx deleted file mode 100644 index ecee370188..0000000000 --- a/packages/vitnode/src/views/auth/settings/overview/personal-content.tsx +++ /dev/null @@ -1,160 +0,0 @@ -import { cn } from "cn"; -import { ChevronRightIcon } from "lucide-react"; -import React from "react"; -import { useTranslations } from "use-intl"; - -import type { - PersonalInformationFields, - UserPersonalInformation, - UserPersonalInformationTextField, -} from "@/lib/user-personal-information"; -import type { ProfileRole } from "@/views/profile/profile-query"; - -import { Skeleton } from "@/components/ui/skeleton"; -import { PERSONAL_INFORMATION_TEXT_FIELDS } from "@/lib/user-personal-information"; - -import type { UpdatePersonalInformation } from "./personal-update"; - -import { - SETTINGS_INTERACTIVE_ROW, - SETTINGS_ROW, - SETTINGS_ROW_LABEL, - SettingsGroup, -} from "../settings-group"; - -const PersonalFieldEditor = React.lazy(async () => - import("./personal-field-editor").then(module => ({ - default: module.PersonalFieldEditor, - })), -); - -const PersonalFieldEditorSkeleton = () => ( - -); - -const STACKED_FIELDS: readonly UserPersonalInformationTextField[] = [ - "headline", -]; - -export interface PersonalInformationUser extends UserPersonalInformation { - email: string; - emailVerified: boolean; - name: string; - secondaryRoles: ProfileRole[]; -} - -export const PersonalInformationContent = ({ - canEdit, - fields, - onUpdate, - user, -}: { - canEdit: boolean; - fields: PersonalInformationFields; - onUpdate: UpdatePersonalInformation; - user: PersonalInformationUser; -}) => { - const t = useTranslations("core.auth.settings.overview"); - const [editing, setEditing] = - React.useState(null); - const rowsRef = React.useRef( - new Map(), - ); - const visibleFields = PERSONAL_INFORMATION_TEXT_FIELDS.filter( - field => fields[field], - ); - - if (visibleFields.length === 0) return null; - - const closeEditor = (field: UserPersonalInformationTextField) => { - setEditing(null); - requestAnimationFrame(() => rowsRef.current.get(field)?.focus()); - }; - - return ( - - {visibleFields.map(field => { - const value = user[field]; - const isStacked = STACKED_FIELDS.includes(field); - - if (editing === field) { - return ( -
  • - }> - { - closeEditor(field); - }} - onUpdate={onUpdate} - value={value} - /> - -
  • - ); - } - - const details = ( - - {t(field)} - - {value ?? t("notSet")} - - - ); - - return ( -
  • - {canEdit ? ( - - ) : ( -
    - {details} -
    - )} -
  • - ); - })} -
    - ); -}; diff --git a/packages/vitnode/src/views/auth/settings/overview/personal-field-editor.tsx b/packages/vitnode/src/views/auth/settings/overview/personal-field-editor.tsx deleted file mode 100644 index bdaef60666..0000000000 --- a/packages/vitnode/src/views/auth/settings/overview/personal-field-editor.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import { PhoneIcon } from "lucide-react"; -import React from "react"; -import { toast } from "sonner"; -import { useTranslations } from "use-intl"; -import { z } from "zod"; - -import type { AutoFormOnSubmit } from "@/components/form/auto-form"; -import type { UserPersonalInformationTextField } from "@/lib/user-personal-information"; - -import { AutoForm, AutoFormSubmitButton } from "@/components/form/auto-form"; -import { AutoFormInput } from "@/components/form/fields/input"; -import { Button } from "@/components/ui/button"; -import { InputGroupAddon } from "@/components/ui/input-group"; -import { - USER_FIRST_NAME_MAX_LENGTH, - USER_HEADLINE_MAX_LENGTH, - USER_LAST_NAME_MAX_LENGTH, - USER_PHONE_MAX_LENGTH, - USER_PHONE_PATTERN, -} from "@/lib/user-personal-information"; - -import type { - UpdatePersonalInformation, - UpdatePersonalInformationInput, -} from "./personal-update"; - -const FIELD_INPUTS: Record< - UserPersonalInformationTextField, - { autoComplete: string; max: number } -> = { - firstName: { autoComplete: "given-name", max: USER_FIRST_NAME_MAX_LENGTH }, - headline: { autoComplete: "off", max: USER_HEADLINE_MAX_LENGTH }, - lastName: { autoComplete: "family-name", max: USER_LAST_NAME_MAX_LENGTH }, - phone: { autoComplete: "tel", max: USER_PHONE_MAX_LENGTH }, -}; - -export const PersonalFieldEditor = ({ - field, - onClose, - onUpdate, - value, -}: { - field: UserPersonalInformationTextField; - onClose: () => void; - onUpdate: UpdatePersonalInformation; - value: null | string; -}) => { - const t = useTranslations("core.auth.settings.overview"); - const tGlobal = useTranslations("core.global"); - const tError = useTranslations("core.global.errors"); - const { autoComplete, max } = FIELD_INPUTS[field]; - const isPhone = field === "phone"; - const [autoFocus] = React.useState( - () => !window.matchMedia("(pointer: coarse)").matches, - ); - - const text = z.string().max(max); - const formSchema = z.object({ - value: (isPhone - ? text.refine(next => next === "" || USER_PHONE_PATTERN.test(next), { - message: tError("field_invalid_phone"), - }) - : text - ).default(value ?? ""), - }); - - const onSubmit: AutoFormOnSubmit = async values => { - const input: UpdatePersonalInformationInput = {}; - input[field] = values.value; - const result = await onUpdate(input); - - if (result.error) { - toast.error(tError("title"), { - description: tError("internal_server_error"), - }); - - return; - } - - toast.success(t("saved"), { description: t("savedDesc") }); - onClose(); - }; - - return ( - ( - - {isPhone ? ( - - - - ) : null} - - ), - id: "value", - }, - ]} - formSchema={formSchema} - layout={rendered => ( - <> - {rendered.value} -
    - - {t("save")} -
    - - )} - mode="all" - onKeyDown={event => { - if (event.key !== "Escape") return; - event.stopPropagation(); - onClose(); - }} - onSubmit={onSubmit} - /> - ); -}; diff --git a/packages/vitnode/src/views/auth/settings/overview/profile-changes.test.ts b/packages/vitnode/src/views/auth/settings/overview/profile-changes.test.ts new file mode 100644 index 0000000000..fab689e756 --- /dev/null +++ b/packages/vitnode/src/views/auth/settings/overview/profile-changes.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vitest"; + +import type { UserPersonalInformation } from "@/lib/user-personal-information"; + +import { resolvePersonalInformationFields } from "@/lib/user-personal-information"; + +import { profileChanges } from "./profile-changes"; + +const user: UserPersonalInformation = { + firstName: "Ada", + headline: null, + lastName: "Lovelace", + phone: null, + showRealName: true, +}; + +const allFields = resolvePersonalInformationFields(); + +const unchanged = { + firstName: "Ada", + headline: "", + lastName: "Lovelace", + phone: "", + showRealName: true, + timeZone: "Europe/Warsaw", +}; + +describe("profileChanges", () => { + it("reports nothing when the form still holds the saved values", () => { + expect( + profileChanges({ + canEdit: true, + fields: allFields, + initialTimeZone: "Europe/Warsaw", + user, + values: unchanged, + }), + ).toEqual({ personal: null }); + }); + + it("sends only the personal fields that changed, trimmed and emptied to null", () => { + expect( + profileChanges({ + canEdit: true, + fields: allFields, + initialTimeZone: "Europe/Warsaw", + user, + values: { + ...unchanged, + lastName: " ", + phone: " +48 600 700 800 ", + showRealName: false, + }, + }).personal, + ).toEqual({ + lastName: null, + phone: "+48 600 700 800", + showRealName: false, + }); + }); + + it("sends the time zone only when it moved away from the starting value", () => { + expect( + profileChanges({ + canEdit: true, + fields: allFields, + initialTimeZone: "Europe/Warsaw", + user, + values: { ...unchanged, timeZone: "America/New_York" }, + }), + ).toEqual({ personal: null, timeZone: "America/New_York" }); + }); + + it("ignores personal fields the role cannot edit or the install switched off", () => { + const values = { ...unchanged, firstName: "Augusta", phone: "123" }; + + expect( + profileChanges({ + canEdit: false, + fields: allFields, + initialTimeZone: "Europe/Warsaw", + user, + values, + }).personal, + ).toBeNull(); + expect( + profileChanges({ + canEdit: true, + fields: { ...allFields, phone: false }, + initialTimeZone: "Europe/Warsaw", + user, + values, + }).personal, + ).toEqual({ firstName: "Augusta" }); + }); +}); diff --git a/packages/vitnode/src/views/auth/settings/overview/profile-changes.ts b/packages/vitnode/src/views/auth/settings/overview/profile-changes.ts new file mode 100644 index 0000000000..a6bd2352c5 --- /dev/null +++ b/packages/vitnode/src/views/auth/settings/overview/profile-changes.ts @@ -0,0 +1,48 @@ +import type { + PersonalInformationFields, + UserPersonalInformation, + UserPersonalInformationField, +} from "@/lib/user-personal-information"; + +import { personalInformationChanges } from "@/lib/user-personal-information"; + +export interface ProfileSheetValues extends Partial< + Record +> { + timeZone: string; +} + +export interface ProfileChanges { + personal: null | Partial; + timeZone?: string; +} + +export const profileChanges = ({ + canEdit, + fields, + initialTimeZone, + user, + values, +}: { + canEdit: boolean; + fields: PersonalInformationFields; + initialTimeZone: string; + user: UserPersonalInformation; + values: ProfileSheetValues; +}): ProfileChanges => { + const personal = canEdit + ? Object.fromEntries( + Object.entries(personalInformationChanges(values, fields)).filter( + ([key, value]) => + user[key as keyof UserPersonalInformation] !== value, + ), + ) + : {}; + + return { + personal: Object.keys(personal).length > 0 ? personal : null, + ...(values.timeZone === initialTimeZone + ? {} + : { timeZone: values.timeZone }), + }; +}; diff --git a/packages/vitnode/src/views/auth/settings/overview/profile-sheet-form.tsx b/packages/vitnode/src/views/auth/settings/overview/profile-sheet-form.tsx new file mode 100644 index 0000000000..1dfef58452 --- /dev/null +++ b/packages/vitnode/src/views/auth/settings/overview/profile-sheet-form.tsx @@ -0,0 +1,257 @@ +import { useSelector } from "@tanstack/react-form"; +import { PhoneIcon } from "lucide-react"; +import React from "react"; +import { toast } from "sonner"; +import { useTranslations } from "use-intl"; +import { z } from "zod"; + +import type { AutoFormOnSubmit } from "@/components/form/auto-form"; +import type { PersonalInformationFields } from "@/lib/user-personal-information"; + +import { AutoForm } from "@/components/form/auto-form"; +import { AutoFormSheetFooter } from "@/components/form/auto-form-sheet-footer"; +import { AutoFormCombobox } from "@/components/form/fields/combobox"; +import { AutoFormInput } from "@/components/form/fields/input"; +import { AutoFormSwitch } from "@/components/form/fields/switch"; +import { Button } from "@/components/ui/button"; +import { useFormApi } from "@/components/ui/form"; +import { InputGroupAddon } from "@/components/ui/input-group"; +import { + PERSONAL_INFORMATION_TEXT_FIELDS, + USER_FIRST_NAME_MAX_LENGTH, + USER_HEADLINE_MAX_LENGTH, + USER_LAST_NAME_MAX_LENGTH, + USER_PHONE_MAX_LENGTH, + USER_PHONE_PATTERN, +} from "@/lib/user-personal-information"; + +import type { UpdatePersonalInformation } from "./personal-update"; +import type { ProfileSummaryUser } from "./profile-summary"; +import type { UpdateTimeZone } from "./time-zone-update"; + +import { profileChanges } from "./profile-changes"; +import { + deviceTimeZone, + supportedTimeZones, + timeZoneLabel, +} from "./time-zone-update"; + +const TEXT_INPUTS = { + firstName: { autoComplete: "given-name" }, + headline: { autoComplete: "off" }, + lastName: { autoComplete: "family-name" }, + phone: { + autoComplete: "tel", + inputMode: "tel", + placeholder: "+48 600 700 800", + type: "tel", + }, +} as const; + +const UseDeviceTimeZone = ({ device }: { device: null | string }) => { + const t = useTranslations("core.auth.settings.overview"); + const { form } = useFormApi(); + const value = useSelector(form.store, state => state.values.timeZone); + + if (!device || device === value) return null; + + return ( + + ); +}; + +export interface ProfileSheetFormProps { + canEdit: boolean; + fields: PersonalInformationFields; + onDone: () => void; + onTimeZoneUpdate: UpdateTimeZone; + onUpdate: UpdatePersonalInformation; + user: ProfileSummaryUser; +} + +export const ProfileSheetForm = ({ + canEdit, + fields, + onDone, + onTimeZoneUpdate, + onUpdate, + user, +}: ProfileSheetFormProps) => { + const t = useTranslations("core.auth.settings.overview"); + const tError = useTranslations("core.global.errors"); + const headingId = React.useId(); + const zones = React.useMemo(() => supportedTimeZones(), []); + const zoneLabels = React.useMemo( + () => zones.map(zone => ({ label: timeZoneLabel(zone), value: zone })), + [zones], + ); + const [device] = React.useState(deviceTimeZone); + const initialTimeZone = user.timeZone ?? device ?? "UTC"; + const textFields = canEdit + ? PERSONAL_INFORMATION_TEXT_FIELDS.filter(field => fields[field]) + : []; + const hasRealName = canEdit && fields.showRealName; + + const formSchema = z.object({ + firstName: z + .string() + .max(USER_FIRST_NAME_MAX_LENGTH) + .default(user.firstName ?? ""), + lastName: z + .string() + .max(USER_LAST_NAME_MAX_LENGTH) + .default(user.lastName ?? ""), + phone: z + .string() + .max(USER_PHONE_MAX_LENGTH) + .refine( + value => value.trim() === "" || USER_PHONE_PATTERN.test(value.trim()), + { message: tError("field_invalid_phone") }, + ) + .default(user.phone ?? ""), + headline: z + .string() + .max(USER_HEADLINE_MAX_LENGTH) + .default(user.headline ?? ""), + showRealName: z.boolean().default(user.showRealName), + timeZone: z.enum(zones as [string, ...string[]]).default(initialTimeZone), + }); + + const onSubmit: AutoFormOnSubmit = async values => { + const changes = profileChanges({ + canEdit, + fields, + initialTimeZone, + user, + values, + }); + const results = await Promise.all([ + changes.personal ? onUpdate(changes.personal) : null, + changes.timeZone === undefined + ? null + : onTimeZoneUpdate(changes.timeZone), + ]); + + if (results.some(result => result?.error)) { + toast.error(tError("title"), { + description: tError("internal_server_error"), + }); + + return; + } + + toast.success(t("saved"), { description: t("savedDesc") }); + onDone(); + }; + + return ( + ({ + component: (props: React.ComponentProps) => ( + + {field === "phone" ? ( + + + + ) : null} + + ), + id: field, + })), + ...(hasRealName + ? [ + { + component: ( + props: React.ComponentProps, + ) => ( + + ), + id: "showRealName", + }, + ] + : []), + { + component: props => ( + + ), + id: "timeZone", + }, + ]} + formSchema={formSchema} + layout={rendered => ( + <> +
    + {textFields.length > 0 || hasRealName ? ( +
    +

    + {t("personalTitle")} +

    + {textFields.map(field => ( + {rendered[field]} + ))} + {rendered.showRealName} +
    + ) : null} +
    +
    +

    + {t("regionTitle")} +

    +

    + {t("regionDesc")} +

    +
    +
    + {rendered.timeZone} + +
    +
    +
    + + + )} + onSubmit={onSubmit} + /> + ); +}; diff --git a/packages/vitnode/src/views/auth/settings/overview/profile-sheet.tsx b/packages/vitnode/src/views/auth/settings/overview/profile-sheet.tsx new file mode 100644 index 0000000000..927c4d2d26 --- /dev/null +++ b/packages/vitnode/src/views/auth/settings/overview/profile-sheet.tsx @@ -0,0 +1,66 @@ +import { PencilIcon } from "lucide-react"; +import React from "react"; +import { useTranslations } from "use-intl"; + +import { Button } from "@/components/ui/button"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "@/components/ui/sheet"; +import { Spinner } from "@/components/ui/spinner"; + +import type { ProfileSheetFormProps } from "./profile-sheet-form"; + +const ProfileSheetForm = React.lazy(async () => + import("./profile-sheet-form").then(module => ({ + default: module.ProfileSheetForm, + })), +); + +const warm = () => { + void import("./profile-sheet-form"); +}; + +export const ProfileSheet = (props: Omit) => { + const t = useTranslations("core.auth.settings.overview"); + const [open, setOpen] = React.useState(false); + + return ( + + } + > + + {t("editProfile")} + + + + {t("editProfile")} + + {t("editProfileDesc")} + + + + + + } + > + { + setOpen(false); + }} + /> + + + + ); +}; diff --git a/packages/vitnode/src/views/auth/settings/overview/profile-summary.tsx b/packages/vitnode/src/views/auth/settings/overview/profile-summary.tsx new file mode 100644 index 0000000000..8746796d29 --- /dev/null +++ b/packages/vitnode/src/views/auth/settings/overview/profile-summary.tsx @@ -0,0 +1,92 @@ +import { cn } from "cn"; +import { useTranslations } from "use-intl"; + +import type { + PersonalInformationFields, + UserPersonalInformation, +} from "@/lib/user-personal-information"; +import type { ProfileRole } from "@/views/profile/profile-query"; + +import { fullNameOf } from "@/lib/user-personal-information"; + +import { SETTINGS_ROW, SETTINGS_ROW_LABEL } from "../settings-group"; +import { timeZoneLabel } from "./time-zone-update"; + +export interface ProfileSummaryUser extends UserPersonalInformation { + email: string; + emailVerified: boolean; + name: string; + secondaryRoles: ProfileRole[]; + timeZone: null | string; +} + +const SummaryRow = ({ + label, + value, +}: { + label: string; + value: null | string; +}) => { + const t = useTranslations("core.auth.settings.overview"); + + return ( +
  • + {label} + + {value ?? t("notSet")} + +
  • + ); +}; + +export const ProfileSummary = ({ + fields, + user, +}: { + fields: PersonalInformationFields; + user: ProfileSummaryUser; +}) => { + const t = useTranslations("core.auth.settings.overview"); + + return ( + <> + {fields.headline && user.headline ? ( +
  • +

    + {user.headline} +

    +
  • + ) : null} + {fields.firstName || fields.lastName ? ( + + ) : null} + {fields.headline && !user.headline ? ( + + ) : null} + {fields.phone ? ( + + ) : null} + + {fields.showRealName ? ( + + ) : null} + + ); +}; diff --git a/packages/vitnode/src/views/auth/settings/overview/real-name-row.tsx b/packages/vitnode/src/views/auth/settings/overview/real-name-row.tsx deleted file mode 100644 index 4b20db40f2..0000000000 --- a/packages/vitnode/src/views/auth/settings/overview/real-name-row.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import React from "react"; -import { toast } from "sonner"; -import { useTranslations } from "use-intl"; - -import { Switch } from "@/components/ui/switch"; - -import type { UpdatePersonalInformation } from "./personal-update"; - -import { SETTINGS_ROW } from "../settings-group"; - -export const RealNameRow = ({ - canEdit, - checked, - onUpdate, -}: { - canEdit: boolean; - checked: boolean; - onUpdate: UpdatePersonalInformation; -}) => { - const t = useTranslations("core.auth.settings.overview"); - const tError = useTranslations("core.global.errors"); - const [pending, setPending] = React.useState(null); - const id = React.useId(); - - const onCheckedChange = async (next: boolean) => { - setPending(next); - const result = await onUpdate({ showRealName: next }); - setPending(null); - - if (result.error) { - toast.error(tError("title"), { - description: tError("internal_server_error"), - }); - - return; - } - - toast.success(t("saved"), { description: t("savedDesc") }); - }; - - return ( -
  • - - { - void onCheckedChange(next); - }} - /> -
  • - ); -}; diff --git a/packages/vitnode/src/views/auth/settings/overview/time-zone-editor.tsx b/packages/vitnode/src/views/auth/settings/overview/time-zone-editor.tsx deleted file mode 100644 index 6150f444b9..0000000000 --- a/packages/vitnode/src/views/auth/settings/overview/time-zone-editor.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import React from "react"; -import { toast } from "sonner"; -import { useTranslations } from "use-intl"; -import { z } from "zod"; - -import type { AutoFormOnSubmit } from "@/components/form/auto-form"; - -import { AutoForm, AutoFormSubmitButton } from "@/components/form/auto-form"; -import { AutoFormCombobox } from "@/components/form/fields/combobox"; -import { Button } from "@/components/ui/button"; - -import type { UpdateTimeZone } from "./time-zone-update"; - -import { - deviceTimeZone, - supportedTimeZones, - timeZoneLabel, -} from "./time-zone-update"; - -export const TimeZoneEditor = ({ - onClose, - onUpdate, - value, -}: { - onClose: () => void; - onUpdate: UpdateTimeZone; - value: null | string; -}) => { - const t = useTranslations("core.auth.settings.overview"); - const tGlobal = useTranslations("core.global"); - const tError = useTranslations("core.global.errors"); - const zones = React.useMemo(() => supportedTimeZones(), []); - const device = React.useMemo(() => deviceTimeZone(), []); - const [isUsingDevice, setIsUsingDevice] = React.useState(false); - - const save = async (timeZone: null | string) => { - const result = await onUpdate(timeZone); - - if (result.error) { - toast.error(tError("title"), { - description: tError("internal_server_error"), - }); - - return; - } - - toast.success(t("timeZoneSaved"), { - description: timeZone - ? t("timeZoneSavedDesc", { timeZone: timeZoneLabel(timeZone) }) - : t("timeZoneAuto"), - }); - onClose(); - }; - - const formSchema = z.object({ - value: z - .enum(zones as [string, ...string[]]) - .default(value ?? device ?? "UTC"), - }); - - const onSubmit: AutoFormOnSubmit = async values => { - await save(values.value); - }; - - return ( - ( - - ), - id: "value", - }, - ]} - formSchema={formSchema} - layout={rendered => ( - <> - {rendered.value} -
    - {device && device !== value ? ( - - ) : ( - - )} -
    - - {t("save")} -
    -
    - - )} - mode="all" - onKeyDown={event => { - if (event.key !== "Escape") return; - event.stopPropagation(); - onClose(); - }} - onSubmit={onSubmit} - /> - ); -}; diff --git a/packages/vitnode/src/views/auth/settings/overview/time-zone-row.tsx b/packages/vitnode/src/views/auth/settings/overview/time-zone-row.tsx deleted file mode 100644 index 24875a73f4..0000000000 --- a/packages/vitnode/src/views/auth/settings/overview/time-zone-row.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { ChevronRightIcon } from "lucide-react"; -import React from "react"; -import { useTranslations } from "use-intl"; - -import { Skeleton } from "@/components/ui/skeleton"; - -import type { UpdateTimeZone } from "./time-zone-update"; - -import { - SETTINGS_INTERACTIVE_ROW, - SETTINGS_ROW_LABEL, - SettingsGroup, -} from "../settings-group"; -import { timeZoneLabel } from "./time-zone-update"; - -const TimeZoneEditor = React.lazy(async () => - import("./time-zone-editor").then(module => ({ - default: module.TimeZoneEditor, - })), -); - -const TimeZoneEditorSkeleton = () => ( -
    - -
    - - -
    -
    -); - -export const TimeZoneGroup = ({ - onUpdate, - timeZone, -}: { - onUpdate: UpdateTimeZone; - timeZone: null | string; -}) => { - const t = useTranslations("core.auth.settings.overview"); - const [isEditing, setIsEditing] = React.useState(false); - const rowRef = React.useRef(null); - - const close = () => { - setIsEditing(false); - requestAnimationFrame(() => rowRef.current?.focus()); - }; - - return ( - -
  • - {isEditing ? ( - }> - - - ) : ( - - )} -
  • -
    - ); -};