diff --git a/apps/client-standalone/src/lightweight/sql_provider.ts b/apps/client-standalone/src/lightweight/sql_provider.ts index 972b58db0d..7a3c8ff1ec 100644 --- a/apps/client-standalone/src/lightweight/sql_provider.ts +++ b/apps/client-standalone/src/lightweight/sql_provider.ts @@ -1,4 +1,4 @@ -import { type BindableValue, default as sqlite3InitModule } from "@sqlite.org/sqlite-wasm"; +import { type BindableValue, type SAHPoolUtil, default as sqlite3InitModule } from "@sqlite.org/sqlite-wasm"; import type { DatabaseProvider, RunResult, Statement, Transaction } from "@triliumnext/core"; // Type definitions for SQLite WASM (the library doesn't export these directly) @@ -227,6 +227,10 @@ export default class BrowserSqlProvider implements DatabaseProvider { // OPFS state tracking private opfsDbPath?: string; + // SAHPool state tracking + private sahPoolUtil?: SAHPoolUtil; + private sahPoolDbName?: string; + /** * Get the SQLite WASM module version info. * Returns undefined if the module hasn't been initialized yet. @@ -287,16 +291,111 @@ export default class BrowserSqlProvider implements DatabaseProvider { return this.sqlite3 !== undefined; } - // ==================== OPFS Support ==================== + // ==================== SAHPool VFS (preferred OPFS backend) ==================== /** - * Check if the OPFS VFS is available. + * Install the OPFS SAHPool VFS. This pre-allocates a pool of OPFS + * SyncAccessHandle objects, enabling WAL mode and significantly faster + * writes compared to the legacy OPFS VFS. + * + * Must be called after `initWasm()` and before `loadFromSahPool()`. + * This is async because it acquires OPFS file handles. + * + * @param options.directory - OPFS directory for the pool (default: auto-derived from VFS name) + * @param options.initialCapacity - Minimum number of file slots (default: 6) + * @throws Error if the environment doesn't support SAHPool (no OPFS, no Worker, no COOP/COEP) + */ + async installSahPool(options: { directory?: string; initialCapacity?: number } = {}): Promise { + this.ensureSqlite3(); + + console.log("[BrowserSqlProvider] Installing OPFS SAHPool VFS..."); + const startTime = performance.now(); + + this.sahPoolUtil = await this.sqlite3!.installOpfsSAHPoolVfs({ + clearOnInit: false, + initialCapacity: options.initialCapacity ?? 6, + directory: options.directory, + }); + + // Ensure enough slots for DB + WAL + journal + temp files + await this.sahPoolUtil.reserveMinimumCapacity(options.initialCapacity ?? 6); + + const initTime = performance.now() - startTime; + console.log( + `[BrowserSqlProvider] SAHPool VFS installed in ${initTime.toFixed(2)}ms ` + + `(capacity: ${this.sahPoolUtil.getCapacity()}, files: ${this.sahPoolUtil.getFileCount()})` + ); + } + + /** + * Whether the SAHPool VFS has been successfully installed. + */ + get isSahPoolInstalled(): boolean { + return this.sahPoolUtil !== undefined; + } + + /** + * Access the SAHPool utility for advanced operations (import/export/migration). + */ + get sahPool(): SAHPoolUtil | undefined { + return this.sahPoolUtil; + } + + /** + * Load or create a database using the SAHPool VFS. + * This is the preferred method for persistent storage — it supports WAL mode + * and is significantly faster than the legacy OPFS VFS. + * + * @param dbName - Virtual filename within the pool (e.g., "/trilium.db"). + * Must start with a slash. + * @throws Error if SAHPool VFS is not installed + */ + loadFromSahPool(dbName: string): void { + this.ensureSqlite3(); + if (!this.sahPoolUtil) { + throw new Error( + "SAHPool VFS not installed. Call installSahPool() first." + ); + } + + console.log(`[BrowserSqlProvider] Loading database from SAHPool: ${dbName}`); + const startTime = performance.now(); + + try { + this.db = new this.sahPoolUtil.OpfsSAHPoolDb(dbName); + this.sahPoolDbName = dbName; + this.opfsDbPath = undefined; + + // SAHPool supports WAL mode — the key advantage over legacy OPFS VFS + this.db.exec("PRAGMA journal_mode = WAL"); + this.db.exec("PRAGMA synchronous = NORMAL"); + + const loadTime = performance.now() - startTime; + console.log(`[BrowserSqlProvider] SAHPool database loaded in ${loadTime.toFixed(2)}ms (WAL mode)`); + } catch (e) { + const error = e instanceof Error ? e : new Error(String(e)); + console.error(`[BrowserSqlProvider] Failed to load SAHPool database: ${error.message}`); + throw error; + } + } + + /** + * Whether the currently open database is using the SAHPool VFS. + */ + get isUsingSahPool(): boolean { + return this.sahPoolDbName !== undefined; + } + + // ==================== Legacy OPFS Support ==================== + + /** + * Check if the legacy OPFS VFS is available. * This requires: * - Running in a Worker context * - Browser support for OPFS APIs * - COOP/COEP headers sent by the server (for SharedArrayBuffer) * - * @returns true if OPFS VFS is available for use + * @returns true if legacy OPFS VFS is available for use */ isOpfsAvailable(): boolean { this.ensureSqlite3(); @@ -307,6 +406,9 @@ export default class BrowserSqlProvider implements DatabaseProvider { /** * Load or create a database stored in OPFS for persistent storage. + * + * **Prefer `loadFromSahPool()` over this method** — it supports WAL mode + * and is significantly faster. This method is kept for migration purposes. * The database will persist across browser sessions. * * Requires COOP/COEP headers to be set by the server: @@ -354,9 +456,10 @@ export default class BrowserSqlProvider implements DatabaseProvider { const mode = options.createIfNotExists !== false ? 'c' : ''; this.db = new this.sqlite3!.oo1.OpfsDb(path, mode); this.opfsDbPath = path; + this.sahPoolDbName = undefined; - // Configure the database for OPFS - // Note: WAL mode requires exclusive locking in OPFS environment + // Configure the database for legacy OPFS + // Note: WAL mode is not supported by the legacy OPFS VFS this.db.exec("PRAGMA journal_mode = DELETE"); this.db.exec("PRAGMA synchronous = NORMAL"); @@ -370,10 +473,10 @@ export default class BrowserSqlProvider implements DatabaseProvider { } /** - * Check if the currently open database is stored in OPFS. + * Check if the currently open database is stored in OPFS (legacy or SAHPool). */ get isUsingOpfs(): boolean { - return this.opfsDbPath !== undefined; + return this.opfsDbPath !== undefined || this.sahPoolDbName !== undefined; } /** @@ -381,7 +484,7 @@ export default class BrowserSqlProvider implements DatabaseProvider { * Returns undefined if not using OPFS. */ get currentOpfsPath(): string | undefined { - return this.opfsDbPath; + return this.opfsDbPath ?? this.sahPoolDbName; } /** @@ -426,7 +529,8 @@ export default class BrowserSqlProvider implements DatabaseProvider { const startTime = performance.now(); this.db = new this.sqlite3!.oo1.DB(":memory:", "c"); - this.opfsDbPath = undefined; // Not using OPFS + this.opfsDbPath = undefined; + this.sahPoolDbName = undefined; this.db.exec("PRAGMA journal_mode = WAL"); const loadTime = performance.now() - startTime; @@ -448,7 +552,8 @@ export default class BrowserSqlProvider implements DatabaseProvider { // once we swap connections. Drop them so callers re-prepare. this.clearStatementCache(); this.db = new this.sqlite3!.oo1.DB({ filename: ":memory:", flags: "c" }); - this.opfsDbPath = undefined; // Not using OPFS + this.opfsDbPath = undefined; + this.sahPoolDbName = undefined; const rc = this.sqlite3!.capi.sqlite3_deserialize( this.db.pointer!, @@ -592,8 +697,9 @@ export default class BrowserSqlProvider implements DatabaseProvider { this.db = undefined; } - // Reset OPFS state + // Reset OPFS / SAHPool state this.opfsDbPath = undefined; + this.sahPoolDbName = undefined; } /** diff --git a/apps/client-standalone/src/local-bridge.ts b/apps/client-standalone/src/local-bridge.ts index dbd7e326d2..8d13d1f7ad 100644 --- a/apps/client-standalone/src/local-bridge.ts +++ b/apps/client-standalone/src/local-bridge.ts @@ -66,6 +66,11 @@ export function startLocalServerWorker() { } export function attachServiceWorkerBridge() { + if (!("serviceWorker" in navigator) || !navigator.serviceWorker) { + console.warn("[LocalBridge] Service workers not available — skipping bridge setup"); + return; + } + navigator.serviceWorker.addEventListener("message", async (event) => { const msg = event.data; if (!msg || msg.type !== "LOCAL_FETCH") return; diff --git a/apps/client-standalone/src/local-server-worker.ts b/apps/client-standalone/src/local-server-worker.ts index 6431b0354f..9245d6276d 100644 --- a/apps/client-standalone/src/local-server-worker.ts +++ b/apps/client-standalone/src/local-server-worker.ts @@ -122,6 +122,127 @@ async function writeOpfsFile(fileName: string, buffer: Uint8Array): Promise { + const root = await navigator.storage.getDirectory(); + const fileHandle = await root.getFileHandle(fileName); + const file = await fileHandle.getFile(); + return new Uint8Array(await file.arrayBuffer()); +} + +/** + * Delete a file from the OPFS root. + * Used to clean up the legacy OPFS database after migration to SAHPool. + */ +async function deleteOpfsFile(fileName: string): Promise { + const root = await navigator.storage.getDirectory(); + await root.removeEntry(fileName); +} + +/** + * Verify that a buffer contains a valid SQLite database by checking the + * 16-byte magic string "SQLite format 3\0". + */ +function assertSqliteMagic(buffer: Uint8Array, source: string): void { + const magic = new TextDecoder().decode(buffer.subarray(0, 15)); + if (magic !== "SQLite format 3") { + throw new Error( + `${source} is not a SQLite database ` + + `(got ${buffer.byteLength} bytes starting with "${magic}"). ` + + `The file is likely missing and the SPA fallback is returning index.html.` + ); + } +} + +/** + * Migrate database from legacy OPFS VFS to SAHPool VFS. + * Checks if a legacy `/trilium.db` file exists in the OPFS root, and if the + * SAHPool doesn't already have it. If migration is needed, the legacy file is + * read, imported into the pool, and then deleted. + */ +async function migrateFromLegacyOpfs(dbName: string): Promise { + const legacyFileName = dbName.replace(/^\//, ""); // strip leading slash + const legacyExists = await opfsFileExists(legacyFileName); + + if (!legacyExists) { + return; // Nothing to migrate + } + + // Check if SAHPool already has this DB (e.g. migration already happened) + const poolFiles = sqlProvider!.sahPool!.getFileNames(); + if (poolFiles.includes(dbName)) { + console.log("[Worker] SAHPool already contains the database, deleting legacy OPFS file..."); + await deleteOpfsFile(legacyFileName); + return; + } + + console.log("[Worker] Migrating database from legacy OPFS to SAHPool VFS..."); + const startTime = performance.now(); + + const buffer = await readOpfsFile(legacyFileName); + assertSqliteMagic(buffer, "Legacy OPFS database"); + + await sqlProvider!.sahPool!.importDb(dbName, buffer); + await deleteOpfsFile(legacyFileName); + + // Also clean up legacy journal/WAL files if they exist + for (const suffix of ["-journal", "-wal", "-shm"]) { + try { + await deleteOpfsFile(legacyFileName + suffix); + } catch { + // Ignore — file may not exist + } + } + + const elapsed = performance.now() - startTime; + console.log(`[Worker] Migration complete in ${elapsed.toFixed(2)}ms (${buffer.byteLength} bytes)`); +} + +/** + * Load the test fixture database for integration tests. + * Seeds from the fixture if not already present, using SAHPool when available. + */ +async function loadTestDatabase(sahPoolAvailable: boolean, dbName: string): Promise { + if (sahPoolAvailable) { + const poolFiles = sqlProvider!.sahPool!.getFileNames(); + if (!poolFiles.includes(dbName)) { + console.log("[Worker] Integration test mode: seeding fixture database into SAHPool..."); + const buffer = await fetchTestFixture(); + await sqlProvider!.sahPool!.importDb(dbName, buffer); + } else { + console.log("[Worker] Integration test mode: reusing existing SAHPool DB from earlier in this test"); + } + sqlProvider!.loadFromSahPool(dbName); + } else { + // Fallback to legacy OPFS for tests when SAHPool isn't available + const legacyFileName = dbName.replace(/^\//, ""); + if (!(await opfsFileExists(legacyFileName))) { + console.log("[Worker] Integration test mode: seeding fixture database into OPFS..."); + const buffer = await fetchTestFixture(); + await writeOpfsFile(legacyFileName, buffer); + } else { + console.log("[Worker] Integration test mode: reusing existing OPFS DB from earlier in this test"); + } + sqlProvider!.loadFromOpfs(dbName); + } +} + +/** + * Fetch the test fixture database and validate it. + */ +async function fetchTestFixture(): Promise { + const response = await fetch("/test-fixtures/document.db"); + if (!response.ok) { + throw new Error(`Failed to fetch test fixture: ${response.status} ${response.statusText}`); + } + const buffer = new Uint8Array(await response.arrayBuffer()); + assertSqliteMagic(buffer, "Test fixture at /test-fixtures/document.db"); + return buffer; +} + /** * Load all required modules using dynamic imports. * This allows errors to be caught by our error handlers. @@ -193,10 +314,20 @@ async function initialize(): Promise { console.log("[Worker] Initializing SQLite WASM..."); await sqlProvider!.initWasm(); + // Try to install the SAHPool VFS (preferred: supports WAL, much faster) + let sahPoolAvailable = false; + try { + await sqlProvider!.installSahPool(); + sahPoolAvailable = true; + } catch (e) { + console.warn("[Worker] SAHPool VFS not available, will fall back to legacy OPFS or in-memory:", e); + } + // Integration test mode is baked in at build time via the // __TRILIUM_INTEGRATION_TEST__ Vite define (derived from the // TRILIUM_INTEGRATION_TEST env var when the bundle was built). const integrationTestMode = __TRILIUM_INTEGRATION_TEST__; + const dbName = "/trilium.db"; if (integrationTestMode === "memory") { // Use OPFS for the DB in integration test mode so option changes @@ -204,34 +335,16 @@ async function initialize(): Promise { // Playwright gives each test a fresh BrowserContext, which means a // fresh OPFS — so on the first worker init of a test we seed from // the fixture, and subsequent inits in the same test reuse it. - const opfsDbName = "trilium.db"; - if (!(await opfsFileExists(opfsDbName))) { - console.log("[Worker] Integration test mode: seeding fixture database into OPFS..."); - const response = await fetch("/test-fixtures/document.db"); - if (!response.ok) { - throw new Error(`Failed to fetch test fixture: ${response.status} ${response.statusText}`); - } - const buffer = new Uint8Array(await response.arrayBuffer()); - // Verify we actually got a SQLite database, not an SPA-fallback - // index.html served for a missing file. SQLite databases start - // with the 16-byte magic string "SQLite format 3\0". - const magic = new TextDecoder().decode(buffer.subarray(0, 15)); - if (magic !== "SQLite format 3") { - throw new Error( - `Test fixture at /test-fixtures/document.db is not a SQLite database ` + - `(got ${buffer.byteLength} bytes starting with "${magic}"). ` + - `The file is likely missing and the SPA fallback is returning index.html.` - ); - } - await writeOpfsFile(opfsDbName, buffer); - } else { - console.log("[Worker] Integration test mode: reusing existing OPFS DB from earlier in this test"); - } - sqlProvider!.loadFromOpfs(`/${opfsDbName}`); + await loadTestDatabase(sahPoolAvailable, dbName); + } else if (sahPoolAvailable) { + // SAHPool available — migrate from legacy OPFS if needed, then open + await migrateFromLegacyOpfs(dbName); + console.log("[Worker] SAHPool available, loading persistent database (WAL mode)..."); + sqlProvider!.loadFromSahPool(dbName); } else if (sqlProvider!.isOpfsAvailable()) { - // Try to use OPFS for persistent storage - console.log("[Worker] OPFS available, loading persistent database..."); - sqlProvider!.loadFromOpfs("/trilium.db"); + // Fall back to legacy OPFS VFS (no WAL, slower writes) + console.warn("[Worker] Using legacy OPFS VFS (no WAL mode). Consider enabling COOP/COEP headers for SAHPool."); + sqlProvider!.loadFromOpfs(dbName); } else { // Fall back to in-memory database (non-persistent) console.warn("[Worker] OPFS not available, using in-memory database (data will not persist)"); diff --git a/apps/client-standalone/src/main.ts b/apps/client-standalone/src/main.ts index 97e6f65547..b7e36101d5 100644 --- a/apps/client-standalone/src/main.ts +++ b/apps/client-standalone/src/main.ts @@ -1,8 +1,21 @@ import { attachServiceWorkerBridge, startLocalServerWorker } from "./local-bridge.js"; async function waitForServiceWorkerControl(): Promise { - if (!("serviceWorker" in navigator)) { - throw new Error("Service Worker not supported in this browser"); + if (!("serviceWorker" in navigator) || !navigator.serviceWorker) { + const isSecure = location.protocol === "https:" || location.hostname === "localhost" || location.hostname === "127.0.0.1"; + const hints: string[] = []; + if (!isSecure) { + hints.push(`The page is served over ${location.protocol}//${location.hostname} which is not a secure context. Service workers require HTTPS (or localhost).`); + } + if (window.isSecureContext === false) { + hints.push("The browser reports this is not a secure context."); + } + throw new Error( + "Service workers are not available in this browser.\n\n" + + "Trilium standalone mode requires service workers to function.\n" + + (hints.length ? "\nPossible cause:\n- " + hints.join("\n- ") + "\n" : "") + + "\nTo fix this, access the application over HTTPS or via localhost." + ); } // If already controlling, we're good @@ -67,7 +80,7 @@ async function bootstrap() {

Failed to Initialize

The application failed to start. Please check the browser console for details.

-
${err instanceof Error ? err.message : String(err)}
+
${err instanceof Error ? err.message : String(err)}
diff --git a/apps/client/src/layouts/desktop_layout.tsx b/apps/client/src/layouts/desktop_layout.tsx index 4570e70ef3..b3b07e4a9b 100644 --- a/apps/client/src/layouts/desktop_layout.tsx +++ b/apps/client/src/layouts/desktop_layout.tsx @@ -91,6 +91,7 @@ export default class DesktopLayout { .optChild(launcherPaneIsHorizontal, ) .child() .child(new TabRowWidget().class("full-width")) + .optChild(glob.isStandalone, ) .optChild(isNewLayout, ) .optChild(customTitleBarButtons, ) .css("height", "40px") @@ -118,6 +119,7 @@ export default class DesktopLayout { .class("tab-row-container") .child() .child(new TabRowWidget()) + .optChild(glob.isStandalone, ) .optChild(isNewLayout, ) .optChild(customTitleBarButtons, ) .css("height", "40px") @@ -187,7 +189,6 @@ export default class DesktopLayout { ) ) .optChild(launcherPaneIsHorizontal && isNewLayout, ) - .optChild(glob.isStandalone, ) .child() // Desktop-specific dialogs. diff --git a/apps/client/src/layouts/mobile_layout.tsx b/apps/client/src/layouts/mobile_layout.tsx index 56a3fe064e..5412e6e708 100644 --- a/apps/client/src/layouts/mobile_layout.tsx +++ b/apps/client/src/layouts/mobile_layout.tsx @@ -56,7 +56,6 @@ export default class MobileLayout { .child( new SplitNoteContainer(() => new NoteWrapperWidget() - .optChild(glob.isStandalone, ) .child( new FlexContainer("row") .class("title-row note-split-title") @@ -66,6 +65,7 @@ export default class MobileLayout { .child() .child() .child() + .optChild(glob.isStandalone, ) .child() ) .child( diff --git a/apps/client/src/setup.css b/apps/client/src/setup.css index c86c48307f..13debde7fe 100644 --- a/apps/client/src/setup.css +++ b/apps/client/src/setup.css @@ -100,11 +100,15 @@ body.setup { >.back-button { position: absolute; top: 2em; - left: 2em; + inset-inline-start: 2em; color: var(--muted-text-color); .tn-icon { - margin-right: 0.4em; + margin-inline-end: 0.4em; + } + + body[dir=rtl] & .tn-icon { + transform: scaleX(-1); } } @@ -134,19 +138,18 @@ body.setup { >.page-error { position: absolute; top: 0; - left: 0; - right: 0; + inset-inline: 0; background: var(--admonition-caution-accent-color); z-index: 1; margin: 0; border-radius: 0; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1); - padding-right: 2.5em; + padding-inline-end: 2.5em; button { position: absolute; top: 0.5em; - right: 0.5em; + inset-inline-end: 0.5em; } } } @@ -308,6 +311,22 @@ body.setup.platform-darwin { animation-name: slide-in-left; } +body[dir=rtl] .slide-out-forward { + animation-name: slide-out-right; +} + +body[dir=rtl] .slide-out-backward { + animation-name: slide-out-left; +} + +body[dir=rtl] .slide-in-forward { + animation-name: slide-in-left; +} + +body[dir=rtl] .slide-in-backward { + animation-name: slide-in-right; +} + .page.select-language { .dropdownWrapper { padding-bottom: 2em; @@ -394,7 +413,7 @@ body.setup.platform-darwin { font-size: 0.85rem; color: var(--muted-text-color); min-width: 2.5em; - text-align: right; + text-align: end; } } } diff --git a/apps/client/src/setup.tsx b/apps/client/src/setup.tsx index 6606baf4d2..62c34908c9 100644 --- a/apps/client/src/setup.tsx +++ b/apps/client/src/setup.tsx @@ -104,6 +104,8 @@ function SelectLanguage({ setState }: { setState: (state: State) => void }) { { await i18n.changeLanguage(id); setCurrentLocale(id); + const locale = LOCALES.find(l => l.id === id); + document.body.dir = locale?.rtl ? "rtl" : "ltr"; }}> {filteredLocales.map(locale => ( {locale.name} diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index 8ebdc2470f..5277594cf8 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -1,5 +1,9 @@ { - "about": { + "standalone": { + "badge_label": "Standalone", + "warning_tooltip": "You are running Trilium in standalone mode. Some features are not available, and you may experience issues or data loss. Use the desktop application or self-hosted server for the best experience." + }, + "about": { "version_label": "Version:", "version": "{{appVersion}} (database: {{dbVersion}}, sync protocol: {{syncVersion}})", "build_info": "Build: {{buildDate}}, revision: ", diff --git a/apps/client/src/widgets/layout/StandaloneWarningBar.css b/apps/client/src/widgets/layout/StandaloneWarningBar.css new file mode 100644 index 0000000000..1749601293 --- /dev/null +++ b/apps/client/src/widgets/layout/StandaloneWarningBar.css @@ -0,0 +1,20 @@ +.component.standalone-badge { + contain: none; + display: flex; + align-items: center; + gap: 4px; + padding: 4px 10px; + margin-inline: 4px; + border-radius: 4px; + font-size: 0.75em; + font-weight: 600; + white-space: nowrap; + cursor: default; + background-color: color-mix(in srgb, var(--color-warning, #e6a700) 15%, transparent); + color: var(--color-warning, #e6a700); + border: 1px solid color-mix(in srgb, var(--color-warning, #e6a700) 30%, transparent); + + .bx { + font-size: 1.1em; + } +} diff --git a/apps/client/src/widgets/layout/StandaloneWarningBar.tsx b/apps/client/src/widgets/layout/StandaloneWarningBar.tsx index 43a2c8af5f..cf9dca028e 100644 --- a/apps/client/src/widgets/layout/StandaloneWarningBar.tsx +++ b/apps/client/src/widgets/layout/StandaloneWarningBar.tsx @@ -1,26 +1,27 @@ -import { isMobile } from "../../services/utils"; -import Admonition from "../react/Admonition"; +import { useRef } from "preact/hooks"; +import { t } from "../../services/i18n"; +import { useNoteContext, useTooltip } from "../react/hooks"; +import "./StandaloneWarningBar.css"; export default function StandaloneWarningBar() { + const { noteContext } = useNoteContext(); + const badgeRef = useRef(null); + + useTooltip(badgeRef, { + title: t("standalone.warning_tooltip"), + placement: "top", + delay: 200 + }); + + // Only show in the main split, not sub-splits. + if (noteContext?.mainNtxId) { + return null; + } + return ( -
- - {isMobile() - ? "Running Trilium standalone. Beware of data loss and other issues." - : "You are running Trilium in standalone mode. Some features are not available, and you may experience issues or data loss. Use the desktop application or self-hosted server for the best experience." - } - +
+ + {t("standalone.badge_label")}
); }