diff --git a/public/style.css b/public/style.css index d962ff6..47ca533 100644 --- a/public/style.css +++ b/public/style.css @@ -7,6 +7,12 @@ line-height: 1.2; } +/* In-flight submit controls (double-submit guard in ui.ts). */ +[aria-busy="true"] { + opacity: 0.65; + cursor: progress; +} + /* Footnotes */ .footnotes { font-size: 0.875rem; diff --git a/public/ui.ts b/public/ui.ts index 53952e8..93accb8 100644 --- a/public/ui.ts +++ b/public/ui.ts @@ -256,6 +256,32 @@ dialog?.showModal(); } } + + // Prevent double-submit: mark submit controls busy and disable them once a + // real submission starts. Skipped when a guard above cancelled it, and for + // HTMX-managed forms (htmx runs its own in-flight state). + if (e.defaultPrevented) return; + if ( + f.hasAttribute("hx-post") || + f.hasAttribute("hx-get") || + f.hasAttribute("hx-put") || + f.hasAttribute("hx-delete") + ) + return; + f.querySelectorAll('button[type="submit"], input[type="submit"]').forEach( + (btn) => { + if ( + btn instanceof HTMLButtonElement || + btn instanceof HTMLInputElement + ) { + btn.setAttribute("aria-busy", "true"); + // Defer so the control's name/value is still included in the payload. + setTimeout(() => { + btn.disabled = true; + }, 0); + } + }, + ); }); // Global search shortcut: opt-in via data-search-shortcut on
. diff --git a/src/lib/dates.ts b/src/lib/dates.ts new file mode 100644 index 0000000..052526a --- /dev/null +++ b/src/lib/dates.ts @@ -0,0 +1,30 @@ +import type { Locale } from "./i18n/index.ts"; + +// SQLite timestamps may arrive as "YYYY-MM-DD HH:MM:SS" (no T/zone); normalize to UTC. +function parse(iso: string): Date { + return new Date(iso.includes("T") ? iso : `${iso.replace(" ", "T")}Z`); +} + +// Localized date + time (e.g. "5 Jan 2026, 14:03"); falls back to the raw value on parse failure. +export function formatDate(iso: string, locale: Locale): string { + const d = parse(iso); + if (Number.isNaN(d.getTime())) return iso; + return d.toLocaleString(locale, { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +// Localized date only (e.g. "5 Jan 2026"); falls back to the raw value on parse failure. +export function formatDay(iso: string, locale: Locale): string { + const d = parse(iso); + if (Number.isNaN(d.getTime())) return iso; + return d.toLocaleDateString(locale, { + year: "numeric", + month: "short", + day: "numeric", + }); +} diff --git a/src/lib/i18n/en.ts b/src/lib/i18n/en.ts index 7c404bf..b56e07b 100644 --- a/src/lib/i18n/en.ts +++ b/src/lib/i18n/en.ts @@ -239,7 +239,8 @@ export const en: Messages = { zipCompressedTooLarge: "Zip file is too large.", noMarkdownFiles: "No markdown files found in zip.", importFailed: "Import failed: {error}", - requestFailed: "Something went wrong. Please try again.", + requestFailed: + "That action didn't go through. Check your connection and try again.", invalidThemeMode: "Invalid theme mode.", invalidTheme: "Invalid theme.", wikiRemoved: "This wiki has been removed.", diff --git a/src/lib/i18n/fr.ts b/src/lib/i18n/fr.ts index 987f5c0..b885784 100644 --- a/src/lib/i18n/fr.ts +++ b/src/lib/i18n/fr.ts @@ -242,7 +242,8 @@ export const fr: PartialMessages = { zipCompressedTooLarge: "Le fichier zip est trop volumineux.", noMarkdownFiles: "Aucun fichier markdown trouvé dans le zip.", importFailed: "Échec de l'importation : {error}", - requestFailed: "Une erreur est survenue. Veuillez réessayer.", + requestFailed: + "L'action n'a pas abouti. Vérifiez votre connexion et réessayez.", invalidThemeMode: "Mode de thème invalide.", invalidTheme: "Thème invalide.", }, diff --git a/src/lib/i18n/ja.ts b/src/lib/i18n/ja.ts index 2c092a7..83c719f 100644 --- a/src/lib/i18n/ja.ts +++ b/src/lib/i18n/ja.ts @@ -246,7 +246,8 @@ export const ja: PartialMessages = { zipCompressedTooLarge: "zip ファイルが大きすぎます。", noMarkdownFiles: "zip 内に Markdown ファイルが見つかりません。", importFailed: "インポートに失敗しました: {error}", - requestFailed: "問題が発生しました。もう一度お試しください。", + requestFailed: + "処理を完了できませんでした。接続を確認して、もう一度お試しください。", invalidThemeMode: "無効なテーマモードです。", invalidTheme: "無効なテーマです。", }, diff --git a/src/views/history.ts b/src/views/history.ts index 2ef45e7..e4bea46 100644 --- a/src/views/history.ts +++ b/src/views/history.ts @@ -1,3 +1,4 @@ +import { formatDate } from "../lib/dates.ts"; import { renderContentDiff, renderDiffInline } from "../lib/diff-render.ts"; import { escapeHtml } from "../lib/html.ts"; import { type Locale, t } from "../lib/i18n/index.ts"; @@ -24,18 +25,6 @@ export interface HistoryEntry { afterContent: string | null; } -function formatDate(iso: string, locale: Locale): string { - const d = new Date(iso.includes("T") ? iso : `${iso.replace(" ", "T")}Z`); - if (Number.isNaN(d.getTime())) return iso; - return d.toLocaleString(locale, { - year: "numeric", - month: "short", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - }); -} - function authorLabel(entry: HistoryEntry): string { const handleOrDid = entry.handle ?? entry.did; const href = profileUrl(handleOrDid); diff --git a/src/views/settings.ts b/src/views/settings.ts index c9bd829..f57566b 100644 --- a/src/views/settings.ts +++ b/src/views/settings.ts @@ -1,5 +1,6 @@ +import { formatDay } from "../lib/dates.ts"; import { escapeHtml } from "../lib/html.ts"; -import { fmt, t } from "../lib/i18n/index.ts"; +import { fmt, type Locale, t } from "../lib/i18n/index.ts"; import type { ProfileInfo } from "../lib/profile.ts"; import { wikiUrl } from "../lib/urls.ts"; import type { WikiIdentity } from "../lib/wiki-identity.ts"; @@ -85,6 +86,7 @@ function renderMemberRow( profile: ProfileInfo | undefined, roleLabel: (r: string) => string, msg: ReturnType