mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-10-19 15:44:27 +00:00
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
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:
commit
92fd5a225a
45 changed files with 1136 additions and 127 deletions
24
docs/architecture/about.md
Normal file
24
docs/architecture/about.md
Normal 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.
|
||||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
19
surfsense_local/electron/src/main/about/about-ipc.ts
Normal file
19
surfsense_local/electron/src/main/about/about-ipc.ts
Normal 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,
|
||||
})
|
||||
)
|
||||
}
|
||||
44
surfsense_local/electron/src/main/about/app-details.test.ts
Normal file
44
surfsense_local/electron/src/main/about/app-details.test.ts
Normal 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"
|
||||
)
|
||||
})
|
||||
35
surfsense_local/electron/src/main/about/app-details.ts
Normal file
35
surfsense_local/electron/src/main/about/app-details.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
49
surfsense_local/electron/src/main/external-url.test.ts
Normal file
49
surfsense_local/electron/src/main/external-url.test.ts
Normal 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)
|
||||
})
|
||||
28
surfsense_local/electron/src/main/external-url.ts
Normal file
28
surfsense_local/electron/src/main/external-url.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
17
surfsense_local/frontend/src/components/ui/textarea.tsx
Normal file
17
surfsense_local/frontend/src/components/ui/textarea.tsx
Normal 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 }
|
||||
20
surfsense_local/frontend/src/features/about/about-links.ts
Normal file
20
surfsense_local/frontend/src/features/about/about-links.ts
Normal 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}`
|
||||
}
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
143
surfsense_local/frontend/src/features/about/about-settings.tsx
Normal file
143
surfsense_local/frontend/src/features/about/about-settings.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
10
surfsense_local/frontend/src/features/about/system-info.ts
Normal file
10
surfsense_local/frontend/src/features/about/system-info.ts
Normal 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")
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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" ? (
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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 …",
|
||||
|
|
|
|||
|
|
@ -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…",
|
||||
|
|
|
|||
|
|
@ -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…",
|
||||
|
|
|
|||
|
|
@ -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…",
|
||||
|
|
|
|||
|
|
@ -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": "जांच हो रही है…",
|
||||
|
|
|
|||
|
|
@ -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": "確認中…",
|
||||
|
|
|
|||
|
|
@ -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": "확인 중…",
|
||||
|
|
|
|||
|
|
@ -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…",
|
||||
|
|
|
|||
|
|
@ -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": "Проверка…",
|
||||
|
|
|
|||
|
|
@ -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": "检查中…",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue