- desktop/bridge.ts listen<T>(): the no-op-off-desktop event listener that onSynced, onCaptured and onReminderDue each wrote. - size.ts roughSize(): one decimal below 10, none above, for the client download sizes (MB) and the storage line (GB). - BaseModal's title prop draws the heading row and close button that LabelsModal and ShareDialog each built. - SyncView: switchChannel and switchSource share recheck(); the four update-card radios render from two option lists. Kept: the board's and the shell's is-typing guards (they ignore different elements), the drawer's nav beside the palette's commands (one is a laid- out list with tags between its entries, the other a command list), the status pills (each colours differently), and plurals (the copy is the operator's call, raised in #5371). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,8 +2,8 @@
|
||||
// The backdrop + centered dialog panel every modal shares: a fixed dimmed overlay and
|
||||
// a bordered, rounded panel with role="dialog". Emits `close` on Escape and on a
|
||||
// backdrop pointer-down (mousedown outside the panel). The caller sizes/shadows/pads
|
||||
// the panel via `panelClass` and fills it (its own header + content) through the
|
||||
// default slot.
|
||||
// the panel via `panelClass` and fills it through the default slot; `title` adds the
|
||||
// heading row with a close button that a titled dialog wants.
|
||||
//
|
||||
// Owns the dialog's focus contract on behalf of all four callers: focus moves in on
|
||||
// open, Tab is trapped inside, and focus returns to the opener on close. Doing it
|
||||
@@ -13,6 +13,7 @@
|
||||
// NoteEditor deliberately does NOT use this — its backdrop mousedown is drag-guarded and
|
||||
// its Escape/⌘-Enter handling is bespoke (unsaved-edit safety), so it keeps its own shell.
|
||||
import { onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import Icon from "./Icon.vue";
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
@@ -22,6 +23,8 @@ withDefaults(
|
||||
align?: "start" | "center";
|
||||
/** aria-label for the panel when it has no visible heading (e.g. the command palette). */
|
||||
ariaLabel?: string;
|
||||
/** A heading row with a close button, for a dialog that has a title. */
|
||||
title?: string;
|
||||
}>(),
|
||||
{ panelClass: "w-full max-w-sm shadow-xl", align: "start" },
|
||||
);
|
||||
@@ -109,6 +112,13 @@ onBeforeUnmount(() => restoreTo?.focus?.());
|
||||
@keydown.esc="emit('close')"
|
||||
@keydown.tab="onTab"
|
||||
>
|
||||
<div
|
||||
v-if="title"
|
||||
class="flex items-center justify-between border-b border-neutral-100 px-4 py-3 dark:border-neutral-800"
|
||||
>
|
||||
<h2 class="text-sm font-semibold">{{ title }}</h2>
|
||||
<button type="button" class="icon-btn" aria-label="Close" @click="emit('close')"><Icon name="close" /></button>
|
||||
</div>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
// because a wrong guess must cost a person nothing.
|
||||
import { computed } from "vue";
|
||||
import { useConfigStore, type ClientRelease } from "../stores/config";
|
||||
import { roughSize } from "../size";
|
||||
|
||||
const config = useConfigStore();
|
||||
|
||||
@@ -108,13 +109,6 @@ const missingPlatform = computed(() =>
|
||||
const hasLinux = computed(() => Object.keys(config.clients).some((id) => id.startsWith("linux-")));
|
||||
const installCommand = `curl -fsSL ${window.location.origin}/install.sh | sh`;
|
||||
|
||||
// One decimal below 10 MB, none above: these sit in one list where a 2.7 MB
|
||||
// package and a 95 MB AppImage are compared, and "3 MB" next to "95 MB" loses the
|
||||
// only distinction that matters at the small end.
|
||||
function readableSize(bytes: number): string {
|
||||
const mb = bytes / 1024 / 1024;
|
||||
return `${mb < 10 ? mb.toFixed(1) : mb.toFixed(0)} MB`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -144,7 +138,7 @@ function readableSize(bytes: number): string {
|
||||
second source to contradict it, a wrong version here is a wrong
|
||||
answer nothing can catch. Not knowing which build it is, is also
|
||||
not a reason to withhold the download. -->
|
||||
Version {{ client.version || "unknown" }} · {{ readableSize(client.size) }}<span
|
||||
Version {{ client.version || "unknown" }} · {{ roughSize(client.size, "MB") }}<span
|
||||
v-if="client.platform === 'linux-appimage'"
|
||||
>, and the only one that updates itself in place</span
|
||||
>
|
||||
|
||||
@@ -58,11 +58,7 @@ async function doMerge(sourceId: string, targetId: string) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseModal panel-class="w-full max-w-sm shadow-xl" @close="emit('close')">
|
||||
<div class="flex items-center justify-between border-b border-neutral-100 px-4 py-3 dark:border-neutral-800">
|
||||
<h2 class="text-sm font-semibold">Manage tags</h2>
|
||||
<button type="button" class="icon-btn" aria-label="Close" @click="emit('close')"><Icon name="close" /></button>
|
||||
</div>
|
||||
<BaseModal panel-class="w-full max-w-sm shadow-xl" title="Manage tags" @close="emit('close')">
|
||||
<div class="flex flex-col gap-2 p-4">
|
||||
<form class="flex items-center gap-2" @submit.prevent="add">
|
||||
<Icon name="plus" />
|
||||
|
||||
@@ -105,11 +105,7 @@ onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseModal panel-class="w-full max-w-md shadow-xl" @close="emit('close')">
|
||||
<div class="flex items-center justify-between border-b border-neutral-100 px-4 py-3 dark:border-neutral-800">
|
||||
<h2 class="text-sm font-semibold">Share note</h2>
|
||||
<button type="button" class="icon-btn" aria-label="Close" @click="emit('close')"><Icon name="close" /></button>
|
||||
</div>
|
||||
<BaseModal panel-class="w-full max-w-md shadow-xl" title="Share note" @close="emit('close')">
|
||||
|
||||
<div class="flex flex-col gap-4 p-4">
|
||||
<p v-if="loading" class="py-4 text-center text-sm text-neutral-400">Loading…</p>
|
||||
|
||||
@@ -32,6 +32,17 @@ export interface IntegrationStatus {
|
||||
appimage_path: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `handler` with each payload of a desktop event. Returns an unlisten function,
|
||||
* or a no-op where there is no event API (the web build, or a desktop runtime that
|
||||
* doesn't expose one).
|
||||
*/
|
||||
async function listen<T>(event: string, handler: (payload: T) => void): Promise<() => void> {
|
||||
const events = window.__TAURI__?.event;
|
||||
if (!events) return () => {};
|
||||
return events.listen<T>(event, (e) => handler(e.payload));
|
||||
}
|
||||
|
||||
/** True when running inside the Tauri desktop shell (vs. the web build). */
|
||||
export function isDesktop(): boolean {
|
||||
return typeof window !== "undefined" && !!window.__TAURI__;
|
||||
@@ -200,9 +211,7 @@ const SYNCED_EVENT = "inkwell://synced";
|
||||
* there, so there is nothing to hear.
|
||||
*/
|
||||
export async function onSynced(handler: (cycle: LastCycle) => void): Promise<() => void> {
|
||||
const events = window.__TAURI__?.event;
|
||||
if (!events) return () => {};
|
||||
return events.listen<LastCycle>(SYNCED_EVENT, (e) => handler(e.payload));
|
||||
return listen<LastCycle>(SYNCED_EVENT, handler);
|
||||
}
|
||||
|
||||
export const sync = {
|
||||
@@ -295,9 +304,7 @@ export const capture = {
|
||||
* what it has until its next load, which is a stale list rather than a broken one.
|
||||
*/
|
||||
export async function onCaptured(handler: () => void): Promise<() => void> {
|
||||
const events = window.__TAURI__?.event;
|
||||
if (!events) return () => {};
|
||||
return events.listen(CAPTURED_EVENT, () => handler());
|
||||
return listen(CAPTURED_EVENT, () => handler());
|
||||
}
|
||||
|
||||
/** A reminder the desktop's reminder worker found due (desktop/src-tauri/src/reminders.rs). */
|
||||
@@ -319,7 +326,5 @@ const REMINDER_DUE_EVENT = "inkwell://reminder-due";
|
||||
* Returns an unlisten function, or a no-op where there is no event API.
|
||||
*/
|
||||
export async function onReminderDue(handler: (reminder: DueReminder) => void): Promise<() => void> {
|
||||
const events = window.__TAURI__?.event;
|
||||
if (!events) return () => {};
|
||||
return events.listen<DueReminder>(REMINDER_DUE_EVENT, (e) => handler(e.payload));
|
||||
return listen<DueReminder>(REMINDER_DUE_EVENT, handler);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
// How big something is, in words a person reads at a glance.
|
||||
|
||||
const UNITS = { MB: 1024 ** 2, GB: 1024 ** 3 } as const;
|
||||
|
||||
/**
|
||||
* `bytes` in `unit`, with one decimal below 10 and none above. Sizes sit in lists
|
||||
* where a 2.7 MB package and a 95 MB AppImage are compared, and "3 MB" next to
|
||||
* "95 MB" loses the only distinction that matters at the small end.
|
||||
*/
|
||||
export function roughSize(bytes: number, unit: keyof typeof UNITS): string {
|
||||
const n = bytes / UNITS[unit];
|
||||
return `${n < 10 ? n.toFixed(1) : n.toFixed(0)} ${unit}`;
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { formatDateTime } from "../notes/datetime";
|
||||
import { repo } from "../adapters";
|
||||
import type { StorageUse } from "../adapters/repo";
|
||||
import { useAction } from "../composables/useAction";
|
||||
import { roughSize } from "../size";
|
||||
|
||||
// The signed-in person's own account (not admin): the native clients linked to it
|
||||
// (the desktop and Android apps sync with a device bearer token issued here), its
|
||||
@@ -51,14 +52,9 @@ async function load() {
|
||||
// Attachment storage (#2939 §4): how much this account uses, against its limit.
|
||||
const storage = ref<StorageUse | null>(null);
|
||||
|
||||
function gigabytes(bytes: number): string {
|
||||
const gb = bytes / 1024 ** 3;
|
||||
return `${gb < 10 ? gb.toFixed(1) : gb.toFixed(0)} GB`;
|
||||
}
|
||||
|
||||
function storageLine(use: StorageUse): string {
|
||||
const used = gigabytes(use.used_bytes);
|
||||
return use.limit_bytes === null ? `${used} used` : `${used} of ${gigabytes(use.limit_bytes)} used`;
|
||||
const used = roughSize(use.used_bytes, "GB");
|
||||
return use.limit_bytes === null ? `${used} used` : `${used} of ${roughSize(use.limit_bytes, "GB")} used`;
|
||||
}
|
||||
|
||||
async function link() {
|
||||
|
||||
@@ -107,6 +107,46 @@ const offeredServer = computed(
|
||||
() => source.value ?? status.value?.server_url?.replace(/\/+$/, "") ?? null,
|
||||
);
|
||||
|
||||
// The two choices on the update card, each a radio with a name and a line on what
|
||||
// choosing it means.
|
||||
interface UpdateOption {
|
||||
title: string;
|
||||
detail: string;
|
||||
checked: boolean;
|
||||
pick: () => Promise<void>;
|
||||
}
|
||||
|
||||
const sourceOptions = computed<UpdateOption[]>(() => [
|
||||
{
|
||||
title: "Fabled-Git",
|
||||
detail: "Where Inkwell is published, on the channel below.",
|
||||
checked: source.value === null,
|
||||
pick: () => switchSource(null),
|
||||
},
|
||||
{
|
||||
title: offeredServer.value ?? "",
|
||||
detail:
|
||||
"The build that server holds. It passes on official builds only: every update is checked against the signature built into this app first.",
|
||||
checked: source.value !== null,
|
||||
pick: () => switchSource(offeredServer.value),
|
||||
},
|
||||
]);
|
||||
|
||||
const channelOptions = computed<UpdateOption[]>(() => [
|
||||
{
|
||||
title: "Stable",
|
||||
detail: "Released versions only.",
|
||||
checked: channel.value === "stable",
|
||||
pick: () => switchChannel("stable"),
|
||||
},
|
||||
{
|
||||
title: "Development",
|
||||
detail: "Every build that passes CI. Newer, and less tested.",
|
||||
checked: channel.value === "dev",
|
||||
pick: () => switchChannel("dev"),
|
||||
},
|
||||
]);
|
||||
|
||||
// --- Quick capture -----------------------------------------------------------
|
||||
// A desktop-local preference, so it lives here beside the update channel rather
|
||||
// than in admin Settings: that screen is the SERVER's, and this is a property of
|
||||
@@ -147,15 +187,20 @@ async function checkUpdates() {
|
||||
}
|
||||
}
|
||||
|
||||
// After a channel or source switch: the last answer described somewhere else, so it
|
||||
// means nothing now. Forget it and ask again.
|
||||
async function recheck() {
|
||||
update.value = null;
|
||||
checkedOnce.value = false;
|
||||
await checkUpdates();
|
||||
}
|
||||
|
||||
async function switchChannel(next: UpdateChannel) {
|
||||
if (next === channel.value) return;
|
||||
updateError.value = "";
|
||||
try {
|
||||
channel.value = await updateBridge.setChannel(next);
|
||||
// The previous answer described the OTHER channel, so it's meaningless now.
|
||||
update.value = null;
|
||||
checkedOnce.value = false;
|
||||
await checkUpdates();
|
||||
await recheck();
|
||||
} catch (e) {
|
||||
updateError.value = errorMessage(e, "The update channel couldn't be changed.");
|
||||
}
|
||||
@@ -166,10 +211,7 @@ async function switchSource(next: string | null) {
|
||||
updateError.value = "";
|
||||
try {
|
||||
source.value = await updateBridge.setSource(next);
|
||||
// Same reason as a channel switch: the last answer came from somewhere else.
|
||||
update.value = null;
|
||||
checkedOnce.value = false;
|
||||
await checkUpdates();
|
||||
await recheck();
|
||||
} catch (e) {
|
||||
updateError.value = errorMessage(e, "The update source couldn't be changed.");
|
||||
}
|
||||
@@ -613,35 +655,11 @@ onBeforeUnmount(() => stopSyncListener?.());
|
||||
Updates from
|
||||
</legend>
|
||||
<div class="mt-2 flex flex-col gap-2">
|
||||
<label class="flex items-start gap-2 text-sm">
|
||||
<input
|
||||
type="radio"
|
||||
class="mt-1"
|
||||
name="update-source"
|
||||
:checked="source === null"
|
||||
@change="switchSource(null)"
|
||||
/>
|
||||
<label v-for="o in sourceOptions" :key="o.title" class="flex items-start gap-2 text-sm">
|
||||
<input type="radio" class="mt-1" name="update-source" :checked="o.checked" @change="o.pick()" />
|
||||
<span>
|
||||
<span class="font-medium">Fabled-Git</span>
|
||||
<span class="block text-neutral-500 dark:text-neutral-400">
|
||||
Where Inkwell is published, on the channel below.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<label class="flex items-start gap-2 text-sm">
|
||||
<input
|
||||
type="radio"
|
||||
class="mt-1"
|
||||
name="update-source"
|
||||
:checked="source !== null"
|
||||
@change="switchSource(offeredServer)"
|
||||
/>
|
||||
<span>
|
||||
<span class="font-medium">{{ offeredServer }}</span>
|
||||
<span class="block text-neutral-500 dark:text-neutral-400">
|
||||
The build that server holds. It passes on official builds only: every
|
||||
update is checked against the signature built into this app first.
|
||||
</span>
|
||||
<span class="font-medium">{{ o.title }}</span>
|
||||
<span class="block text-neutral-500 dark:text-neutral-400">{{ o.detail }}</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
@@ -653,36 +671,11 @@ onBeforeUnmount(() => stopSyncListener?.());
|
||||
Channel
|
||||
</legend>
|
||||
<div class="mt-2 flex flex-col gap-2">
|
||||
<label class="flex items-start gap-2 text-sm">
|
||||
<input
|
||||
type="radio"
|
||||
class="mt-1"
|
||||
name="update-channel"
|
||||
value="stable"
|
||||
:checked="channel === 'stable'"
|
||||
@change="switchChannel('stable')"
|
||||
/>
|
||||
<label v-for="o in channelOptions" :key="o.title" class="flex items-start gap-2 text-sm">
|
||||
<input type="radio" class="mt-1" name="update-channel" :checked="o.checked" @change="o.pick()" />
|
||||
<span>
|
||||
<span class="font-medium">Stable</span>
|
||||
<span class="block text-neutral-500 dark:text-neutral-400">
|
||||
Released versions only.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<label class="flex items-start gap-2 text-sm">
|
||||
<input
|
||||
type="radio"
|
||||
class="mt-1"
|
||||
name="update-channel"
|
||||
value="dev"
|
||||
:checked="channel === 'dev'"
|
||||
@change="switchChannel('dev')"
|
||||
/>
|
||||
<span>
|
||||
<span class="font-medium">Development</span>
|
||||
<span class="block text-neutral-500 dark:text-neutral-400">
|
||||
Every build that passes CI. Newer, and less tested.
|
||||
</span>
|
||||
<span class="font-medium">{{ o.title }}</span>
|
||||
<span class="block text-neutral-500 dark:text-neutral-400">{{ o.detail }}</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user