web: shared note actions, reminder chips, page header, formatters and settings seam
From the audit (#5179, web half). - NoteActions: share, pin, archive and trash (restore and delete forever when trashed), as both the card and the editor offer them. The editor's history toggle goes in its slot, and it closes on `acted`. - ReminderActions: the Done / 1h / 1d chips on the card and in the editor, which are now the same chips. - PageHeader: the back-to-board header that Settings, Sync and Linked devices each wrote out, now with a `back` icon from the shared set. - notes/datetime: formatShortDateTime (was formatReminder and the editor's revLabel) and formatDateTime (the two `fmt` copies). - notes/colors: labelDotClasses (the sidebar's and the tag manager's labelDot). - Drawer links use exact-active-class instead of route.name ternaries. - BoardView binds its three grids from one gridBinds object. - Settings goes through repo.settings (rest, plus a local adapter that answers "needs a server") and shows load failures through AsyncState. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,7 +7,9 @@ import BaseButton from "../components/BaseButton.vue";
|
||||
import BaseInput from "../components/BaseInput.vue";
|
||||
import ClientDownloads from "../components/ClientDownloads.vue";
|
||||
import Icon from "../components/Icon.vue";
|
||||
import PageHeader from "../components/PageHeader.vue";
|
||||
import { errorMessage } from "../api/errors";
|
||||
import { formatDateTime } from "../notes/datetime";
|
||||
|
||||
// Per-user (not admin) management of linked native clients — the Tauri desktop and
|
||||
// Android apps authenticate sync with a device bearer token issued here.
|
||||
@@ -66,11 +68,6 @@ async function revoke(id: string, name: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function fmt(iso: string | null): string {
|
||||
if (!iso) return "never";
|
||||
return new Date(iso).toLocaleString();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load();
|
||||
});
|
||||
@@ -78,23 +75,7 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<div class="mx-auto min-h-full max-w-2xl px-4 py-8">
|
||||
<header class="mb-8 flex items-center gap-3">
|
||||
<RouterLink to="/" class="icon-btn" title="Back to board" aria-label="Back to board">
|
||||
<svg
|
||||
class="h-[18px] w-[18px]"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="m15 18-6-6 6-6" />
|
||||
</svg>
|
||||
</RouterLink>
|
||||
<h1 class="text-xl font-bold tracking-tight">Linked devices</h1>
|
||||
</header>
|
||||
<PageHeader title="Linked devices" />
|
||||
|
||||
<p class="mb-6 max-w-xl text-sm text-neutral-500 dark:text-neutral-400">
|
||||
Link the Inkwell desktop or mobile app to sync your notes. Create a device token here, then
|
||||
@@ -170,7 +151,7 @@ onMounted(() => {
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm font-medium text-neutral-800 dark:text-neutral-100">{{ d.name }}</p>
|
||||
<p class="text-xs text-neutral-400">
|
||||
Linked {{ fmt(d.created_at) }} · last synced {{ fmt(d.last_used_at) }}
|
||||
Linked {{ formatDateTime(d.created_at) }} · last synced {{ formatDateTime(d.last_used_at) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -281,6 +281,17 @@ async function onDrop(targetId: string) {
|
||||
order.splice(toIdx, 0, from);
|
||||
await notes.reorder(order);
|
||||
}
|
||||
|
||||
/** What every grid on this board takes besides its notes: the keyboard focus, and
|
||||
* opening and reordering cards. */
|
||||
const gridBinds = computed(() => ({
|
||||
activeId: focusedId.value,
|
||||
reorderable: true,
|
||||
onOpen: openEditor,
|
||||
onDragstart: onDragStart,
|
||||
onDragend: onDragEnd,
|
||||
onDrop,
|
||||
}));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -340,42 +351,17 @@ async function onDrop(targetId: string) {
|
||||
<template v-if="isMainBoard">
|
||||
<section v-if="pinnedNotes.length">
|
||||
<h2 class="mb-2 text-xs font-semibold uppercase tracking-wide text-neutral-400">Pinned</h2>
|
||||
<NoteGrid
|
||||
:notes="pinnedNotes"
|
||||
:active-id="focusedId"
|
||||
reorderable
|
||||
@open="openEditor"
|
||||
@dragstart="onDragStart"
|
||||
@dragend="onDragEnd"
|
||||
@drop="onDrop"
|
||||
/>
|
||||
<NoteGrid :notes="pinnedNotes" v-bind="gridBinds" />
|
||||
</section>
|
||||
<section v-if="otherNotes.length" :class="pinnedNotes.length ? 'mt-8' : ''">
|
||||
<h2 v-if="pinnedNotes.length" class="mb-2 text-xs font-semibold uppercase tracking-wide text-neutral-400">
|
||||
Others
|
||||
</h2>
|
||||
<NoteGrid
|
||||
:notes="otherNotes"
|
||||
:active-id="focusedId"
|
||||
reorderable
|
||||
@open="openEditor"
|
||||
@dragstart="onDragStart"
|
||||
@dragend="onDragEnd"
|
||||
@drop="onDrop"
|
||||
/>
|
||||
<NoteGrid :notes="otherNotes" v-bind="gridBinds" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<NoteGrid
|
||||
v-else
|
||||
:notes="notes.items"
|
||||
:active-id="focusedId"
|
||||
reorderable
|
||||
@open="openEditor"
|
||||
@dragstart="onDragStart"
|
||||
@dragend="onDragEnd"
|
||||
@drop="onDrop"
|
||||
/>
|
||||
<NoteGrid v-else :notes="notes.items" v-bind="gridBinds" />
|
||||
</template>
|
||||
</AsyncState>
|
||||
</div>
|
||||
|
||||
@@ -1,40 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { api } from "../api/client";
|
||||
import { repo } from "../adapters";
|
||||
import type { ServerSetting, SettingValue } from "../adapters/repo";
|
||||
import { useConfigStore } from "../stores/config";
|
||||
import AsyncState from "../components/AsyncState.vue";
|
||||
import BaseButton from "../components/BaseButton.vue";
|
||||
import PageHeader from "../components/PageHeader.vue";
|
||||
import InviteList from "../components/InviteList.vue";
|
||||
import AccountList from "../components/AccountList.vue";
|
||||
import GroupList from "../components/GroupList.vue";
|
||||
import { errorMessage } from "../api/errors";
|
||||
import { useUiStore } from "../stores/ui";
|
||||
|
||||
interface SettingItem {
|
||||
key: string;
|
||||
type: "string" | "bool" | "int";
|
||||
value: string | boolean | number;
|
||||
default: string | boolean | number;
|
||||
label: string;
|
||||
description: string;
|
||||
group: string;
|
||||
// Ints only, and nullable: the server sends the registry's bounds so the number
|
||||
// input can refuse an out-of-range value before the round trip.
|
||||
minimum: number | null;
|
||||
maximum: number | null;
|
||||
// Strings: the allowed values, drawn as a choice.
|
||||
choices: string[] | null;
|
||||
// A credential (the SMTP password). Its value never comes back; `is_set` says
|
||||
// whether one is saved, and leaving the field empty keeps it.
|
||||
secret: boolean;
|
||||
is_set: boolean | null;
|
||||
}
|
||||
|
||||
const config = useConfigStore();
|
||||
const ui = useUiStore();
|
||||
|
||||
const items = ref<SettingItem[]>([]);
|
||||
const original = ref<Record<string, string | boolean | number>>({});
|
||||
const items = ref<ServerSetting[]>([]);
|
||||
const original = ref<Record<string, SettingValue>>({});
|
||||
const loading = ref(true);
|
||||
const loadError = ref("");
|
||||
const saving = ref(false);
|
||||
const error = ref("");
|
||||
const saved = ref(false);
|
||||
@@ -46,7 +30,7 @@ watch(dirty, (d) => {
|
||||
});
|
||||
|
||||
const groups = computed(() => {
|
||||
const map = new Map<string, SettingItem[]>();
|
||||
const map = new Map<string, ServerSetting[]>();
|
||||
for (const it of items.value) {
|
||||
const arr = map.get(it.group) ?? [];
|
||||
arr.push(it);
|
||||
@@ -55,19 +39,18 @@ const groups = computed(() => {
|
||||
return Array.from(map, ([name, entries]) => ({ name, entries }));
|
||||
});
|
||||
|
||||
function snapshot(list: SettingItem[]) {
|
||||
function snapshot(list: ServerSetting[]) {
|
||||
original.value = Object.fromEntries(list.map((it) => [it.key, it.value]));
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
loadError.value = "";
|
||||
try {
|
||||
const res = await api.get<{ settings: SettingItem[] }>("/api/settings");
|
||||
items.value = res.settings;
|
||||
snapshot(res.settings);
|
||||
} catch {
|
||||
error.value = "Could not load settings.";
|
||||
items.value = await repo.settings.list();
|
||||
snapshot(items.value);
|
||||
} catch (e) {
|
||||
loadError.value = errorMessage(e, "Could not load settings.");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -77,12 +60,11 @@ async function save() {
|
||||
error.value = "";
|
||||
saved.value = false;
|
||||
saving.value = true;
|
||||
const payload: Record<string, string | boolean | number> = {};
|
||||
const payload: Record<string, SettingValue> = {};
|
||||
for (const it of items.value) payload[it.key] = it.value;
|
||||
try {
|
||||
const res = await api.patch<{ settings: SettingItem[] }>("/api/settings", { settings: payload });
|
||||
items.value = res.settings;
|
||||
snapshot(res.settings);
|
||||
items.value = await repo.settings.save(payload);
|
||||
snapshot(items.value);
|
||||
saved.value = true;
|
||||
await config.reload(); // header/site name reflects changes immediately
|
||||
} catch (e) {
|
||||
@@ -98,7 +80,7 @@ const testing = ref(false);
|
||||
async function sendTestEmail() {
|
||||
testing.value = true;
|
||||
try {
|
||||
const res = await api.post<{ to: string }>("/api/settings/test-email");
|
||||
const res = await repo.settings.sendTestEmail();
|
||||
ui.showToast(`Test email sent to ${res.to}.`);
|
||||
} catch (e) {
|
||||
ui.showToast(errorMessage(e, "Couldn't send a test email."));
|
||||
@@ -112,121 +94,93 @@ onMounted(load);
|
||||
|
||||
<template>
|
||||
<div class="mx-auto min-h-full max-w-2xl px-4 py-8">
|
||||
<header class="mb-8 flex items-center gap-3">
|
||||
<RouterLink to="/" class="icon-btn" title="Back to board" aria-label="Back to board">
|
||||
<svg
|
||||
class="h-[18px] w-[18px]"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="m15 18-6-6 6-6" />
|
||||
</svg>
|
||||
</RouterLink>
|
||||
<h1 class="text-xl font-bold tracking-tight">Settings</h1>
|
||||
</header>
|
||||
<PageHeader title="Settings" />
|
||||
|
||||
<div v-if="loading" class="py-20 text-center text-sm text-neutral-400">Loading…</div>
|
||||
<AsyncState :loading="loading" :error="loadError" error-title="Couldn't load settings" @retry="load">
|
||||
<form class="flex flex-col gap-8" @submit.prevent="save">
|
||||
<section v-for="group in groups" :key="group.name" class="flex flex-col gap-5">
|
||||
<h2 class="text-xs font-semibold uppercase tracking-wide text-neutral-400">{{ group.name }}</h2>
|
||||
|
||||
<div v-else-if="error && !items.length" class="py-20 text-center">
|
||||
<h2 class="text-lg font-semibold text-neutral-700 dark:text-neutral-200">Couldn't load settings</h2>
|
||||
<p class="mt-1 text-sm text-neutral-400">{{ error }}</p>
|
||||
<button
|
||||
type="button"
|
||||
class="mt-3 rounded-md border border-neutral-300 px-3 py-1.5 text-sm hover:bg-neutral-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:hover:bg-neutral-800"
|
||||
@click="load"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
<div v-for="it in group.entries" :key="it.key" class="flex flex-col gap-1">
|
||||
<div class="flex items-center justify-between gap-6">
|
||||
<label :for="it.key" class="text-sm font-medium text-neutral-800 dark:text-neutral-200">{{
|
||||
it.label
|
||||
}}</label>
|
||||
|
||||
<form v-else class="flex flex-col gap-8" @submit.prevent="save">
|
||||
<section v-for="group in groups" :key="group.name" class="flex flex-col gap-5">
|
||||
<h2 class="text-xs font-semibold uppercase tracking-wide text-neutral-400">{{ group.name }}</h2>
|
||||
|
||||
<div v-for="it in group.entries" :key="it.key" class="flex flex-col gap-1">
|
||||
<div class="flex items-center justify-between gap-6">
|
||||
<label :for="it.key" class="text-sm font-medium text-neutral-800 dark:text-neutral-200">{{
|
||||
it.label
|
||||
}}</label>
|
||||
|
||||
<input
|
||||
v-if="it.type === 'bool'"
|
||||
:id="it.key"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 accent-brand focus:outline-none focus-visible:ring-2 focus-visible:ring-brand"
|
||||
:checked="Boolean(it.value)"
|
||||
@change="it.value = ($event.target as HTMLInputElement).checked"
|
||||
/>
|
||||
<!-- min/max come from the registry. The server rejects out-of-range
|
||||
values regardless — this is so the browser says so first, rather than
|
||||
letting someone type a hop count that would disable a protection and
|
||||
only learn about it from an error banner. -->
|
||||
<input
|
||||
v-else-if="it.type === 'int'"
|
||||
:id="it.key"
|
||||
type="number"
|
||||
:min="it.minimum ?? undefined"
|
||||
:max="it.maximum ?? undefined"
|
||||
class="w-28 rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
|
||||
:value="Number(it.value)"
|
||||
@input="it.value = Number(($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
<select
|
||||
v-else-if="it.choices"
|
||||
:id="it.key"
|
||||
class="w-56 rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
|
||||
:value="String(it.value)"
|
||||
@change="it.value = ($event.target as HTMLSelectElement).value"
|
||||
>
|
||||
<option v-for="c in it.choices" :key="c" :value="c">{{ c }}</option>
|
||||
</select>
|
||||
<input
|
||||
v-else-if="it.secret"
|
||||
:id="it.key"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
:placeholder="it.is_set ? 'Saved — type to replace' : ''"
|
||||
class="w-56 rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
|
||||
:value="String(it.value)"
|
||||
@input="it.value = ($event.target as HTMLInputElement).value"
|
||||
/>
|
||||
<input
|
||||
v-else
|
||||
:id="it.key"
|
||||
type="text"
|
||||
class="w-56 rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
|
||||
:value="String(it.value)"
|
||||
@input="it.value = ($event.target as HTMLInputElement).value"
|
||||
/>
|
||||
<input
|
||||
v-if="it.type === 'bool'"
|
||||
:id="it.key"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 accent-brand focus:outline-none focus-visible:ring-2 focus-visible:ring-brand"
|
||||
:checked="Boolean(it.value)"
|
||||
@change="it.value = ($event.target as HTMLInputElement).checked"
|
||||
/>
|
||||
<!-- min/max come from the registry. The server rejects out-of-range
|
||||
values regardless — this is so the browser says so first, rather than
|
||||
letting someone type a hop count that would disable a protection and
|
||||
only learn about it from an error banner. -->
|
||||
<input
|
||||
v-else-if="it.type === 'int'"
|
||||
:id="it.key"
|
||||
type="number"
|
||||
:min="it.minimum ?? undefined"
|
||||
:max="it.maximum ?? undefined"
|
||||
class="w-28 rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
|
||||
:value="Number(it.value)"
|
||||
@input="it.value = Number(($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
<select
|
||||
v-else-if="it.choices"
|
||||
:id="it.key"
|
||||
class="w-56 rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
|
||||
:value="String(it.value)"
|
||||
@change="it.value = ($event.target as HTMLSelectElement).value"
|
||||
>
|
||||
<option v-for="c in it.choices" :key="c" :value="c">{{ c }}</option>
|
||||
</select>
|
||||
<input
|
||||
v-else-if="it.secret"
|
||||
:id="it.key"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
:placeholder="it.is_set ? 'Saved — type to replace' : ''"
|
||||
class="w-56 rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
|
||||
:value="String(it.value)"
|
||||
@input="it.value = ($event.target as HTMLInputElement).value"
|
||||
/>
|
||||
<input
|
||||
v-else
|
||||
:id="it.key"
|
||||
type="text"
|
||||
class="w-56 rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
|
||||
:value="String(it.value)"
|
||||
@input="it.value = ($event.target as HTMLInputElement).value"
|
||||
/>
|
||||
</div>
|
||||
<p class="max-w-md text-xs text-neutral-400">{{ it.description }}</p>
|
||||
</div>
|
||||
<p class="max-w-md text-xs text-neutral-400">{{ it.description }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="group.name === 'Email'" class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-neutral-300 px-3 py-1.5 text-sm hover:bg-neutral-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand disabled:opacity-50 dark:border-neutral-700 dark:hover:bg-neutral-800"
|
||||
:disabled="testing || dirty"
|
||||
@click="sendTestEmail"
|
||||
>
|
||||
Send test email
|
||||
</button>
|
||||
<span v-if="dirty" class="text-xs text-neutral-400">Save first</span>
|
||||
</div>
|
||||
</section>
|
||||
<div v-if="group.name === 'Email'" class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-neutral-300 px-3 py-1.5 text-sm hover:bg-neutral-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand disabled:opacity-50 dark:border-neutral-700 dark:hover:bg-neutral-800"
|
||||
:disabled="testing || dirty"
|
||||
@click="sendTestEmail"
|
||||
>
|
||||
Send test email
|
||||
</button>
|
||||
<span v-if="dirty" class="text-xs text-neutral-400">Save first</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<BaseButton type="submit" :loading="saving" :disabled="!dirty">Save changes</BaseButton>
|
||||
<span v-if="saved && !dirty" class="text-sm text-green-600 dark:text-green-400">Saved.</span>
|
||||
<span v-else-if="!dirty && !error" class="text-sm text-neutral-400">No unsaved changes</span>
|
||||
<span v-if="error" class="text-sm text-red-600 dark:text-red-400">{{ error }}</span>
|
||||
</div>
|
||||
</form>
|
||||
<div class="flex items-center gap-3">
|
||||
<BaseButton type="submit" :loading="saving" :disabled="!dirty">Save changes</BaseButton>
|
||||
<span v-if="saved && !dirty" class="text-sm text-green-600 dark:text-green-400">Saved.</span>
|
||||
<span v-else-if="!dirty && !error" class="text-sm text-neutral-400">No unsaved changes</span>
|
||||
<span v-if="error" class="text-sm text-red-600 dark:text-red-400">{{ error }}</span>
|
||||
</div>
|
||||
</form>
|
||||
</AsyncState>
|
||||
|
||||
<!-- Outside the settings form: each invite, account and group action saves on its own,
|
||||
and the form's Save button has nothing to do with them. -->
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useUiStore } from "../stores/ui";
|
||||
import BaseButton from "../components/BaseButton.vue";
|
||||
import BaseInput from "../components/BaseInput.vue";
|
||||
import Icon from "../components/Icon.vue";
|
||||
import PageHeader from "../components/PageHeader.vue";
|
||||
import DesktopMenuEntry from "../components/DesktopMenuEntry.vue";
|
||||
import {
|
||||
SUGGESTED_CAPTURE_SHORTCUT,
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
type UpdateStatus,
|
||||
} from "../desktop/bridge";
|
||||
import { errorMessage } from "../api/errors";
|
||||
import { formatDateTime } from "../notes/datetime";
|
||||
|
||||
// Opt-in server sync for the desktop app. Being UNLINKED is the normal resting
|
||||
// state, not an incomplete setup — the app is local-first and fully usable having
|
||||
@@ -309,11 +311,6 @@ async function disconnect() {
|
||||
}
|
||||
}
|
||||
|
||||
function fmt(iso: string | null): string {
|
||||
if (!iso) return "never";
|
||||
return new Date(iso).toLocaleString();
|
||||
}
|
||||
|
||||
// A background cycle finished — redraw from it, so this screen is never staler
|
||||
// than the board behind it.
|
||||
let stopSyncListener: (() => void) | null = null;
|
||||
@@ -335,23 +332,7 @@ onBeforeUnmount(() => stopSyncListener?.());
|
||||
|
||||
<template>
|
||||
<div class="mx-auto min-h-full max-w-2xl px-4 py-8">
|
||||
<header class="mb-8 flex items-center gap-3">
|
||||
<RouterLink to="/" class="icon-btn" title="Back to board" aria-label="Back to board">
|
||||
<svg
|
||||
class="h-[18px] w-[18px]"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="m15 18-6-6 6-6" />
|
||||
</svg>
|
||||
</RouterLink>
|
||||
<h1 class="text-xl font-bold tracking-tight">Sync</h1>
|
||||
</header>
|
||||
<PageHeader title="Sync" />
|
||||
|
||||
<div v-if="loading" class="py-10 text-center text-sm text-neutral-400">Loading…</div>
|
||||
|
||||
@@ -366,7 +347,7 @@ onBeforeUnmount(() => stopSyncListener?.());
|
||||
</p>
|
||||
<p v-if="linkedAs" class="mt-0.5 text-xs text-neutral-400">as {{ linkedAs }}</p>
|
||||
<p class="mt-1 text-xs text-neutral-400">
|
||||
Last synced {{ fmt(status?.last_sync_at ?? null) }}
|
||||
Last synced {{ formatDateTime(status?.last_sync_at ?? null) }}
|
||||
<span v-if="pending"> · unsent changes on this device</span>
|
||||
</p>
|
||||
<p class="mt-0.5 text-xs text-neutral-400">
|
||||
|
||||
Reference in New Issue
Block a user