diff --git a/web/src/lib/components/app/copy-button.svelte b/web/src/lib/components/app/copy-button.svelte index 4a8d1c7..ba27306 100644 --- a/web/src/lib/components/app/copy-button.svelte +++ b/web/src/lib/components/app/copy-button.svelte @@ -1,5 +1,6 @@ diff --git a/web/src/lib/components/app/service-file-tree.svelte b/web/src/lib/components/app/service-file-tree.svelte index 24a1c7b..159b310 100644 --- a/web/src/lib/components/app/service-file-tree.svelte +++ b/web/src/lib/components/app/service-file-tree.svelte @@ -11,8 +11,11 @@ isEncryptedFilePath, type ServiceFileNode, } from '$lib/service-workspace'; + import { getMessages } from '$lib/i18n'; import ServiceFileTree from './service-file-tree.svelte'; + const messages = getMessages(); + interface Props { nodes?: ServiceFileNode[]; activePath?: string; @@ -111,6 +114,7 @@ {/if} {:else} {@const iconName = nodeIconName(node)} + {@const encrypted = isEncryptedFilePath(node.path)} {/if} diff --git a/web/src/lib/i18n/messages/de.ts b/web/src/lib/i18n/messages/de.ts index cd12e46..ce71ba3 100644 --- a/web/src/lib/i18n/messages/de.ts +++ b/web/src/lib/i18n/messages/de.ts @@ -82,6 +82,7 @@ export const de = { previous: "Vorherige", submit: "Absenden", copy: "Kopieren", + clipboardFailed: "Kopieren in die Zwischenablage fehlgeschlagen.", copied: "Kopiert!", download: "Herunterladen", upload: "Hochladen", @@ -108,6 +109,7 @@ export const de = { options: "Optionen", loadingWithDots: "Laden...", for: "für", + to: "bis", on: "auf", na: "N/V", skipToContent: "Zum Hauptinhalt springen", @@ -312,6 +314,7 @@ export const de = { newFolderPathPlaceholder: "config/snippets", newFilePlaceholder: "neuer/pfad.yaml", newServiceFolderPlaceholder: "neuer-dienst-ordner", + encryptedFile: "Verschlüsselte Datei", encryptedUnavailableTitle: "Verschlüsselte Datei nicht verfügbar", encryptedUnavailableMissingSecrets: "Secrets-Unterstützung konfigurieren, bevor verschlüsselte Dateien im Editor geöffnet werden.", diff --git a/web/src/lib/i18n/messages/en-us.ts b/web/src/lib/i18n/messages/en-us.ts index bc32c96..e269737 100644 --- a/web/src/lib/i18n/messages/en-us.ts +++ b/web/src/lib/i18n/messages/en-us.ts @@ -88,6 +88,7 @@ export const enUS = { previous: "Previous", submit: "Submit", copy: "Copy", + clipboardFailed: "Failed to copy to clipboard.", copied: "Copied!", download: "Download", upload: "Upload", @@ -114,6 +115,7 @@ export const enUS = { options: "Options", loadingWithDots: "Loading...", for: "for", + to: "to", on: "on", na: "N/A", skipToContent: "Skip to main content", @@ -312,6 +314,7 @@ export const enUS = { newFolderPathPlaceholder: "config/snippets", newFilePlaceholder: "new/path.yaml", newServiceFolderPlaceholder: "new-service-folder", + encryptedFile: "Encrypted file", encryptedUnavailableTitle: "Encrypted file is unavailable", encryptedUnavailableMissingSecrets: "Configure secrets support before opening encrypted files in the editor.", diff --git a/web/src/lib/i18n/messages/fr.ts b/web/src/lib/i18n/messages/fr.ts index ef1c5da..69a2bef 100644 --- a/web/src/lib/i18n/messages/fr.ts +++ b/web/src/lib/i18n/messages/fr.ts @@ -83,6 +83,7 @@ export const fr = { previous: "Précédent", submit: "Envoyer", copy: "Copier", + clipboardFailed: "Échec de la copie dans le presse-papiers.", copied: "Copié !", download: "Télécharger", upload: "Téléverser", @@ -109,6 +110,7 @@ export const fr = { options: "Options", loadingWithDots: "Chargement...", for: "pour", + to: "à", on: "sur", na: "N/D", skipToContent: "Aller au contenu principal", @@ -313,6 +315,7 @@ export const fr = { newFolderPathPlaceholder: "config/snippets", newFilePlaceholder: "nouveau/chemin.yaml", newServiceFolderPlaceholder: "nouveau-dossier-service", + encryptedFile: "Fichier chiffré", encryptedUnavailableTitle: "Fichier chiffré indisponible", encryptedUnavailableMissingSecrets: "Configurez le support des secrets avant d'ouvrir des fichiers chiffrés dans l'éditeur.", diff --git a/web/src/lib/i18n/messages/ja.ts b/web/src/lib/i18n/messages/ja.ts index 1f663e6..c63bbf2 100644 --- a/web/src/lib/i18n/messages/ja.ts +++ b/web/src/lib/i18n/messages/ja.ts @@ -83,6 +83,7 @@ export const ja = { previous: "前へ", submit: "送信", copy: "コピー", + clipboardFailed: "クリップボードにコピーできませんでした。", copied: "コピーしました!", download: "ダウンロード", upload: "アップロード", @@ -109,6 +110,7 @@ export const ja = { options: "オプション", loadingWithDots: "読み込み中...", for: "対象", + to: "から", on: "対象ノード", na: "N/A", skipToContent: "メインコンテンツへスキップ", @@ -311,6 +313,7 @@ export const ja = { newFolderPathPlaceholder: "config/snippets", newFilePlaceholder: "new/path.yaml", newServiceFolderPlaceholder: "new-service-folder", + encryptedFile: "暗号化ファイル", encryptedUnavailableTitle: "暗号化ファイルは利用できません", encryptedUnavailableMissingSecrets: "エディターで暗号化ファイルを開く前に、Secrets サポートを設定してください。", diff --git a/web/src/lib/i18n/messages/zh-hans.ts b/web/src/lib/i18n/messages/zh-hans.ts index 377dbaa..cdf0c52 100644 --- a/web/src/lib/i18n/messages/zh-hans.ts +++ b/web/src/lib/i18n/messages/zh-hans.ts @@ -81,6 +81,7 @@ export const zhHans = { previous: "上一步", submit: "提交", copy: "复制", + clipboardFailed: "复制到剪贴板失败。", copied: "已复制!", download: "下载", upload: "上传", @@ -107,6 +108,7 @@ export const zhHans = { options: "选项", loadingWithDots: "加载中...", for: "用于", + to: "到", on: "在", na: "无", skipToContent: "跳到主内容", @@ -306,6 +308,7 @@ export const zhHans = { newFolderPathPlaceholder: "config/片段", newFilePlaceholder: "新/路径.yaml", newServiceFolderPlaceholder: "新服务文件夹", + encryptedFile: "加密文件", }, operations: { title: "操作", diff --git a/web/src/lib/i18n/messages/zh-hant.ts b/web/src/lib/i18n/messages/zh-hant.ts index 5cd279d..eebe936 100644 --- a/web/src/lib/i18n/messages/zh-hant.ts +++ b/web/src/lib/i18n/messages/zh-hant.ts @@ -81,6 +81,7 @@ export const zhHant = { previous: "上一步", submit: "提交", copy: "複製", + clipboardFailed: "複製到剪貼簿失敗。", copied: "已複製!", download: "下載", upload: "上傳", @@ -107,6 +108,7 @@ export const zhHant = { options: "選項", loadingWithDots: "載入中...", for: "用於", + to: "到", on: "在", na: "無", skipToContent: "跳到主內容", @@ -306,6 +308,7 @@ export const zhHant = { newFolderPathPlaceholder: "config/片段", newFilePlaceholder: "新/路徑.yaml", newServiceFolderPlaceholder: "新服務資料夾", + encryptedFile: "加密檔案", }, operations: { title: "操作", diff --git a/web/src/lib/presenters.test.mjs b/web/src/lib/presenters.test.mjs index 55efa43..25c8c3b 100644 --- a/web/src/lib/presenters.test.mjs +++ b/web/src/lib/presenters.test.mjs @@ -1,13 +1,23 @@ import { assertEquals } from "jsr:@std/assert@1.0.19/equals"; -import { formatBytes, formatDuration } from "./presenters.ts"; +import { + formatBytes, + formatDockerTimestamp, + formatDuration, +} from "./presenters.ts"; Deno.test("formats binary byte units", () => { - assertEquals(formatBytes(0), "0 B"); - assertEquals(formatBytes(1536), "1.5 KiB"); + assertEquals(formatBytes(0, "en-US"), "0 B"); + assertEquals(formatBytes(1536, "en-US"), "1.5 KiB"); + assertEquals(formatBytes(1536, "de-DE"), "1,5 KiB"); }); -Deno.test("formats elapsed time", () => { +Deno.test("formats elapsed time with locale-aware units", () => { const twoMinutesAgo = new Date(Date.now() - 120_000).toISOString(); - assertEquals(formatDuration(twoMinutesAgo), "2 minutes"); + assertEquals(formatDuration(twoMinutesAgo, "en-US"), "2 minutes"); +}); + +Deno.test("formats docker timestamps as localized relative time", () => { + const twoMinutesAgo = new Date(Date.now() - 120_000).toISOString(); + assertEquals(formatDockerTimestamp(twoMinutesAgo, "en-US"), "2 minutes ago"); }); diff --git a/web/src/lib/presenters.ts b/web/src/lib/presenters.ts index 57b35c5..1dc714a 100644 --- a/web/src/lib/presenters.ts +++ b/web/src/lib/presenters.ts @@ -2,12 +2,14 @@ import type { BadgeVariant } from "$lib/components/ui/badge"; import type { Dictionary } from "$lib/i18n"; -export function formatTimestamp(value: string) { +export function formatTimestamp(value: string, locale = currentLocale()) { if (!value) { return "-"; } const parsed = parseTimestamp(value); - return parsed ? parsed.toLocaleString() : value; + return parsed + ? new Intl.DateTimeFormat(locale, dateTimeFormat).format(parsed) + : value; } export function taskStatusTone(status: string): BadgeVariant { @@ -158,13 +160,16 @@ export function isTaskRecent(createdAt: string): boolean { return parsed ? parsed.getTime() > Date.now() - 86_400_000 : false; } -export function formatDuration(startedAt: string): string { +export function formatDuration( + startedAt: string, + locale = currentLocale(), +): string { if (!startedAt) return "-"; const start = parseTimestamp(startedAt); - return start ? formatDistance(start, new Date()) : "-"; + return start ? formatDistance(start, new Date(), locale) : "-"; } -export function formatBytes(bytes: number) { +export function formatBytes(bytes: number, locale = currentLocale()) { const value = Math.max(0, bytes || 0); const units = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"]; const unitIndex = Math.min( @@ -172,7 +177,7 @@ export function formatBytes(bytes: number) { units.length - 1, ); const amount = value / 1024 ** unitIndex; - return `${new Intl.NumberFormat("en-US", { maximumFractionDigits: 2 }).format(amount)} ${units[unitIndex]}`; + return `${new Intl.NumberFormat(locale, { maximumFractionDigits: 2 }).format(amount)} ${units[unitIndex]}`; } export function formatShortId(value: string, length = 12) { @@ -192,7 +197,10 @@ export function parseJsonList(rawJson: string) { return Array.isArray(parsed) ? parsed[0] : parsed; } -export function formatDockerTimestamp(timestamp: string) { +export function formatDockerTimestamp( + timestamp: string, + locale = currentLocale(), +) { if (!timestamp) { return "-"; } @@ -202,9 +210,22 @@ export function formatDockerTimestamp(timestamp: string) { return timestamp; } - return parsed.getTime() > Date.now() - ? "just now" - : `${formatDistance(parsed, new Date())} ago`; + return formatRelativeTime(parsed, new Date(), locale); +} + +const dateTimeFormat: Intl.DateTimeFormatOptions = { + dateStyle: "medium", + timeStyle: "medium", +}; + +function currentLocale() { + if (typeof document !== "undefined" && document.documentElement.lang) { + return document.documentElement.lang; + } + if (typeof navigator !== "undefined") { + return navigator.languages?.[0] ?? navigator.language; + } + return "en-US"; } function parseTimestamp(value: string): Date | null { @@ -217,12 +238,34 @@ function parseDockerTimestamp(value: string): Date | null { return parseTimestamp(cleaned.replace(" ", "T")); } -function formatDistance(from: Date, to: Date) { +function formatDistance(from: Date, to: Date, locale: string) { + const [amount, unit] = elapsedUnit(from, to); + return new Intl.NumberFormat(locale, { + style: "unit", + unit, + unitDisplay: "long", + maximumFractionDigits: 0, + }).format(amount); +} + +function formatRelativeTime(from: Date, to: Date, locale: string) { + const [amount, unit] = elapsedUnit(from, to); + const sign = from.getTime() > to.getTime() ? amount : -amount; + return new Intl.RelativeTimeFormat(locale, { numeric: "auto" }).format( + sign, + unit, + ); +} + +function elapsedUnit( + from: Date, + to: Date, +): [number, Intl.RelativeTimeFormatUnit] { const seconds = Math.max( 0, - Math.floor((to.getTime() - from.getTime()) / 1000), + Math.floor(Math.abs(to.getTime() - from.getTime()) / 1000), ); - const units: Array<[number, string]> = [ + const units: Array<[number, Intl.RelativeTimeFormatUnit]> = [ [31_536_000, "year"], [2_592_000, "month"], [604_800, "week"], @@ -232,6 +275,5 @@ function formatDistance(from: Date, to: Date) { [1, "second"], ]; const [size, unit] = units.find(([size]) => seconds >= size) ?? units.at(-1)!; - const amount = Math.floor(seconds / size); - return `${amount} ${unit}${amount === 1 ? "" : "s"}`; + return [Math.floor(seconds / size), unit]; } diff --git a/web/src/routes/(app)/nodes/[id]/docker/containers/[cid]/+page.svelte b/web/src/routes/(app)/nodes/[id]/docker/containers/[cid]/+page.svelte index f3a2688..3e3b436 100644 --- a/web/src/routes/(app)/nodes/[id]/docker/containers/[cid]/+page.svelte +++ b/web/src/routes/(app)/nodes/[id]/docker/containers/[cid]/+page.svelte @@ -1,5 +1,7 @@