Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 0 additions & 11 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<!-- BEGIN:turborepo-agent-rules -->

# 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.
<!-- END:turborepo-agent-rules -->
30 changes: 29 additions & 1 deletion apps/web/content/docs/dev/cli/build.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
19 changes: 11 additions & 8 deletions apps/web/content/docs/dev/working-with-users/users.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
| :------ | :----------------------------------- | :----------------------------------- |
Expand All @@ -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.

Expand Down Expand Up @@ -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

Expand Down
24 changes: 17 additions & 7 deletions apps/web/src/locales/@vitnode/core/pl.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand All @@ -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",
Expand Down Expand Up @@ -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": {
Expand Down
83 changes: 83 additions & 0 deletions apps/web/src/proto/ai-unlimited/breakdown.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<SettingsGroup title="AI points">
<li className={cn(SETTINGS_ROW, 'items-start')}>
<InfinityTile />
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="text-sm font-medium">Unlimited points</span>
<span className="text-muted-foreground text-sm leading-relaxed text-pretty">
No monthly cap. Your usage count starts over on {formatDay(usage.resetsAt)}.
</span>
</div>
<div className="flex shrink-0 flex-col items-end gap-0.5">
<span className="text-lg leading-none font-semibold tabular-nums">{formatPoints(usage.used)}</span>
<span className="text-muted-foreground text-xs">pts this month</span>
</div>
</li>
<li className={cn(SETTINGS_ROW, 'flex-col items-stretch gap-3')}>
{segments.length === 0 ? (
<p className="text-muted-foreground text-sm leading-relaxed text-pretty">
Nothing used since {formatDay(usage.periodStart)}. Once you run an AI feature, you'll see where your points go.
</p>
) : (
<>
<div
aria-hidden
className="bg-muted flex h-2 w-full gap-0.5 overflow-hidden rounded-full"
>
{segments.map((segment, index) => (
<span
className={cn('h-full min-w-1', SEGMENT_TONES[index])}
key={segment.key}
style={{ flexGrow: segment.points }}
/>
))}
</div>
<ul aria-label="Where your points went this month" className="flex flex-col gap-2">
{segments.map((segment, index) => (
<li className="flex items-center gap-3 text-sm" key={segment.key}>
<span aria-hidden className={cn('size-2 shrink-0 rounded-full', SEGMENT_TONES[index])} />
<span className="min-w-0 flex-1 truncate">{segment.label}</span>
<span className="text-muted-foreground shrink-0 tabular-nums">{formatPoints(segment.points)} pts</span>
<span className="w-10 shrink-0 text-end font-medium tabular-nums">
{percent.format(segment.points / usage.used)}
</span>
</li>
))}
</ul>
</>
)}
</li>
<li className={SETTINGS_ROW}>
<DetailsLink />
</li>
</SettingsGroup>
)
}
18 changes: 18 additions & 0 deletions apps/web/src/proto/ai-unlimited/current.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => (
<SettingsGroup title="AI points">
<li className={SETTINGS_ROW}>
<p className="text-sm leading-relaxed">
<span className="font-medium">Unlimited points</span>{' '}
<span className="text-muted-foreground tabular-nums">{formatPoints(usage.used)} points used this month</span>
</p>
</li>
<li className={SETTINGS_ROW}>
<DetailsLink />
</li>
</SettingsGroup>
)
63 changes: 63 additions & 0 deletions apps/web/src/proto/ai-unlimited/daily-limits.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<SettingsGroup title="AI points">
<li className={cn(SETTINGS_ROW, 'items-start')}>
<InfinityTile />
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="text-sm font-medium">No monthly cap on your points</span>
<span className="text-muted-foreground text-sm leading-relaxed text-pretty">
Only daily limits can pause a feature. They reset at midnight.
</span>
</div>
</li>
{limited.map(feature => {
const limit = feature.dailyLimit ?? 0
const isFull = feature.usedToday >= limit
const share = Math.min(feature.usedToday / limit, 1)

return (
<li className={SETTINGS_ROW} key={feature.key}>
<AiActionIcon enabled={!isFull} icon={feature.icon} />
<span className="min-w-0 flex-1 truncate text-sm">{feature.title}</span>
{isFull ? (
<Badge variant="destructive">Limit reached</Badge>
) : (
<span className="flex shrink-0 items-center gap-3">
<span
aria-hidden
className="bg-muted hidden h-1.5 w-20 overflow-hidden rounded-full sm:block"
>
<span
className={cn('block h-full rounded-full', share >= 0.8 ? 'bg-warn' : 'bg-primary')}
style={{ width: `${share * 100}%` }}
/>
</span>
<span className="text-muted-foreground min-w-24 text-end text-sm whitespace-nowrap tabular-nums">
{feature.usedToday} / {limit} today
</span>
</span>
)}
</li>
)
})}
<li className={cn(SETTINGS_ROW, 'flex-wrap justify-between gap-y-1')}>
<span className="text-muted-foreground text-sm tabular-nums">
{open > 0 ? `${open} more ${open === 1 ? 'feature has' : 'features have'} no daily limit · ` : ''}
{formatPoints(usage.used)} pts used this month
</span>
<DetailsLink>All features</DetailsLink>
</li>
</SettingsGroup>
)
}
64 changes: 64 additions & 0 deletions apps/web/src/proto/ai-unlimited/data.ts
Original file line number Diff line number Diff line change
@@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React Doctor · react-doctor/js-hoist-intl (warning)

This is slow because new Intl.NumberFormat() rebuilds on every call inside a function, so move it to the top of the file, or wrap it in useMemo

Fix → Move new Intl.NumberFormat(...) to the top of the file or wrap it in useMemo. Building one is slow, so don't redo it on every call

Docs

}

export const formatDay = (date: Date) =>
new Intl.DateTimeFormat('en', { day: 'numeric', month: 'long' }).format(date)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React Doctor · react-doctor/js-hoist-intl (warning)

This is slow because new Intl.DateTimeFormat() rebuilds on every call inside a function, so move it to the top of the file, or wrap it in useMemo

Fix → Move new Intl.NumberFormat(...) to the top of the file or wrap it in useMemo. Building one is slow, so don't redo it on every call

Docs


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)
Loading
Loading