Merge pull request #1924 from AnishSarkar22/feat/desktop-app-ui
Some checks are pending
Build and Push Docker Images / image_version (push) Waiting to run
Build and Push Docker Images / build (./docker/sandbox, cpu, ./docker/sandbox/Dockerfile, sandbox, surfsense-sandbox, ubuntu-24.04-arm, linux/arm64, arm64, , false, cpu) (push) Blocked by required conditions
Build and Push Docker Images / build (./docker/sandbox, cpu, ./docker/sandbox/Dockerfile, sandbox, surfsense-sandbox, ubuntu-latest, linux/amd64, amd64, , false, cpu) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_backend, cpu, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64, , production, false, cpu) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_backend, cpu, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64, , production, false, cpu) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_backend, cu126, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64, -cuda126, production, true, cuda126) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_backend, cu126, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64, -cuda126, production, true, cuda126) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_backend, cu128, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64, -cuda, production, true, cuda) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_backend, cu128, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64, -cuda, production, true, cuda) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_web, cpu, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-24.04-arm, linux/arm64, arm64, , runner, false, cpu) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_web, cpu, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-latest, linux/amd64, amd64, , runner, false, cpu) (push) Blocked by required conditions
Build and Push Docker Images / verify_digests (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (backend, surfsense-backend, , cpu) (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (backend, surfsense-backend, -cuda, cuda) (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (backend, surfsense-backend, -cuda126, cuda126) (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (sandbox, surfsense-sandbox, , cpu) (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (web, surfsense-web, , cpu) (push) Blocked by required conditions

feat(local): add an About section to settings and re-sync UI components to base-nova
This commit is contained in:
Anish Sarkar 2026-09-26 03:47:50 +05:30 • committed by GitHub
commit 92fd5a225a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
45 changed files with 1136 additions and 127 deletions

View file

@ -0,0 +1,24 @@
# About
Settings › About, in the dialog's App group, shows which build is running and where to get help: the version, the update controls, links, the license, and details for a bug report.
**Code:** [`surfsense_local/frontend/src/features/about/`](../../surfsense_local/frontend/src/features/about/), [`surfsense_local/electron/src/main/about/`](../../surfsense_local/electron/src/main/about/), [`surfsense_local/electron/src/main/external-url.ts`](../../surfsense_local/electron/src/main/external-url.ts)
## What it shows
- The version, from `app.getVersion()` in the main process, read through `window.surfsense.about.details()`. The same call returns the OS, architecture, and the Electron, Chromium and Node versions.
- The update controls ([updates](updates.md)), which moved here from General.
- Links: release notes for the running version (`/releases/tag/v<version>`), the docs on `surfsense.com`, the GitHub repository, the Discord invite, and a new bug report on the `bug.yml` form with the system details prefilled in its `what` field.
- The license line, Apache 2.0, linking to `LICENSE` on GitHub.
- Troubleshooting: Copy system info puts the version, OS and runtime versions on the clipboard, in English whatever the interface language, since it is pasted into issues.
## Links
Every link opens in the OS browser, only on a click, through `shell:open-external`. The main process opens only `surfsense.com`, the `MODSetter/SurfSense` repository on GitHub, and the Discord invite, and drops anything else ([egress](egress.md)). A link added here that falls outside that list does nothing until the list grows.
Outside the desktop app there is no bridge: the version, release notes, bug report and troubleshooting rows are hidden, and the remaining links are plain anchors.
## Known gaps
- No third-party license notices ship with the app. Generating them needs the frontend's npm dependencies, the Python sidecars' packages and the bundled native binaries collected at build time.
- The app writes no log files, so there is no logs folder to open. Sidecar output goes to the terminal only.

View file

@ -41,7 +41,7 @@ The GGUF download runs inside the API (`modules/llm/providers/llamacpp/download.
- llama-server and sd-server, which the backend reaches on `127.0.0.1`.
- The worker's `POST /internal/events` to the API, and Electron polling the API: `/health` at startup, the image runtime every few seconds.
- App updates. electron-updater talks to GitHub from the Electron main process, out of reach of `egress.require()`, so it is gated by the `automatic` preference in `updates.json` and by consent in the renderer instead ([updates](updates.md)). Settings › Network shows it as the App updates row, host `github.com`.
- Links. The main process refuses new windows and `https:` navigations inside the app, and hands a URL on `surfsense.com` or `www.surfsense.com` to `shell.openExternal`; that request is the browser's.
- Links. The main process refuses new windows and `https:` navigations inside the app, and hands a URL on `surfsense.com` or `www.surfsense.com`, under `github.com/MODSetter/SurfSense`, or the project's Discord invite to `shell.openExternal` (`electron/src/main/external-url.ts`); that request is the browser's.
- Docling. Packaged Python sidecars run with `HF_HUB_OFFLINE=1` (`electron/src/main/sidecars/python.ts`) and parse with the bundled parser pack ([packaging](packaging.md)). No test yet ingests a PDF with networking off, so this rests on that configuration rather than on a check.
- Licenses. The app verifies license files offline and never contacts Keygen ([license](license/app.md)); `PUT /egress/keygen` is refused as unknown.

View file

@ -113,7 +113,7 @@ The rules that keep the processes out of each other's way:
- [Connections](connections.md): OpenAI-compatible endpoints and where keys live.
- Local models: [runtime](local-models/runtime.md), [fit](local-models/fit.md), [catalog](local-models/catalog.md), [selection and onboarding](local-models/selection.md).
- [Localization](localization.md): the interface in English, Japanese and German.
- [Egress](egress.md), [import](import.md), [license in the app](license/app.md), [license portal](license/portal.md), [updates](updates.md), [packaging](packaging.md), [sunset](sunset.md).
- [Egress](egress.md), [import](import.md), [license in the app](license/app.md), [license portal](license/portal.md), [updates](updates.md), [about](about.md), [packaging](packaging.md), [sunset](sunset.md).
- [Contracts](../contracts/README.md) between the trees.
## Known gaps

View file

@ -13,7 +13,7 @@ Updates are the one call the app is designed to make on its own, so they are off
- The preference is `updates.json` in Electron's user data: `automatic`, false by default, and `lastCheckedAt`, which Settings › Network shows as the App updates row's last call.
- The check at launch runs only when `automatic` is on.
- Check now in Settings › General and the sidebar's update row ask first while `automatic` is off. Allow turns `automatic` on, which also enables the launch check.
- Check now in Settings › About and the sidebar's update row ask first while `automatic` is off. Allow turns `automatic` on, which also enables the launch check.
- `autoDownload` is false. The download starts from the `update-available` event, so it only ever follows a check the user enabled or clicked.
- Installing waits for the user: the restart action in Settings, the sidebar or the title bar calls `quitAndInstall()`, and only once the download is ready.

View file

@ -0,0 +1,19 @@
import { app, ipcMain } from "electron"
import { appDetails } from "./app-details.ts"
export function registerAboutHandlers(): void {
ipcMain.handle("about:details", () =>
appDetails({
version: app.getVersion(),
versions: {
electron: process.versions.electron,
chrome: process.versions.chrome,
node: process.versions.node,
},
platform: process.platform,
systemVersion: process.getSystemVersion(),
arch: process.arch,
})
)
}

View file

@ -0,0 +1,44 @@
import assert from "node:assert/strict"
import test from "node:test"
import { appDetails } from "./app-details.ts"
test("describes the build and the OS it runs on", () => {
assert.deepEqual(
appDetails({
version: "2.0.2",
versions: { electron: "44.0.0", chrome: "146.0.1", node: "24.1.0" },
platform: "darwin",
systemVersion: "15.4",
arch: "arm64",
}),
{
version: "2.0.2",
electron: "44.0.0",
chrome: "146.0.1",
node: "24.1.0",
os: "macOS 15.4",
arch: "arm64",
}
)
})
test("names Windows and Linux, and passes an unknown platform through", () => {
const base = {
version: "2.0.2",
versions: { electron: "44.0.0", chrome: "146.0.1", node: "24.1.0" },
arch: "x64",
}
assert.equal(
appDetails({ ...base, platform: "win32", systemVersion: "10.0.26100" }).os,
"Windows 10.0.26100"
)
assert.equal(
appDetails({ ...base, platform: "linux", systemVersion: "6.8.0" }).os,
"Linux 6.8.0"
)
assert.equal(
appDetails({ ...base, platform: "freebsd", systemVersion: "14.1" }).os,
"freebsd 14.1"
)
})

View file

@ -0,0 +1,35 @@
// What Settings › About shows and copies into bug reports. Mirrored in
// frontend/src/lib/api.ts.
export type AppDetails = {
version: string
electron: string
chrome: string
node: string
os: string
arch: string
}
const OS_NAMES: Record<string, string> = {
darwin: "macOS",
win32: "Windows",
linux: "Linux",
}
// Inputs are passed in, not read from `app`/`process`, so tests run without Electron.
export function appDetails(runtime: {
version: string
versions: { electron: string; chrome: string; node: string }
platform: string
systemVersion: string
arch: string
}): AppDetails {
const osName = OS_NAMES[runtime.platform] ?? runtime.platform
return {
version: runtime.version,
electron: runtime.versions.electron,
chrome: runtime.versions.chrome,
node: runtime.versions.node,
os: `${osName} ${runtime.systemVersion}`,
arch: runtime.arch,
}
}

View file

@ -0,0 +1,49 @@
import assert from "node:assert/strict"
import test from "node:test"
import { allowedExternalUrl } from "./external-url.ts"
test("opens the SurfSense site", () => {
assert.equal(
allowedExternalUrl("https://www.surfsense.com/docs"),
"https://www.surfsense.com/docs"
)
assert.equal(
allowedExternalUrl("https://surfsense.com/sunset"),
"https://surfsense.com/sunset"
)
})
test("opens the project's GitHub repository and nothing else on GitHub", () => {
assert.equal(
allowedExternalUrl(
"https://github.com/MODSetter/SurfSense/releases/tag/v2.0.2"
),
"https://github.com/MODSetter/SurfSense/releases/tag/v2.0.2"
)
assert.equal(
allowedExternalUrl("https://github.com/MODSetter/SurfSense"),
"https://github.com/MODSetter/SurfSense"
)
assert.equal(allowedExternalUrl("https://github.com/MODSetter"), null)
assert.equal(
allowedExternalUrl("https://github.com/MODSetter/SurfSense-evil"),
null
)
assert.equal(allowedExternalUrl("https://github.com/someone/else"), null)
})
test("opens the community Discord invite only", () => {
assert.equal(
allowedExternalUrl("https://discord.gg/ejRNvftDp9"),
"https://discord.gg/ejRNvftDp9"
)
assert.equal(allowedExternalUrl("https://discord.gg/other"), null)
})
test("refuses other schemes and hosts", () => {
assert.equal(allowedExternalUrl("http://surfsense.com"), null)
assert.equal(allowedExternalUrl("file:///etc/passwd"), null)
assert.equal(allowedExternalUrl("https://evil.surfsense.com.example"), null)
assert.equal(allowedExternalUrl("not a url"), null)
})

View file

@ -0,0 +1,28 @@
// The only links the app hands to the OS browser: the SurfSense site, the
// project's own GitHub repository, and its Discord invite. Anything else is
// dropped so a crafted link in content cannot open arbitrary pages.
const SITE_HOSTS = new Set(["surfsense.com", "www.surfsense.com"])
const REPO_PATH = "/MODSetter/SurfSense"
const DISCORD_INVITE = "/ejRNvftDp9"
export function allowedExternalUrl(url: string): string | null {
let parsed: URL
try {
parsed = new URL(url)
} catch {
return null
}
if (parsed.protocol !== "https:") return null
if (SITE_HOSTS.has(parsed.hostname)) return parsed.href
if (
parsed.hostname === "github.com" &&
(parsed.pathname === REPO_PATH ||
parsed.pathname.startsWith(`${REPO_PATH}/`))
) {
return parsed.href
}
if (parsed.hostname === "discord.gg" && parsed.pathname === DISCORD_INVITE) {
return parsed.href
}
return null
}

View file

@ -21,6 +21,7 @@ import {
nameDevBuild,
} from "./dev-app-identity.ts"
import { managedOriginalPath } from "./document-files.ts"
import { allowedExternalUrl } from "./external-url.ts"
import { getFreePort, waitForHealth } from "./net.ts"
import { loadSecret } from "./secret.ts"
import {
@ -64,25 +65,10 @@ import { loadWindowState, saveWindowState } from "./window-state.ts"
import { applyLocalePreference } from "./i18n/app-locale.ts"
import { loadLocalePreference } from "./i18n/locale-prefs.ts"
import { registerLocaleHandlers } from "./i18n/locale-ipc.ts"
import { registerAboutHandlers } from "./about/about-ipc.ts"
const DEV_RENDERER_URL = "http://localhost:5173"
function allowedExternalUrl(url: string): string | null {
try {
const parsed = new URL(url)
if (
parsed.protocol === "https:" &&
(parsed.hostname === "surfsense.com" ||
parsed.hostname === "www.surfsense.com")
) {
return parsed.href
}
} catch {
return null
}
return null
}
function openAllowedExternal(url: string): void {
const allowed = allowedExternalUrl(url)
if (allowed) void shell.openExternal(allowed)
@ -583,6 +569,7 @@ function main(): void {
isTrusted: (sender) =>
mainWindow !== null && sender === mainWindow.webContents,
})
registerAboutHandlers()
installMenu()
createWindow(boot.apiUrl)
await registerUpdateHandlers()

View file

@ -46,6 +46,9 @@ contextBridge.exposeInMainWorld("surfsense", {
}): Promise<void> => ipcRenderer.invoke("shell:titlebar-overlay", overlay),
openExternal: (url: string): Promise<void> =>
ipcRenderer.invoke("shell:open-external", url),
about: {
details: () => ipcRenderer.invoke("about:details"),
},
locale: {
get: (): string => locale,
preference: (): Promise<string> => ipcRenderer.invoke("locale:preference"),

View file

@ -40,6 +40,7 @@ import {
InformationCircleIcon as InformationCircleIconData,
LayoutGridIcon as LayoutGridIconData,
LicenseIcon as LicenseIconData,
LinkSquare02Icon as LinkSquare02IconData,
LightbulbIcon as LightbulbIconData,
Loading03Icon,
Moon02Icon as Moon02IconData,
@ -149,6 +150,7 @@ export const AudioWaveformIcon = createIcon(AudioWaveformIconData)
export const InformationCircleIcon = createIcon(InformationCircleIconData)
export const LayoutGridIcon = createIcon(LayoutGridIconData)
export const LicenseIcon = createIcon(LicenseIconData)
export const ExternalLinkIcon = createIcon(LinkSquare02IconData)
export const LightbulbIcon = createIcon(LightbulbIconData)
export const Loader2Icon = createIcon(Loading03Icon)
export const MoonIcon = createIcon(Moon02IconData)

View file

@ -1,9 +1,12 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
@ -11,7 +14,7 @@ function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
data-slot="input-group"
role="group"
className={cn(
"group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring/70 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
"group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring/70 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
className
)}
{...props}
@ -26,8 +29,10 @@ const inputGroupAddonVariants = cva(
align: {
"inline-start":
"order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]",
// A trailing button gets a square cap the group's height, so it sits
// as far from the end as from the top and bottom at any group height.
"inline-end":
"order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]",
"order-last pr-2 has-[>button]:aspect-square has-[>button]:self-stretch has-[>button]:p-0 has-[>kbd]:mr-[-0.15rem]",
"block-start":
"order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
"block-end":
@ -86,8 +91,10 @@ function InputGroupButton({
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size"> &
VariantProps<typeof inputGroupButtonVariants>) {
}: Omit<React.ComponentProps<typeof Button>, "size" | "type"> &
VariantProps<typeof inputGroupButtonVariants> & {
type?: "button" | "submit" | "reset"
}) {
return (
<Button
type={type}
@ -127,10 +134,27 @@ function InputGroupInput({
)
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className
)}
{...props}
/>
)
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
}

View file

@ -1,10 +1,11 @@
import type { ComponentProps } from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: ComponentProps<"input">) {
return (
<input
<InputPrimitive
type={type}
data-slot="input"
className={cn(

View file

@ -1,28 +1,82 @@
import * as React from "react"
"use client"
import { Progress as ProgressPrimitive } from "@base-ui/react/progress"
import { cn } from "@/lib/utils"
interface ProgressProps extends React.HTMLAttributes<HTMLDivElement> {
value?: number
}
const Progress = React.forwardRef<HTMLDivElement, ProgressProps>(
({ className, value = 0, ...props }, ref) => (
<div
ref={ref}
className={cn(
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
className
)}
function Progress({
className,
children,
value,
...props
}: ProgressPrimitive.Root.Props) {
return (
<ProgressPrimitive.Root
value={value}
data-slot="progress"
className={cn("flex flex-wrap gap-3", className)}
{...props}
>
<div
className="h-full bg-primary transition-all duration-300 ease-in-out"
style={{ width: `${Math.min(100, Math.max(0, value))}%` }}
/>
</div>
{children}
<ProgressTrack>
<ProgressIndicator />
</ProgressTrack>
</ProgressPrimitive.Root>
)
)
Progress.displayName = "Progress"
}
export { Progress }
function ProgressTrack({ className, ...props }: ProgressPrimitive.Track.Props) {
return (
<ProgressPrimitive.Track
className={cn(
"relative flex h-1 w-full items-center overflow-x-hidden rounded-full bg-muted",
className
)}
data-slot="progress-track"
{...props}
/>
)
}
function ProgressIndicator({
className,
...props
}: ProgressPrimitive.Indicator.Props) {
return (
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className={cn("h-full bg-primary transition-all", className)}
{...props}
/>
)
}
function ProgressLabel({ className, ...props }: ProgressPrimitive.Label.Props) {
return (
<ProgressPrimitive.Label
className={cn("text-sm font-medium", className)}
data-slot="progress-label"
{...props}
/>
)
}
function ProgressValue({ className, ...props }: ProgressPrimitive.Value.Props) {
return (
<ProgressPrimitive.Value
className={cn(
"ml-auto text-sm text-muted-foreground tabular-nums",
className
)}
data-slot="progress-value"
{...props}
/>
)
}
export {
Progress,
ProgressTrack,
ProgressIndicator,
ProgressLabel,
ProgressValue,
}

View file

@ -1,27 +1,52 @@
"use client"
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
function Tabs({ className, ...props }: TabsPrimitive.Root.Props) {
function Tabs({
className,
orientation = "horizontal",
...props
}: TabsPrimitive.Root.Props) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-4", className)}
data-orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-horizontal:flex-col",
className
)}
{...props}
/>
)
}
function TabsList({ className, ...props }: TabsPrimitive.List.Props) {
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"inline-flex h-9 w-full items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
className
)}
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
@ -32,7 +57,10 @@ function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
<TabsPrimitive.Tab
data-slot="tabs-trigger"
className={cn(
"inline-flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-md border border-transparent px-2.5 py-1 text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-active:bg-background data-active:text-foreground data-active:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
className
)}
{...props}
@ -44,10 +72,10 @@ function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
return (
<TabsPrimitive.Panel
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
className={cn("flex-1 text-sm outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent }
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }

View file

@ -0,0 +1,17 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring/70 focus-visible:ring-0 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }

View file

@ -0,0 +1,20 @@
// Each must pass electron/src/main/external-url.ts, or the click does nothing.
const REPO_URL = "https://github.com/MODSetter/SurfSense"
export const DOCS_URL = "https://www.surfsense.com/docs"
export const GITHUB_URL = REPO_URL
export const DISCORD_URL = "https://discord.gg/ejRNvftDp9"
export const LICENSE_URL = `${REPO_URL}/blob/main/LICENSE`
export function releaseNotesUrl(version: string): string {
return `${REPO_URL}/releases/tag/v${version}`
}
// `what` is a field id in .github/ISSUE_TEMPLATE/bug.yml; GitHub prefills it.
export function bugReportUrl(systemInfo: string): string {
const params = new URLSearchParams({
template: "bug.yml",
what: `\n\n---\n${systemInfo}`,
})
return `${REPO_URL}/issues/new?${params}`
}

View file

@ -0,0 +1,102 @@
import { afterEach, describe, expect, it, vi } from "vitest"
import { cleanup, screen } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { stubUpdateBridge } from "@/features/updates/stub-bridge"
import { render } from "@/test-utils"
import { AboutSettings } from "./about-settings"
const DETAILS = {
version: "2.0.2",
electron: "44.0.0",
chrome: "146.0.1",
node: "24.1.0",
os: "macOS 15.4",
arch: "arm64",
}
function stubAboutBridge() {
stubUpdateBridge({ automatic: true, state: { status: "idle" } })
const openExternal = vi.fn(async () => undefined)
window.surfsense = {
...window.surfsense!,
openExternal,
about: { details: async () => DETAILS },
}
return { openExternal }
}
afterEach(() => {
cleanup()
delete window.surfsense
vi.restoreAllMocks()
})
describe("AboutSettings", () => {
it("shows the running version beside the update controls", async () => {
stubAboutBridge()
render(<AboutSettings />)
expect(await screen.findByText("Version 2.0.2")).toBeTruthy()
// The heading scrolls with the page, as the model sections' do, so the
// fade never cuts the app's name under a fixed header.
expect(
document
.querySelector('[data-slot="scroll-fade-viewport"]')
?.contains(screen.getByRole("heading", { name: "About" }))
).toBe(true)
expect(
await screen.findByRole("button", { name: "Check now" })
).toBeTruthy()
})
it("opens this version's release notes in the browser", async () => {
const bridge = stubAboutBridge()
const user = userEvent.setup()
render(<AboutSettings />)
await user.click(await screen.findByRole("link", { name: "Release notes" }))
expect(bridge.openExternal).toHaveBeenCalledWith(
"https://github.com/MODSetter/SurfSense/releases/tag/v2.0.2"
)
})
it("copies the version and system for a bug report", async () => {
stubAboutBridge()
const user = userEvent.setup()
const writeText = vi
.spyOn(navigator.clipboard, "writeText")
.mockResolvedValue(undefined)
render(<AboutSettings />)
await user.click(
await screen.findByRole("button", { name: "Copy system info" })
)
const copied = writeText.mock.calls[0]?.[0] ?? ""
expect(copied).toContain("SurfSense 2.0.2")
expect(copied).toContain("macOS 15.4 (arm64)")
expect(copied).toContain("Electron 44.0.0")
expect(await screen.findByRole("button", { name: "Copied" })).toBeTruthy()
})
it("copies the bare version from beside it", async () => {
stubAboutBridge()
const user = userEvent.setup()
const writeText = vi
.spyOn(navigator.clipboard, "writeText")
.mockResolvedValue(undefined)
render(<AboutSettings />)
await user.click(
await screen.findByRole("button", { name: "Copy version" })
)
expect(writeText).toHaveBeenCalledWith("2.0.2")
expect(
await screen.findByRole("button", { name: "Version copied" })
).toBeTruthy()
})
})

View file

@ -0,0 +1,143 @@
import { SettingsSection } from "@/features/settings/settings-section"
import { UpdateSettings } from "@/features/updates/update-settings"
import { intl } from "@/i18n/intl"
import surfSenseLogo from "@/surfsense-logo.svg"
import {
DISCORD_URL,
DOCS_URL,
GITHUB_URL,
LICENSE_URL,
bugReportUrl,
releaseNotesUrl,
} from "./about-links"
import { CopyVersionButton } from "./copy-version-button"
import { ExternalLink } from "./external-link"
import { systemInfo } from "./system-info"
import { Troubleshooting } from "./troubleshooting"
import { useAppDetails } from "./use-app-details"
function AppIdentity({ version }: { version?: string }) {
return (
<div className="flex items-center gap-3">
<span
aria-hidden="true"
className="size-10 shrink-0 bg-foreground"
style={{
maskImage: `url(${surfSenseLogo})`,
maskPosition: "center",
maskRepeat: "no-repeat",
maskSize: "contain",
}}
/>
<div className="flex flex-col">
<span className="font-heading text-lg font-medium">SurfSense</span>
{version ? (
<div className="flex items-center gap-1">
<span className="text-sm text-muted-foreground tabular-nums">
{intl.formatMessage(
{
id: "about_identity_version_label",
defaultMessage: "Version {version}",
},
{ version }
)}
</span>
<CopyVersionButton version={version} />
</div>
) : null}
</div>
</div>
)
}
export function AboutSettings() {
const { data: details } = useAppDetails()
return (
<SettingsSection
title={intl.formatMessage({
id: "about_settings_title",
defaultMessage: "About",
})}
description={intl.formatMessage({
id: "about_settings_body",
defaultMessage:
"See which version you have, get updates, and find help.",
})}
scrollable="all"
>
<AppIdentity version={details?.version} />
<UpdateSettings />
<div className="mt-8 flex flex-col gap-1">
<h3 className="text-sm font-medium">
{intl.formatMessage({
id: "about_links_title",
defaultMessage: "Links",
})}
</h3>
<div className="mt-1 flex flex-col gap-2">
{details ? (
<ExternalLink href={releaseNotesUrl(details.version)}>
{intl.formatMessage({
id: "about_release_notes_link",
defaultMessage: "Release notes",
})}
</ExternalLink>
) : null}
<ExternalLink href={DOCS_URL}>
{intl.formatMessage({
id: "about_docs_link",
defaultMessage: "Documentation",
})}
</ExternalLink>
<ExternalLink href={GITHUB_URL}>
{intl.formatMessage({
id: "about_github_link",
defaultMessage: "Source code on GitHub",
})}
</ExternalLink>
<ExternalLink href={DISCORD_URL}>
{intl.formatMessage({
id: "about_discord_link",
defaultMessage: "Community on Discord",
})}
</ExternalLink>
{details ? (
<ExternalLink href={bugReportUrl(systemInfo(details))}>
{intl.formatMessage({
id: "about_report_issue_link",
defaultMessage: "Report an issue",
})}
</ExternalLink>
) : null}
</div>
</div>
<div className="mt-8 flex flex-col gap-1">
<h3 className="text-sm font-medium">
{intl.formatMessage({
id: "about_license_title",
defaultMessage: "License",
})}
</h3>
<p className="text-sm text-pretty text-muted-foreground">
{intl.formatMessage({
id: "about_license_body",
defaultMessage:
"SurfSense is open source under the Apache License 2.0.",
})}
</p>
<ExternalLink href={LICENSE_URL}>
{intl.formatMessage({
id: "about_license_link",
defaultMessage: "Read the license",
})}
</ExternalLink>
</div>
{details ? <Troubleshooting details={details} /> : null}
</SettingsSection>
)
}

View file

@ -0,0 +1,28 @@
import { Button } from "@/components/ui/button"
import { CheckIcon, CopyIcon } from "@/components/ui/icons"
import { intl } from "@/i18n/intl"
import { useCopyToClipboard } from "./use-copy-to-clipboard"
export function CopySystemInfoButton({ text }: { text: string }) {
const { copied, copy } = useCopyToClipboard(text)
return (
<Button type="button" variant="outline" onClick={() => void copy()}>
{copied ? (
<CheckIcon data-icon="inline-start" />
) : (
<CopyIcon data-icon="inline-start" />
)}
{copied
? intl.formatMessage({
id: "about_system_info_copied_button",
defaultMessage: "Copied",
})
: intl.formatMessage({
id: "about_system_info_copy_button",
defaultMessage: "Copy system info",
})}
</Button>
)
}

View file

@ -0,0 +1,43 @@
import { Button } from "@/components/ui/button"
import { CheckIcon, CopyIcon } from "@/components/ui/icons"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { intl } from "@/i18n/intl"
import { useCopyToClipboard } from "./use-copy-to-clipboard"
export function CopyVersionButton({ version }: { version: string }) {
const { copied, copy } = useCopyToClipboard(version)
const label = copied
? intl.formatMessage({
id: "about_identity_version_copied_aria",
defaultMessage: "Version copied",
})
: intl.formatMessage({
id: "about_identity_version_copy_aria",
defaultMessage: "Copy version",
})
return (
<Tooltip>
<TooltipTrigger
render={
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={label}
className="text-muted-foreground hover:text-foreground"
onClick={() => void copy()}
>
{copied ? <CheckIcon /> : <CopyIcon />}
</Button>
}
/>
<TooltipContent side="top">{label}</TooltipContent>
</Tooltip>
)
}

View file

@ -0,0 +1,31 @@
import type { MouseEvent, ReactNode } from "react"
import { ExternalLinkIcon } from "@/components/ui/icons"
// The desktop app refuses in-app navigation, so a click goes to the OS
// browser through the bridge; a bare browser follows the href instead.
export function ExternalLink({
href,
children,
}: {
href: string
children: ReactNode
}) {
const onClick = (event: MouseEvent<HTMLAnchorElement>) => {
if (!window.surfsense?.openExternal) return
event.preventDefault()
void window.surfsense.openExternal(href)
}
return (
<a
href={href}
target="_blank"
rel="noreferrer"
onClick={onClick}
className="inline-flex w-fit items-center gap-1.5 text-sm text-muted-foreground underline-offset-3 transition-colors hover:text-foreground hover:underline"
>
{children}
<ExternalLinkIcon aria-hidden="true" className="size-3.5" />
</a>
)
}

View file

@ -0,0 +1,10 @@
import type { AppDetails } from "@/lib/api"
// Stays English in every language: it is pasted into GitHub issues for maintainers.
export function systemInfo(details: AppDetails): string {
return [
`SurfSense ${details.version}`,
`${details.os} (${details.arch})`,
`Electron ${details.electron} · Chromium ${details.chrome} · Node ${details.node}`,
].join("\n")
}

View file

@ -0,0 +1,28 @@
import { intl } from "@/i18n/intl"
import type { AppDetails } from "@/lib/api"
import { CopySystemInfoButton } from "./copy-system-info-button"
import { systemInfo } from "./system-info"
export function Troubleshooting({ details }: { details: AppDetails }) {
return (
<div className="mt-8 flex items-start justify-between gap-8">
<div className="flex flex-col gap-1">
<h3 className="text-sm font-medium">
{intl.formatMessage({
id: "about_troubleshooting_title",
defaultMessage: "Troubleshooting",
})}
</h3>
<p className="text-sm text-pretty text-muted-foreground">
{intl.formatMessage({
id: "about_troubleshooting_body",
defaultMessage:
"Copy details about this app and your computer to include in a bug report.",
})}
</p>
</div>
<CopySystemInfoButton text={systemInfo(details)} />
</div>
)
}

View file

@ -0,0 +1,17 @@
import { useQuery } from "@tanstack/react-query"
// The preload bridge, absent in a bare browser and in the web build.
export function aboutBridge() {
return typeof window === "undefined" ? undefined : window.surfsense?.about
}
// Fixed for the life of the process, so it is read once.
export function useAppDetails() {
const about = aboutBridge()
return useQuery({
queryKey: ["app-details"],
queryFn: () => about!.details(),
enabled: about !== undefined,
staleTime: Infinity,
})
}

View file

@ -0,0 +1,21 @@
import { useEffect, useState } from "react"
// Long enough to read the confirmation, short enough that a second copy is not blocked.
const COPIED_MS = 2000
export function useCopyToClipboard(text: string) {
const [copied, setCopied] = useState(false)
useEffect(() => {
if (!copied) return
const timer = window.setTimeout(() => setCopied(false), COPIED_MS)
return () => window.clearTimeout(timer)
}, [copied])
const copy = async () => {
await navigator.clipboard.writeText(text)
setCopied(true)
}
return { copied, copy }
}

View file

@ -45,6 +45,21 @@ afterEach(() => {
})
describe("SettingsDialog", () => {
it("keeps About in its own App group, apart from the settings", async () => {
const user = userEvent.setup()
render(<SettingsHarness />)
const app = screen.getByRole("navigation", { name: "App" })
const settings = screen.getByRole("navigation", { name: "Settings" })
const about = screen.getByRole("button", { name: "About" })
expect(app.contains(about)).toBe(true)
expect(settings.contains(about)).toBe(false)
await user.click(about)
expect(screen.getByRole("heading", { name: "About" })).toBeTruthy()
})
it("changes and persists the appearance preference", async () => {
const user = userEvent.setup()

View file

@ -24,12 +24,12 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { AboutSettings } from "@/features/about/about-settings"
import { NetworkSettings } from "@/features/egress/network-settings"
import { LicenseSettings } from "@/features/license/license-settings"
import type { ImportAccepted } from "@/features/migration/api"
import { ImportBundleButton } from "@/features/migration/import-bundle"
import type { ModelSelection } from "@/features/models/selection/api"
import { UpdateSettings } from "@/features/updates/update-settings"
import { intl } from "@/i18n/intl"
import { cn } from "@/lib/utils"
@ -44,6 +44,7 @@ import { SettingsSection } from "./settings-section"
type SettingsNavItem = {
id: SettingsSectionId
group: "settings" | "app"
icon: ComponentType<{ className?: string; strokeWidth?: number }>
}
@ -56,6 +57,7 @@ export type SettingsSectionId =
| "video-models"
| "network"
| "license"
| "about"
const CLOUD_EXPORT_URL = "https://surfsense.com/sunset"
@ -183,23 +185,36 @@ function GeneralSettings({
</div>
<ImportBundleButton onImported={onImported} />
</div>
<UpdateSettings />
</SettingsSection>
)
}
// Add future settings pages here; the dialog navigation is generated from this list.
const SETTINGS_SECTIONS = [
{ id: "general", icon: Settings2Icon },
{ id: "chat-models", icon: Chat01Icon },
{ id: "image-models", icon: Image01Icon },
{ id: "image-edit-models", icon: AiImageEditIcon },
{ id: "audio-models", icon: AudioWaveformIcon },
{ id: "video-models", icon: Video01Icon },
{ id: "network", icon: ComputerEthernetIcon },
{ id: "license", icon: LicenseIcon },
{ id: "general", group: "settings", icon: Settings2Icon },
{ id: "chat-models", group: "settings", icon: Chat01Icon },
{ id: "image-models", group: "settings", icon: Image01Icon },
{ id: "image-edit-models", group: "settings", icon: AiImageEditIcon },
{ id: "audio-models", group: "settings", icon: AudioWaveformIcon },
{ id: "video-models", group: "settings", icon: Video01Icon },
{ id: "network", group: "settings", icon: ComputerEthernetIcon },
{ id: "license", group: "settings", icon: LicenseIcon },
{ id: "about", group: "app", icon: InformationCircleIcon },
] satisfies SettingsNavItem[]
const GROUP_LABELS: Record<SettingsNavItem["group"], () => string> = {
settings: () =>
intl.formatMessage({
id: "settings_nav_title",
defaultMessage: "Settings",
}),
app: () =>
intl.formatMessage({
id: "settings_nav_app_title",
defaultMessage: "App",
}),
}
const SECTION_LABELS: Record<SettingsSectionId, () => string> = {
general: () =>
intl.formatMessage({
@ -241,6 +256,11 @@ const SECTION_LABELS: Record<SettingsSectionId, () => string> = {
id: "settings_nav_license_label",
defaultMessage: "License",
}),
about: () =>
intl.formatMessage({
id: "settings_nav_about_label",
defaultMessage: "About",
}),
}
export function SettingsDialog({
@ -284,41 +304,49 @@ export function SettingsDialog({
<div className="grid h-full min-h-0 grid-cols-[184px_minmax(0,1fr)]">
<aside className="border-r bg-sidebar p-3 text-sidebar-foreground">
<p className="px-2 pt-5 pb-3 text-xs font-medium text-muted-foreground">
{intl.formatMessage({
id: "settings_nav_title",
defaultMessage: "Settings",
})}
</p>
<nav
className="flex flex-col gap-1"
aria-label={intl.formatMessage({
id: "settings_nav_aria",
defaultMessage: "Settings sections",
})}
>
{SETTINGS_SECTIONS.map((section) => {
const Icon = section.icon
const selected = section.id === activeSection.id
return (
<Button
key={section.id}
type="button"
variant="ghost"
{(Object.keys(GROUP_LABELS) as SettingsNavItem["group"][]).map(
(group, index) => (
<div key={group} className={cn(index > 0 && "mt-4")}>
<p
id={`settings-nav-${group}`}
className={cn(
"h-10 w-full justify-start rounded-lg",
selected &&
"bg-sidebar-accent text-sidebar-accent-foreground"
"px-2 pb-3 text-xs font-medium text-muted-foreground",
index === 0 ? "pt-5" : "pt-2"
)}
aria-current={selected ? "page" : undefined}
onClick={() => onSectionChange(section.id)}
>
<Icon />
{SECTION_LABELS[section.id]()}
</Button>
)
})}
</nav>
{GROUP_LABELS[group]()}
</p>
<nav
className="flex flex-col gap-0.5"
aria-labelledby={`settings-nav-${group}`}
>
{SETTINGS_SECTIONS.filter(
(section) => section.group === group
).map((section) => {
const Icon = section.icon
const selected = section.id === activeSection.id
return (
<Button
key={section.id}
type="button"
variant="ghost"
className={cn(
"h-9 w-full justify-start rounded-lg",
selected &&
"bg-sidebar-accent text-sidebar-accent-foreground"
)}
aria-current={selected ? "page" : undefined}
onClick={() => onSectionChange(section.id)}
>
<Icon />
{SECTION_LABELS[section.id]()}
</Button>
)
})}
</nav>
</div>
)
)}
</aside>
<section className="min-h-0 min-w-0 overflow-hidden bg-popover text-popover-foreground">
@ -347,6 +375,7 @@ export function SettingsDialog({
) : null}
{activeSection.id === "network" ? <NetworkSettings /> : null}
{activeSection.id === "license" ? <LicenseSettings /> : null}
{activeSection.id === "about" ? <AboutSettings /> : null}
</section>
</div>
</DialogContent>

View file

@ -77,6 +77,12 @@ describe("flashcards viewer", () => {
render(<FlashcardsViewer artifact={artifact} actionsContainer={null} />)
expect(await screen.findByText("Arrival year?")).toBeTruthy()
// Card 1 of 2: assistive tech hears how far through the deck this is.
expect(
screen
.getByRole("progressbar", { name: "Deck progress" })
.getAttribute("aria-valuenow")
).toBe("50")
// Both faces mount for the flip transition; only the front is exposed
// to assistive tech until revealed.
expect(

View file

@ -357,8 +357,7 @@ function FlashcardRunner({
<Progress
value={progressValue}
className="h-1.5"
role="progressbar"
className="*:data-[slot=progress-track]:h-1.5"
aria-label={intl.formatMessage({
id: "studio_flashcards_viewer_progress_aria",
defaultMessage: "Deck progress",

View file

@ -261,8 +261,7 @@ function QuizRunner({
state.active_question_indices.length) *
100
}
className="mb-8 h-1.5"
role="progressbar"
className="mb-8 *:data-[slot=progress-track]:h-1.5"
aria-label={intl.formatMessage({
id: "studio_quiz_viewer_progress_aria",
defaultMessage: "Quiz progress",

View file

@ -210,10 +210,10 @@ export function QuizScoreScreen({
/>
</fieldset>
<Tabs value={category} onValueChange={selectCategory} className="mt-4">
<TabsList className="h-auto w-auto justify-center gap-0.5 bg-transparent p-0 sm:gap-2">
<TabsList className="w-auto justify-center gap-0.5 bg-transparent p-0 group-data-horizontal/tabs:h-auto sm:gap-2">
<TabsTrigger
value="correct"
className="flex-none items-center gap-0.5 rounded-full border border-transparent px-1.5 py-1 text-xs sm:gap-1.5 sm:px-3 sm:py-1.5 sm:text-sm data-active:border-transparent data-active:bg-secondary data-active:text-secondary-foreground"
className="h-auto flex-none items-center gap-0.5 rounded-full border border-transparent px-1.5 py-1 text-xs sm:gap-1.5 sm:px-3 sm:py-1.5 sm:text-sm data-active:border-transparent data-active:bg-secondary data-active:text-secondary-foreground dark:data-active:border-transparent dark:data-active:bg-secondary dark:data-active:text-secondary-foreground"
>
<span className="size-2 shrink-0 rounded-full bg-emerald-600 sm:size-2.5" />
{intl.formatMessage(
@ -226,7 +226,7 @@ export function QuizScoreScreen({
</TabsTrigger>
<TabsTrigger
value="missed"
className="flex-none items-center gap-0.5 rounded-full border border-transparent px-1.5 py-1 text-xs sm:gap-1.5 sm:px-3 sm:py-1.5 sm:text-sm data-active:border-transparent data-active:bg-secondary data-active:text-secondary-foreground"
className="h-auto flex-none items-center gap-0.5 rounded-full border border-transparent px-1.5 py-1 text-xs sm:gap-1.5 sm:px-3 sm:py-1.5 sm:text-sm data-active:border-transparent data-active:bg-secondary data-active:text-secondary-foreground dark:data-active:border-transparent dark:data-active:bg-secondary dark:data-active:text-secondary-foreground"
>
<span className="size-2 shrink-0 rounded-full bg-red-600 sm:size-2.5" />
{intl.formatMessage(
@ -241,7 +241,7 @@ export function QuizScoreScreen({
</TabsTrigger>
<TabsTrigger
value="skipped"
className="flex-none items-center gap-0.5 rounded-full border border-transparent px-1.5 py-1 text-xs sm:gap-1.5 sm:px-3 sm:py-1.5 sm:text-sm data-active:border-transparent data-active:bg-secondary data-active:text-secondary-foreground"
className="h-auto flex-none items-center gap-0.5 rounded-full border border-transparent px-1.5 py-1 text-xs sm:gap-1.5 sm:px-3 sm:py-1.5 sm:text-sm data-active:border-transparent data-active:bg-secondary data-active:text-secondary-foreground dark:data-active:border-transparent dark:data-active:bg-secondary dark:data-active:text-secondary-foreground"
>
<span className="size-2 shrink-0 rounded-full bg-muted-foreground sm:size-2.5" />
{intl.formatMessage(

View file

@ -86,7 +86,7 @@ export function UpdateSettings() {
{intl.formatMessage({
id: "updates_settings_body",
defaultMessage:
"Free updates from GitHub Releases. SurfSense stays silent until you allow App updates under Network, which also enables the launch check.",
"Free updates from GitHub. Once you allow update checks, SurfSense also checks each time it starts.",
})}
</p>
{state.status === "error" ? (

View file

@ -27,6 +27,9 @@ declare global {
symbolColor: string
}) => Promise<void>
openExternal?: (url: string) => Promise<void>
about?: {
details: () => Promise<AppDetails>
}
// Mirrors electron/src/preload/index.ts; main resolves and owns the locale.
locale?: {
get: () => string
@ -45,6 +48,16 @@ declare global {
}
}
// Mirrors electron/src/main/about/app-details.ts.
export type AppDetails = {
version: string
electron: string
chrome: string
node: string
os: string
arch: string
}
// Mirrors electron/src/main/updater.ts.
export type UpdatePrefs = { automatic: boolean; lastCheckedAt?: string }
export type UpdateState =

View file

@ -1,4 +1,22 @@
{
"about_discord_link": "Community auf Discord",
"about_docs_link": "Dokumentation",
"about_github_link": "Quellcode auf GitHub",
"about_identity_version_copied_aria": "Version kopiert",
"about_identity_version_copy_aria": "Version kopieren",
"about_identity_version_label": "Version {version}",
"about_license_body": "SurfSense ist Open Source unter der Apache License 2.0.",
"about_license_link": "Lizenz lesen",
"about_license_title": "Lizenz",
"about_links_title": "Links",
"about_release_notes_link": "Versionshinweise",
"about_report_issue_link": "Problem melden",
"about_settings_body": "Sieh nach, welche Version du hast, hol dir Updates und finde Hilfe.",
"about_settings_title": "Über",
"about_system_info_copied_button": "Kopiert",
"about_system_info_copy_button": "Systeminfos kopieren",
"about_troubleshooting_body": "Kopiere Angaben zu dieser App und deinem Computer für einen Fehlerbericht.",
"about_troubleshooting_title": "Fehlerbehebung",
"app_api_insufficient_space_error": "{message} ({required, number, ::unit/gigabyte .#} benötigt, {available, number, ::unit/gigabyte .#} verfügbar)",
"app_api_request_failed_error": "Anfrage fehlgeschlagen mit Status {status}",
"app_bootstrap_loader_aria": "SurfSense wird gestartet",
@ -478,7 +496,8 @@
"settings_models_empty_download_body": "Lade eines herunter, um es auf diesem Computer auszuführen, oder nutze eines von einem Server, den du bereits betreibst.",
"settings_models_empty_server_body": "Nutze eines von einem Server, den du bereits betreibst.",
"settings_models_load_error": "Modelleinstellungen konnten nicht geladen werden",
"settings_nav_aria": "Bereiche der Einstellungen",
"settings_nav_about_label": "Über",
"settings_nav_app_title": "App",
"settings_nav_audio_label": "Audio",
"settings_nav_chat_label": "Texterzeugung",
"settings_nav_general_label": "Allgemein",
@ -741,7 +760,7 @@
"studio_xlsx_viewer_tabs_aria": "Arbeitsblätter",
"studio_xlsx_viewer_truncated_body": "{count, plural, one {# Zeile wird angezeigt.} other {Die ersten # Zeilen werden angezeigt.}} Lade die Datei herunter, um die ganze Arbeitsmappe zu sehen.",
"studio_xlsx_viewer_unreadable_error": "Diese Arbeitsmappe konnte nicht geöffnet werden. Lade sie herunter, um sie zu öffnen.",
"updates_settings_body": "Kostenlose Updates über GitHub Releases. SurfSense bleibt still, bis du App-Updates unter Netzwerk erlaubst. Damit wird auch die Prüfung beim Start aktiviert.",
"updates_settings_body": "Kostenlose Updates über GitHub. Sobald du Update-Prüfungen erlaubst, prüft SurfSense auch bei jedem Start.",
"updates_settings_check_button": "Jetzt prüfen",
"updates_settings_check_error": "Suche nach Updates fehlgeschlagen: {message}",
"updates_settings_checking_status": "Wird geprüft …",

View file

@ -1,4 +1,22 @@
{
"about_discord_link": "Community on Discord",
"about_docs_link": "Documentation",
"about_github_link": "Source code on GitHub",
"about_identity_version_copied_aria": "Version copied",
"about_identity_version_copy_aria": "Copy version",
"about_identity_version_label": "Version {version}",
"about_license_body": "SurfSense is open source under the Apache License 2.0.",
"about_license_link": "Read the license",
"about_license_title": "License",
"about_links_title": "Links",
"about_release_notes_link": "Release notes",
"about_report_issue_link": "Report an issue",
"about_settings_body": "See which version you have, get updates, and find help.",
"about_settings_title": "About",
"about_system_info_copied_button": "Copied",
"about_system_info_copy_button": "Copy system info",
"about_troubleshooting_body": "Copy details about this app and your computer to include in a bug report.",
"about_troubleshooting_title": "Troubleshooting",
"app_api_insufficient_space_error": "{message} ({required, number, ::unit/gigabyte .#} required, {available, number, ::unit/gigabyte .#} available)",
"app_api_request_failed_error": "Request failed with status {status}",
"app_bootstrap_loader_aria": "Starting SurfSense",
@ -478,7 +496,8 @@
"settings_models_empty_download_body": "Download one to run on this computer, or use one from a server you already run.",
"settings_models_empty_server_body": "Use one from a server you already run.",
"settings_models_load_error": "Could not load model settings",
"settings_nav_aria": "Settings sections",
"settings_nav_about_label": "About",
"settings_nav_app_title": "App",
"settings_nav_audio_label": "Audio",
"settings_nav_chat_label": "Text gen",
"settings_nav_general_label": "General",
@ -741,7 +760,7 @@
"studio_xlsx_viewer_tabs_aria": "Worksheets",
"studio_xlsx_viewer_truncated_body": "Showing the first {count, plural, one {# row} other {# rows}}. Download the file for the full workbook.",
"studio_xlsx_viewer_unreadable_error": "This workbook could not be opened. Download it to open it.",
"updates_settings_body": "Free updates from GitHub Releases. SurfSense stays silent until you allow App updates under Network, which also enables the launch check.",
"updates_settings_body": "Free updates from GitHub. Once you allow update checks, SurfSense also checks each time it starts.",
"updates_settings_check_button": "Check now",
"updates_settings_check_error": "Could not check for updates: {message}",
"updates_settings_checking_status": "Checking…",

View file

@ -1,4 +1,22 @@
{
"about_discord_link": "Comunidad en Discord",
"about_docs_link": "Documentación",
"about_github_link": "Código fuente en GitHub",
"about_identity_version_copied_aria": "Versión copiada",
"about_identity_version_copy_aria": "Copiar versión",
"about_identity_version_label": "Versión {version}",
"about_license_body": "SurfSense es de código abierto bajo la Apache License 2.0.",
"about_license_link": "Leer la licencia",
"about_license_title": "Licencia",
"about_links_title": "Enlaces",
"about_release_notes_link": "Notas de la versión",
"about_report_issue_link": "Informar de un problema",
"about_settings_body": "Consulta qué versión tienes, obtén actualizaciones y encuentra ayuda.",
"about_settings_title": "Acerca de",
"about_system_info_copied_button": "Copiado",
"about_system_info_copy_button": "Copiar info del sistema",
"about_troubleshooting_body": "Copia los datos de esta app y de tu equipo para incluirlos en un informe de error.",
"about_troubleshooting_title": "Solución de problemas",
"app_api_insufficient_space_error": "{message} ({required, number, ::unit/gigabyte .#} necesarios, {available, number, ::unit/gigabyte .#} disponibles)",
"app_api_request_failed_error": "La solicitud falló con el estado {status}",
"app_bootstrap_loader_aria": "Iniciando SurfSense",
@ -478,7 +496,8 @@
"settings_models_empty_download_body": "Descarga uno para ejecutarlo en este equipo o usa uno de un servidor que ya tengas en marcha.",
"settings_models_empty_server_body": "Usa uno de un servidor que ya tengas en marcha.",
"settings_models_load_error": "No se pudieron cargar los ajustes del modelo",
"settings_nav_aria": "Secciones de ajustes",
"settings_nav_about_label": "Acerca de",
"settings_nav_app_title": "App",
"settings_nav_audio_label": "Audio",
"settings_nav_chat_label": "Gen. de texto",
"settings_nav_general_label": "General",
@ -741,7 +760,7 @@
"studio_xlsx_viewer_tabs_aria": "Hojas",
"studio_xlsx_viewer_truncated_body": "{count, plural, one {Se muestra # fila.} many {Se muestran las primeras # filas.} other {Se muestran las primeras # filas.}} Descarga el archivo para ver el libro completo.",
"studio_xlsx_viewer_unreadable_error": "No se pudo abrir este libro. Descárgalo para abrirlo.",
"updates_settings_body": "Actualizaciones gratuitas desde GitHub Releases. SurfSense no dice nada hasta que permitas «Actualizaciones de la app» en Red, lo que también activa la comprobación al iniciar.",
"updates_settings_body": "Actualizaciones gratuitas desde GitHub. Cuando permitas la búsqueda de actualizaciones, SurfSense también buscará cada vez que se inicie.",
"updates_settings_check_button": "Comprobar ahora",
"updates_settings_check_error": "No se pudo buscar actualizaciones: {message}",
"updates_settings_checking_status": "Comprobando…",

View file

@ -1,4 +1,22 @@
{
"about_discord_link": "Communauté sur Discord",
"about_docs_link": "Documentation",
"about_github_link": "Code source sur GitHub",
"about_identity_version_copied_aria": "Version copiée",
"about_identity_version_copy_aria": "Copier la version",
"about_identity_version_label": "Version {version}",
"about_license_body": "SurfSense est open source sous licence Apache License 2.0.",
"about_license_link": "Lire la licence",
"about_license_title": "Licence",
"about_links_title": "Liens",
"about_release_notes_link": "Notes de version",
"about_report_issue_link": "Signaler un problème",
"about_settings_body": "Voyez quelle version vous utilisez, obtenez les mises à jour et trouvez de l’aide.",
"about_settings_title": "À propos",
"about_system_info_copied_button": "Copié",
"about_system_info_copy_button": "Copier les infos système",
"about_troubleshooting_body": "Copiez les informations sur cette application et votre ordinateur pour un rapport de bug.",
"about_troubleshooting_title": "Dépannage",
"app_api_insufficient_space_error": "{message} (requis : {required, number, ::unit/gigabyte .#}, disponible : {available, number, ::unit/gigabyte .#})",
"app_api_request_failed_error": "La requête a échoué avec le statut {status}",
"app_bootstrap_loader_aria": "Démarrage de SurfSense",
@ -478,7 +496,8 @@
"settings_models_empty_download_body": "Téléchargez-en un pour l’exécuter sur cet ordinateur ou utilisez-en un depuis un serveur que vous exploitez déjà.",
"settings_models_empty_server_body": "Utilisez-en un depuis un serveur que vous exploitez déjà.",
"settings_models_load_error": "Impossible de charger les paramètres des modèles",
"settings_nav_aria": "Sections des paramètres",
"settings_nav_about_label": "À propos",
"settings_nav_app_title": "Application",
"settings_nav_audio_label": "Audio",
"settings_nav_chat_label": "Gén. de texte",
"settings_nav_general_label": "Général",
@ -741,7 +760,7 @@
"studio_xlsx_viewer_tabs_aria": "Feuilles",
"studio_xlsx_viewer_truncated_body": "{count, plural, one {# ligne affichée.} many {Les # premières lignes sont affichées.} other {Les # premières lignes sont affichées.}} Téléchargez le fichier pour obtenir le classeur complet.",
"studio_xlsx_viewer_unreadable_error": "Ce classeur n’a pas pu être ouvert. Téléchargez-le pour l’ouvrir.",
"updates_settings_body": "Mises à jour gratuites via GitHub Releases. SurfSense reste silencieux jusqu’à ce que vous autorisiez les mises à jour de l’application sous Réseau, ce qui active aussi la vérification au démarrage.",
"updates_settings_body": "Mises à jour gratuites via GitHub. Une fois la recherche de mises à jour autorisée, SurfSense vérifie aussi à chaque démarrage.",
"updates_settings_check_button": "Vérifier maintenant",
"updates_settings_check_error": "Impossible de rechercher des mises à jour : {message}",
"updates_settings_checking_status": "Vérification…",

View file

@ -1,4 +1,22 @@
{
"about_discord_link": "Discord पर कम्युनिटी",
"about_docs_link": "डॉक्यूमेंटेशन",
"about_github_link": "GitHub पर सोर्स कोड",
"about_identity_version_copied_aria": "वर्शन कॉपी हो गया",
"about_identity_version_copy_aria": "वर्शन कॉपी करें",
"about_identity_version_label": "वर्शन {version}",
"about_license_body": "SurfSense, Apache License 2.0 के तहत ओपन सोर्स है।",
"about_license_link": "लाइसेंस पढ़ें",
"about_license_title": "लाइसेंस",
"about_links_title": "लिंक",
"about_release_notes_link": "रिलीज़ नोट्स",
"about_report_issue_link": "समस्या रिपोर्ट करें",
"about_settings_body": "देखें कि आपके पास कौन-सा वर्शन है, अपडेट पाएं और मदद लें।",
"about_settings_title": "जानकारी",
"about_system_info_copied_button": "कॉपी हो गया",
"about_system_info_copy_button": "सिस्टम जानकारी कॉपी करें",
"about_troubleshooting_body": "बग रिपोर्ट में जोड़ने के लिए इस ऐप और आपके कंप्यूटर की जानकारी कॉपी करें।",
"about_troubleshooting_title": "समस्या निवारण",
"app_api_insufficient_space_error": "{message} ({required, number, ::unit/gigabyte .#} चाहिए, {available, number, ::unit/gigabyte .#} उपलब्ध)",
"app_api_request_failed_error": "अनुरोध विफल रहा, स्टेटस {status}",
"app_bootstrap_loader_aria": "SurfSense शुरू हो रहा है",
@ -478,7 +496,8 @@
"settings_models_empty_download_body": "इस कंप्यूटर पर चलाने के लिए कोई डाउनलोड करें, या पहले से चल रहे किसी सर्वर से इस्तेमाल करें।",
"settings_models_empty_server_body": "पहले से चल रहे किसी सर्वर से इस्तेमाल करें।",
"settings_models_load_error": "मॉडल सेटिंग्स लोड नहीं हो सकीं",
"settings_nav_aria": "सेटिंग्स के अनुभाग",
"settings_nav_about_label": "जानकारी",
"settings_nav_app_title": "ऐप",
"settings_nav_audio_label": "ऑडियो",
"settings_nav_chat_label": "टेक्स्ट",
"settings_nav_general_label": "सामान्य",
@ -741,7 +760,7 @@
"studio_xlsx_viewer_tabs_aria": "वर्कशीट",
"studio_xlsx_viewer_truncated_body": "{count, plural, one {पहली # पंक्ति दिखाई जा रही है} other {पहली # पंक्तियां दिखाई जा रही हैं}}। पूरी वर्कबुक के लिए फ़ाइल डाउनलोड करें।",
"studio_xlsx_viewer_unreadable_error": "यह वर्कबुक खोली नहीं जा सकी। इसे खोलने के लिए डाउनलोड करें।",
"updates_settings_body": "GitHub Releases से मुफ़्त अपडेट। जब तक आप नेटवर्क में ऐप अपडेट की अनुमति नहीं देते, SurfSense चुप रहता है; अनुमति देने पर शुरू होते समय होने वाली जांच भी चालू हो जाती है।",
"updates_settings_body": "GitHub से मुफ़्त अपडेट। अपडेट की जांच की अनुमति देने के बाद, SurfSense हर बार शुरू होने पर भी जांच करता है।",
"updates_settings_check_button": "अभी जांचें",
"updates_settings_check_error": "अपडेट की जांच नहीं हो सकी: {message}",
"updates_settings_checking_status": "जांच हो रही है…",

View file

@ -1,4 +1,22 @@
{
"about_discord_link": "Discordのコミュニティ",
"about_docs_link": "ドキュメント",
"about_github_link": "GitHubのソースコード",
"about_identity_version_copied_aria": "バージョンをコピーしました",
"about_identity_version_copy_aria": "バージョンをコピー",
"about_identity_version_label": "バージョン {version}",
"about_license_body": "SurfSenseはApache License 2.0のもとで公開されているオープンソースです。",
"about_license_link": "ライセンスを読む",
"about_license_title": "ライセンス",
"about_links_title": "リンク",
"about_release_notes_link": "リリースノート",
"about_report_issue_link": "問題を報告",
"about_settings_body": "バージョンの確認、アップデートの入手、ヘルプの参照ができます。",
"about_settings_title": "このアプリについて",
"about_system_info_copied_button": "コピーしました",
"about_system_info_copy_button": "システム情報をコピー",
"about_troubleshooting_body": "バグ報告に添えるため、このアプリとコンピューターの情報をコピーします。",
"about_troubleshooting_title": "トラブルシューティング",
"app_api_insufficient_space_error": "{message}(必要: {required, number, ::unit/gigabyte .#}、空き: {available, number, ::unit/gigabyte .#})",
"app_api_request_failed_error": "リクエストが失敗しました(ステータス {status})",
"app_bootstrap_loader_aria": "SurfSenseを起動しています",
@ -478,7 +496,8 @@
"settings_models_empty_download_body": "ダウンロードしてこのコンピューターで実行するか、すでに運用しているサーバー上のモデルを使います。",
"settings_models_empty_server_body": "すでに運用しているサーバー上のモデルを使います。",
"settings_models_load_error": "モデル設定を読み込めませんでした",
"settings_nav_aria": "設定のセクション",
"settings_nav_about_label": "情報",
"settings_nav_app_title": "アプリ",
"settings_nav_audio_label": "音声",
"settings_nav_chat_label": "テキスト生成",
"settings_nav_general_label": "一般",
@ -741,7 +760,7 @@
"studio_xlsx_viewer_tabs_aria": "ワークシート",
"studio_xlsx_viewer_truncated_body": "{count, plural, other {最初の#行を表示しています。}}ブック全体を見るにはファイルをダウンロードしてください。",
"studio_xlsx_viewer_unreadable_error": "このブックを開けませんでした。ダウンロードして開いてください。",
"updates_settings_body": "GitHub Releasesから無料でアップデートできます。ネットワークでアプリのアップデートを許可するまで、SurfSenseは通信しません。許可すると起動時の確認も有効になります。",
"updates_settings_body": "GitHubから無料でアップデートできます。アップデートの確認を許可すると、SurfSenseは起動するたびにも確認します。",
"updates_settings_check_button": "今すぐ確認",
"updates_settings_check_error": "アップデートを確認できませんでした: {message}",
"updates_settings_checking_status": "確認中…",

View file

@ -1,4 +1,22 @@
{
"about_discord_link": "Discord 커뮤니티",
"about_docs_link": "문서",
"about_github_link": "GitHub 소스 코드",
"about_identity_version_copied_aria": "버전 복사됨",
"about_identity_version_copy_aria": "버전 복사",
"about_identity_version_label": "버전 {version}",
"about_license_body": "SurfSense는 Apache License 2.0에 따른 오픈소스입니다.",
"about_license_link": "라이선스 읽기",
"about_license_title": "라이선스",
"about_links_title": "링크",
"about_release_notes_link": "릴리스 노트",
"about_report_issue_link": "문제 신고",
"about_settings_body": "사용 중인 버전을 확인하고, 업데이트를 받고, 도움말을 찾아보세요.",
"about_settings_title": "정보",
"about_system_info_copied_button": "복사됨",
"about_system_info_copy_button": "시스템 정보 복사",
"about_troubleshooting_body": "버그 신고에 포함할 이 앱과 컴퓨터의 정보를 복사하세요.",
"about_troubleshooting_title": "문제 해결",
"app_api_insufficient_space_error": "{message} ({required, number, ::unit/gigabyte .#} 필요, {available, number, ::unit/gigabyte .#} 사용 가능)",
"app_api_request_failed_error": "요청이 실패했습니다 (상태 {status})",
"app_bootstrap_loader_aria": "SurfSense를 시작하는 중",
@ -478,7 +496,8 @@
"settings_models_empty_download_body": "다운로드해 이 컴퓨터에서 실행하거나, 이미 운영 중인 서버의 모델을 사용하세요.",
"settings_models_empty_server_body": "이미 운영 중인 서버의 모델을 사용하세요.",
"settings_models_load_error": "모델 설정을 불러오지 못했습니다",
"settings_nav_aria": "설정 섹션",
"settings_nav_about_label": "정보",
"settings_nav_app_title": "앱",
"settings_nav_audio_label": "음성",
"settings_nav_chat_label": "텍스트 생성",
"settings_nav_general_label": "일반",
@ -741,7 +760,7 @@
"studio_xlsx_viewer_tabs_aria": "워크시트",
"studio_xlsx_viewer_truncated_body": "{count, plural, other {처음 #행}}을 표시하고 있습니다. 전체 통합 문서를 보려면 파일을 다운로드하세요.",
"studio_xlsx_viewer_unreadable_error": "이 통합 문서를 열지 못했습니다. 다운로드해서 여세요.",
"updates_settings_body": "GitHub Releases에서 무료로 업데이트할 수 있습니다. 네트워크에서 앱 업데이트를 허용하기 전까지 SurfSense는 아무것도 전송하지 않으며, 허용하면 시작 시 확인도 켜집니다.",
"updates_settings_body": "GitHub에서 무료로 업데이트할 수 있습니다. 업데이트 확인을 허용하면 SurfSense는 시작할 때마다 확인도 합니다.",
"updates_settings_check_button": "지금 확인",
"updates_settings_check_error": "업데이트를 확인하지 못했습니다: {message}",
"updates_settings_checking_status": "확인 중…",

View file

@ -1,4 +1,22 @@
{
"about_discord_link": "Comunidade no Discord",
"about_docs_link": "Documentação",
"about_github_link": "Código-fonte no GitHub",
"about_identity_version_copied_aria": "Versão copiada",
"about_identity_version_copy_aria": "Copiar versão",
"about_identity_version_label": "Versão {version}",
"about_license_body": "O SurfSense é de código aberto sob a Apache License 2.0.",
"about_license_link": "Ler a licença",
"about_license_title": "Licença",
"about_links_title": "Links",
"about_release_notes_link": "Notas da versão",
"about_report_issue_link": "Relatar um problema",
"about_settings_body": "Veja qual versão você tem, receba atualizações e encontre ajuda.",
"about_settings_title": "Sobre",
"about_system_info_copied_button": "Copiado",
"about_system_info_copy_button": "Copiar info do sistema",
"about_troubleshooting_body": "Copie os dados deste app e do seu computador para incluir em um relatório de bug.",
"about_troubleshooting_title": "Solução de problemas",
"app_api_insufficient_space_error": "{message} (necessário: {required, number, ::unit/gigabyte .#}, disponível: {available, number, ::unit/gigabyte .#})",
"app_api_request_failed_error": "A solicitação falhou com o status {status}",
"app_bootstrap_loader_aria": "Iniciando o SurfSense",
@ -478,7 +496,8 @@
"settings_models_empty_download_body": "Baixe um para executar neste computador ou use um de um servidor que você já mantém.",
"settings_models_empty_server_body": "Use um de um servidor que você já mantém.",
"settings_models_load_error": "Não foi possível carregar as configurações de modelos",
"settings_nav_aria": "Seções das configurações",
"settings_nav_about_label": "Sobre",
"settings_nav_app_title": "App",
"settings_nav_audio_label": "Áudio",
"settings_nav_chat_label": "Gerar texto",
"settings_nav_general_label": "Geral",
@ -741,7 +760,7 @@
"studio_xlsx_viewer_tabs_aria": "Planilhas",
"studio_xlsx_viewer_truncated_body": "{count, plural, one {Mostrando # linha.} many {Mostrando as primeiras # linhas.} other {Mostrando as primeiras # linhas.}} Baixe o arquivo para ver a pasta de trabalho completa.",
"studio_xlsx_viewer_unreadable_error": "Não foi possível abrir esta pasta de trabalho. Baixe-a para abrir.",
"updates_settings_body": "Atualizações gratuitas pelo GitHub Releases. O SurfSense não faz nenhuma conexão até você permitir Atualizações do app em Rede, o que também ativa a verificação na inicialização.",
"updates_settings_body": "Atualizações gratuitas pelo GitHub. Depois que você permitir a verificação de atualizações, o SurfSense também verifica sempre que for iniciado.",
"updates_settings_check_button": "Verificar agora",
"updates_settings_check_error": "Não foi possível verificar atualizações: {message}",
"updates_settings_checking_status": "Verificando…",

View file

@ -1,4 +1,22 @@
{
"about_discord_link": "Сообщество в Discord",
"about_docs_link": "Документация",
"about_github_link": "Исходный код на GitHub",
"about_identity_version_copied_aria": "Версия скопирована",
"about_identity_version_copy_aria": "Скопировать версию",
"about_identity_version_label": "Версия {version}",
"about_license_body": "SurfSense — программа с открытым исходным кодом под лицензией Apache License 2.0.",
"about_license_link": "Прочитать лицензию",
"about_license_title": "Лицензия",
"about_links_title": "Ссылки",
"about_release_notes_link": "Примечания к выпуску",
"about_report_issue_link": "Сообщить о проблеме",
"about_settings_body": "Узнайте, какая у вас версия, получите обновления и найдите помощь.",
"about_settings_title": "О приложении",
"about_system_info_copied_button": "Скопировано",
"about_system_info_copy_button": "Скопировать сведения о системе",
"about_troubleshooting_body": "Скопируйте сведения об этом приложении и вашем компьютере для отчёта об ошибке.",
"about_troubleshooting_title": "Устранение неполадок",
"app_api_insufficient_space_error": "{message} (требуется {required, number, ::unit/gigabyte .#}, доступно {available, number, ::unit/gigabyte .#})",
"app_api_request_failed_error": "Запрос не выполнен, статус {status}",
"app_bootstrap_loader_aria": "Запуск SurfSense",
@ -478,7 +496,8 @@
"settings_models_empty_download_body": "Скачайте модель, чтобы запустить её на этом компьютере, или используйте модель с сервера, который у вас уже работает.",
"settings_models_empty_server_body": "Используйте модель с сервера, который у вас уже работает.",
"settings_models_load_error": "Не удалось загрузить настройки моделей",
"settings_nav_aria": "Разделы настроек",
"settings_nav_about_label": "О приложении",
"settings_nav_app_title": "Приложение",
"settings_nav_audio_label": "Аудио",
"settings_nav_chat_label": "Текст",
"settings_nav_general_label": "Общие",
@ -741,7 +760,7 @@
"studio_xlsx_viewer_tabs_aria": "Листы",
"studio_xlsx_viewer_truncated_body": "{count, plural, one {Показана # строка} few {Показаны первые # строки} many {Показаны первые # строк} other {Показаны первые # строки}}. Скачайте файл, чтобы увидеть книгу целиком.",
"studio_xlsx_viewer_unreadable_error": "Не удалось открыть эту книгу. Скачайте её, чтобы открыть.",
"updates_settings_body": "Бесплатные обновления через GitHub Releases. SurfSense молчит, пока вы не разрешите «Обновления приложения» в разделе «Сеть»; это также включает проверку при запуске.",
"updates_settings_body": "Бесплатные обновления через GitHub. Когда вы разрешите проверку обновлений, SurfSense будет проверять их и при каждом запуске.",
"updates_settings_check_button": "Проверить сейчас",
"updates_settings_check_error": "Не удалось проверить обновления: {message}",
"updates_settings_checking_status": "Проверка…",

View file

@ -1,4 +1,22 @@
{
"about_discord_link": "Discord 社区",
"about_docs_link": "文档",
"about_github_link": "GitHub 源代码",
"about_identity_version_copied_aria": "已复制版本号",
"about_identity_version_copy_aria": "复制版本号",
"about_identity_version_label": "版本 {version}",
"about_license_body": "SurfSense 基于 Apache License 2.0 开源。",
"about_license_link": "阅读许可证",
"about_license_title": "许可证",
"about_links_title": "链接",
"about_release_notes_link": "发行说明",
"about_report_issue_link": "报告问题",
"about_settings_body": "查看你的版本、获取更新并寻求帮助。",
"about_settings_title": "关于",
"about_system_info_copied_button": "已复制",
"about_system_info_copy_button": "复制系统信息",
"about_troubleshooting_body": "复制此应用和你的电脑的信息以附在错误报告中。",
"about_troubleshooting_title": "故障排除",
"app_api_insufficient_space_error": "{message}(需要 {required, number, ::unit/gigabyte .#},可用 {available, number, ::unit/gigabyte .#})",
"app_api_request_failed_error": "请求失败,状态码 {status}",
"app_bootstrap_loader_aria": "正在启动 SurfSense",
@ -478,7 +496,8 @@
"settings_models_empty_download_body": "下载一个在这台电脑上运行,也可以使用你已经在运行的服务器上的模型。",
"settings_models_empty_server_body": "使用你已经在运行的服务器上的模型。",
"settings_models_load_error": "无法加载模型设置",
"settings_nav_aria": "设置分区",
"settings_nav_about_label": "关于",
"settings_nav_app_title": "应用",
"settings_nav_audio_label": "语音",
"settings_nav_chat_label": "文本生成",
"settings_nav_general_label": "通用",
@ -741,7 +760,7 @@
"studio_xlsx_viewer_tabs_aria": "工作表",
"studio_xlsx_viewer_truncated_body": "仅显示前 {count, plural, other {# 行}}。下载文件可查看完整工作簿。",
"studio_xlsx_viewer_unreadable_error": "无法打开这个工作簿。请下载后打开。",
"updates_settings_body": "来自 GitHub Releases 的免费更新。在你于“网络”中允许“应用更新”之前,SurfSense 不会发起任何通信;允许后也会开启启动时检查。",
"updates_settings_body": "来自 GitHub 的免费更新。允许检查更新后,SurfSense 每次启动时也会检查。",
"updates_settings_check_button": "立即检查",
"updates_settings_check_error": "无法检查更新:{message}",
"updates_settings_checking_status": "检查中…",