Android / Build, or is the channel already serving this? (push) Successful in 4s
CI & Build / Build now, or wait for Android? (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Skipped
CI & Build / Python lint (push) Successful in 5s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 8s
CI & Build / Python tests (push) Successful in 13s
CI & Build / integration (push) Successful in 21s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 30s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Failing after 35s
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Build & push image (push) Successful in 35s
The other half of #1899. Press the combination anywhere and a 520x220 window arrives over whatever you were doing; type, Ctrl/Cmd+Enter, it is gone. The board never comes forward, which is the whole point — bringing the app up to write one line is the friction this removes. ## There is no default shortcut, deliberately A global shortcut is the one setting here that can collide with software this app knows nothing about. Any default is a key combination taken away from something on somebody's machine, silently, at install time. So the feature is OFF until a combination is chosen, and choosing one is how it turns on. CommandOrControl+Shift+N is offered as a one-click suggestion, never applied on the user's behalf. ## Stored and live are reported separately `CaptureShortcut` carries both `shortcut` and `registered`, because they genuinely disagree: a combination another app grabbed first is saved and does nothing when pressed, and on Wayland a compositor may refuse global grabs outright. Saying only "your shortcut is X" would be a lie with a keystroke attached, so the settings row says "saved but isn't active — something else is holding it". `capture_shortcut_set` registers BEFORE storing, so a combination the system refuses is never written down as though it worked. Registration at startup is best-effort and logged: a shortcut that worked when it was chosen can be taken by something installed later, and the app must still open. ## Two windows, one database, no shared store The capture window runs a second copy of the frontend with its own Pinia stores, so a note saved there is invisible to the board until it is told. It is told — `capture_done(saved)` emits to `main`, and BoardView reloads. The emit failing is cosmetic (the note is already in SQLite) so it is logged, not raised. The window is opened at `index.html?capture=1` rather than at `/capture` because the bundled assets are served as FILES: a path with no file behind it 404s in the production build while routing fine under the dev server. The router turns the query into the route. It is hidden rather than closed on the way out, and it keeps its text. A capture interrupted by something more urgent is still there on the next press, which is what makes Escape safe to press. A failed save also keeps the window open holding the text — hiding it would throw away the only copy of something just written in order to report a problem you could retry your way out of. ## Where the setting lives Rule 25 says a tunable belongs in the UI, and this one has to be. It sits in the desktop's Sync screen beside the update channel, not in admin Settings: that screen is the SERVER's and bounces on desktop anyway, while this is a property of one installation on one machine. Persisted with the same `store::set_pref` the update channel uses. No @tauri-apps/api dependency was added — everything routes through `invoke` and the `withGlobalTauri` global, as the rest of the bridge does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c
634 lines
23 KiB
Vue
634 lines
23 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onMounted, ref } from "vue";
|
|
import { useUiStore } from "../stores/ui";
|
|
import BaseButton from "../components/BaseButton.vue";
|
|
import BaseInput from "../components/BaseInput.vue";
|
|
import Icon from "../components/Icon.vue";
|
|
import {
|
|
SUGGESTED_CAPTURE_SHORTCUT,
|
|
capture as captureBridge,
|
|
sync as syncBridge,
|
|
updates as updateBridge,
|
|
type CaptureShortcut,
|
|
type Compatibility,
|
|
type ProbeResult,
|
|
type RevokeOutcome,
|
|
type SyncStatus,
|
|
type UpdateChannel,
|
|
type UpdateStatus,
|
|
} from "../desktop/bridge";
|
|
|
|
// 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
|
|
// never touched this screen. The copy has to carry that, or every new user will
|
|
// think something is broken.
|
|
const ui = useUiStore();
|
|
|
|
const status = ref<SyncStatus | null>(null);
|
|
const pending = ref(false);
|
|
const loading = ref(true);
|
|
|
|
// Connect form
|
|
const url = ref("");
|
|
const mode = ref<"password" | "token">("password");
|
|
const email = ref("");
|
|
const password = ref("");
|
|
const token = ref("");
|
|
const deviceName = ref("");
|
|
|
|
const probing = ref(false);
|
|
const probe = ref<ProbeResult | null>(null);
|
|
const probeError = ref("");
|
|
|
|
const linking = ref(false);
|
|
const linkError = ref("");
|
|
const linkedAs = ref("");
|
|
const degraded = ref<string[]>([]);
|
|
|
|
const syncing = ref(false);
|
|
const syncError = ref("");
|
|
const lastResult = ref("");
|
|
|
|
/** Set only when unlinking left the token valid server-side (see revokeWarning). */
|
|
const unlinkWarning = ref("");
|
|
|
|
const linked = computed(() => status.value?.linked === true);
|
|
|
|
/** Only offer to connect once a probe has said the server is usable. */
|
|
const canLink = computed(() => {
|
|
if (!probe.value || probe.value.compatibility.status === "incompatible") return false;
|
|
return mode.value === "token" ? token.value.trim().length > 0 : email.value.trim().length > 0 && password.value.length > 0;
|
|
});
|
|
|
|
function describe(c: Compatibility): string {
|
|
if (c.status === "ok") return "Fully compatible.";
|
|
if (c.status === "degraded") {
|
|
return `Compatible, but these features aren't available on this server: ${c.unavailable.join(", ")}.`;
|
|
}
|
|
return c.reason;
|
|
}
|
|
|
|
// --- App updates (M10.9). Independent of sync: an unlinked, server-less install
|
|
// still updates itself, which is why the feed is the release host and not a
|
|
// ThoughtSync server. ---
|
|
const channel = ref<UpdateChannel>("stable");
|
|
const update = ref<UpdateStatus | null>(null);
|
|
const checking = ref(false);
|
|
const installing = ref(false);
|
|
const updateError = ref("");
|
|
const checkedOnce = ref(false);
|
|
|
|
const updateAvailable = computed(() => !!update.value?.available);
|
|
|
|
// --- 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
|
|
// this installation on this machine.
|
|
const shortcut = ref<CaptureShortcut>({ shortcut: "", registered: false });
|
|
const shortcutDraft = ref("");
|
|
const savingShortcut = ref(false);
|
|
const shortcutError = ref("");
|
|
|
|
async function saveShortcut(value: string) {
|
|
savingShortcut.value = true;
|
|
shortcutError.value = "";
|
|
try {
|
|
shortcut.value = await captureBridge.setShortcut(value);
|
|
shortcutDraft.value = shortcut.value.shortcut;
|
|
} catch (e) {
|
|
// The message comes from the core and names the actual reason — "something
|
|
// else is already using it" reads very differently from "that is not a
|
|
// shortcut this system understands", and both are things you can act on.
|
|
shortcutError.value = String((e as { message?: string }).message ?? e);
|
|
} finally {
|
|
savingShortcut.value = false;
|
|
}
|
|
}
|
|
|
|
async function checkUpdates() {
|
|
checking.value = true;
|
|
updateError.value = "";
|
|
try {
|
|
update.value = await updateBridge.check();
|
|
channel.value = update.value.channel;
|
|
} catch (e) {
|
|
updateError.value = String((e as Error)?.message ?? e);
|
|
} finally {
|
|
checking.value = false;
|
|
checkedOnce.value = true;
|
|
}
|
|
}
|
|
|
|
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();
|
|
} catch (e) {
|
|
updateError.value = String((e as Error)?.message ?? e);
|
|
}
|
|
}
|
|
|
|
async function installUpdate() {
|
|
installing.value = true;
|
|
updateError.value = "";
|
|
try {
|
|
// On success the app restarts and this never returns; reaching the next line
|
|
// means it failed.
|
|
await updateBridge.install();
|
|
} catch (e) {
|
|
updateError.value = String((e as Error)?.message ?? e);
|
|
} finally {
|
|
installing.value = false;
|
|
}
|
|
}
|
|
|
|
async function refresh() {
|
|
try {
|
|
channel.value = await updateBridge.channel();
|
|
} catch {
|
|
// An older build without the update commands — leave the default showing
|
|
// rather than blocking the whole Sync screen on it.
|
|
}
|
|
try {
|
|
shortcut.value = await captureBridge.shortcut();
|
|
shortcutDraft.value = shortcut.value.shortcut;
|
|
} catch {
|
|
// Older build without the capture commands. Same reading as the channel
|
|
// above — show the default rather than block the screen.
|
|
}
|
|
try {
|
|
status.value = await syncBridge.status();
|
|
pending.value = await syncBridge.hasPending();
|
|
} catch {
|
|
status.value = null;
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
async function runProbe() {
|
|
probing.value = true;
|
|
probeError.value = "";
|
|
probe.value = null;
|
|
try {
|
|
probe.value = await syncBridge.probe(url.value);
|
|
} catch (e) {
|
|
probeError.value = String((e as Error)?.message ?? e);
|
|
} finally {
|
|
probing.value = false;
|
|
}
|
|
}
|
|
|
|
async function connect() {
|
|
linking.value = true;
|
|
linkError.value = "";
|
|
try {
|
|
const result = await syncBridge.link({
|
|
url: probe.value?.base_url ?? url.value,
|
|
email: mode.value === "password" ? email.value.trim() : undefined,
|
|
password: mode.value === "password" ? password.value : undefined,
|
|
token: mode.value === "token" ? token.value.trim() : undefined,
|
|
name: deviceName.value.trim() || undefined,
|
|
});
|
|
status.value = result.status;
|
|
linkedAs.value = result.identity.email;
|
|
degraded.value =
|
|
result.compatibility.status === "degraded" ? result.compatibility.unavailable : [];
|
|
// Never keep the secrets around after they've been exchanged for a token.
|
|
password.value = "";
|
|
token.value = "";
|
|
probe.value = null;
|
|
ui.showToast(`Connected to ${result.status.server_url}.`);
|
|
await syncNow();
|
|
} catch (e) {
|
|
linkError.value = String((e as Error)?.message ?? e);
|
|
} finally {
|
|
linking.value = false;
|
|
}
|
|
}
|
|
|
|
async function syncNow() {
|
|
syncing.value = true;
|
|
syncError.value = "";
|
|
try {
|
|
const outcome = await syncBridge.now();
|
|
status.value = outcome.status;
|
|
pending.value = await syncBridge.hasPending();
|
|
const received = outcome.pull.notes_applied + outcome.pull.notes_deleted;
|
|
const sent = outcome.push.created + outcome.push.applied;
|
|
const blobs = outcome.pull.blobs_downloaded;
|
|
const parts: string[] = [];
|
|
if (sent > 0) parts.push(`sent ${sent}`);
|
|
if (received > 0) parts.push(`received ${received}`);
|
|
if (blobs > 0) parts.push(`${blobs} attachment${blobs === 1 ? "" : "s"}`);
|
|
lastResult.value = parts.length ? `Synced — ${parts.join(", ")}.` : "Already up to date.";
|
|
// Attachments that didn't arrive are retried next sync, so this is a note, not
|
|
// an error — but saying nothing would leave a missing image unexplained.
|
|
if (outcome.pull.blobs_failed > 0) {
|
|
lastResult.value += ` ${outcome.pull.blobs_failed} attachment(s) didn't download — they'll retry on the next sync.`;
|
|
}
|
|
// Rejections are the server refusing a specific change — surfaced, never
|
|
// swallowed, because only the person can resolve them.
|
|
if (outcome.push.rejected > 0) {
|
|
syncError.value = `${outcome.push.rejected} change(s) the server wouldn't accept: ${outcome.push.errors.join("; ")}`;
|
|
}
|
|
} catch (e) {
|
|
syncError.value = String((e as Error)?.message ?? e);
|
|
} finally {
|
|
syncing.value = false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Advice for an unlink whose server-side revoke didn't land — empty when it did.
|
|
* Never a toast: a toast disappears, and "your token is still live" is exactly the
|
|
* kind of thing someone comes back to this screen to check.
|
|
*/
|
|
function revokeWarning(outcome: RevokeOutcome): string {
|
|
if (outcome.status === "unsupported") {
|
|
return "This server is older than in-app sign-out, so this device's token had to be left in place. Revoke it in the web app under Account → Linked devices.";
|
|
}
|
|
if (outcome.status === "failed") {
|
|
return `${outcome.reason} Until it's revoked, this device's token still works — you can revoke it in the web app under Account → Linked devices.`;
|
|
}
|
|
return "";
|
|
}
|
|
|
|
async function disconnect() {
|
|
if (
|
|
!window.confirm(
|
|
"Stop syncing with this server?\n\nYour notes stay on this device, and the copy on the server is left alone. This device's access token is revoked, so it can't be used to reach the server again.",
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
try {
|
|
const result = await syncBridge.unlink();
|
|
status.value = result.status;
|
|
linkedAs.value = "";
|
|
degraded.value = [];
|
|
lastResult.value = "";
|
|
unlinkWarning.value = revokeWarning(result.revoked);
|
|
ui.showToast(
|
|
result.revoked.status === "revoked"
|
|
? "Disconnected, and this device is signed out on the server."
|
|
: "Disconnected. This device now works offline only.",
|
|
);
|
|
} catch (e) {
|
|
ui.showToast(String((e as Error)?.message ?? e));
|
|
}
|
|
}
|
|
|
|
function fmt(iso: string | null): string {
|
|
if (!iso) return "never";
|
|
return new Date(iso).toLocaleString();
|
|
}
|
|
|
|
onMounted(refresh);
|
|
</script>
|
|
|
|
<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>
|
|
|
|
<div v-if="loading" class="py-10 text-center text-sm text-neutral-400">Loading…</div>
|
|
|
|
<!-- Linked -->
|
|
<template v-else-if="linked">
|
|
<section class="mb-6 rounded-xl border border-neutral-200 p-4 dark:border-neutral-800">
|
|
<div class="flex items-start justify-between gap-4">
|
|
<div class="min-w-0">
|
|
<p class="text-sm font-medium text-neutral-800 dark:text-neutral-100">
|
|
Connected to
|
|
<span class="font-mono text-xs">{{ status?.server_url }}</span>
|
|
</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) }}
|
|
<span v-if="pending"> · unsent changes on this device</span>
|
|
</p>
|
|
</div>
|
|
<div class="flex shrink-0 gap-2">
|
|
<BaseButton :loading="syncing" @click="syncNow">Sync now</BaseButton>
|
|
<BaseButton variant="ghost" @click="disconnect">Disconnect</BaseButton>
|
|
</div>
|
|
</div>
|
|
|
|
<p v-if="lastResult && !syncError" class="mt-3 text-xs text-neutral-500 dark:text-neutral-400">
|
|
{{ lastResult }}
|
|
</p>
|
|
<p v-if="degraded.length" class="mt-3 text-xs text-amber-600 dark:text-amber-400">
|
|
This server doesn't support: {{ degraded.join(", ") }}. Everything else syncs normally.
|
|
</p>
|
|
<p v-if="syncError" class="mt-3 text-sm text-red-600 dark:text-red-400">{{ syncError }}</p>
|
|
</section>
|
|
|
|
<p class="text-xs text-neutral-400">
|
|
Your notes live on this device either way — syncing just keeps a server copy in step, so
|
|
other devices can catch up.
|
|
</p>
|
|
</template>
|
|
|
|
<!-- Not linked: the normal resting state, deliberately not framed as a problem -->
|
|
<template v-else>
|
|
<!-- The one exception to that framing: an unlink whose server-side revoke
|
|
didn't land leaves a live credential behind, and the person who unlinked
|
|
to retire a machine has to be told plainly rather than by a toast. -->
|
|
<section
|
|
v-if="unlinkWarning"
|
|
class="mb-6 rounded-xl border border-amber-300 bg-amber-50 p-4 dark:border-amber-500/40 dark:bg-amber-500/10"
|
|
role="alert"
|
|
>
|
|
<p class="text-sm font-medium text-amber-800 dark:text-amber-300">
|
|
This device's token is still valid on the server
|
|
</p>
|
|
<p class="mt-1 text-sm text-amber-700 dark:text-amber-300/80">{{ unlinkWarning }}</p>
|
|
</section>
|
|
|
|
<section
|
|
class="mb-6 rounded-xl border border-neutral-200 p-4 dark:border-neutral-800"
|
|
aria-live="polite"
|
|
>
|
|
<p class="text-sm font-medium text-neutral-800 dark:text-neutral-100">
|
|
Working offline on this device
|
|
</p>
|
|
<p class="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
|
|
Everything works without a server — your notes are stored on this machine. Connect a
|
|
ThoughtSync server if you want them to reach your other devices.
|
|
</p>
|
|
</section>
|
|
|
|
<form class="flex flex-col gap-4" @submit.prevent="probe ? connect() : runProbe()">
|
|
<div class="flex items-end gap-3">
|
|
<BaseInput
|
|
id="server-url"
|
|
v-model="url"
|
|
label="Server address"
|
|
placeholder="notes.example.com"
|
|
autocomplete="url"
|
|
class="flex-1"
|
|
/>
|
|
<BaseButton type="button" variant="ghost" :loading="probing" @click="runProbe">
|
|
Check
|
|
</BaseButton>
|
|
</div>
|
|
<p class="-mt-2 text-xs text-neutral-400">
|
|
Uses https unless you type http:// yourself.
|
|
</p>
|
|
|
|
<p v-if="probeError" class="text-sm text-red-600 dark:text-red-400">{{ probeError }}</p>
|
|
|
|
<!-- What answered, BEFORE any credentials are handed over -->
|
|
<div
|
|
v-if="probe"
|
|
class="rounded-xl border p-3 text-sm"
|
|
:class="
|
|
probe.compatibility.status === 'incompatible'
|
|
? 'border-red-300 bg-red-50 dark:border-red-900 dark:bg-red-950/30'
|
|
: 'border-neutral-200 dark:border-neutral-800'
|
|
"
|
|
>
|
|
<p class="font-medium text-neutral-800 dark:text-neutral-100">
|
|
{{ probe.server.site_name || "ThoughtSync server" }}
|
|
<span v-if="probe.server.version" class="text-xs font-normal text-neutral-400">
|
|
v{{ probe.server.version }}
|
|
</span>
|
|
</p>
|
|
<p
|
|
class="mt-1 text-xs"
|
|
:class="
|
|
probe.compatibility.status === 'incompatible'
|
|
? 'text-red-700 dark:text-red-300'
|
|
: 'text-neutral-500 dark:text-neutral-400'
|
|
"
|
|
>
|
|
{{ describe(probe.compatibility) }}
|
|
</p>
|
|
</div>
|
|
|
|
<template v-if="probe && probe.compatibility.status !== 'incompatible'">
|
|
<fieldset class="flex flex-col gap-3">
|
|
<legend class="mb-1 text-sm font-medium text-neutral-800 dark:text-neutral-100">
|
|
Sign in
|
|
</legend>
|
|
<div class="flex gap-4 text-sm">
|
|
<label class="flex items-center gap-2">
|
|
<input v-model="mode" type="radio" value="password" class="accent-brand" />
|
|
Email and password
|
|
</label>
|
|
<label class="flex items-center gap-2">
|
|
<input v-model="mode" type="radio" value="token" class="accent-brand" />
|
|
Paste a device token
|
|
</label>
|
|
</div>
|
|
|
|
<template v-if="mode === 'password'">
|
|
<BaseInput
|
|
id="sync-email"
|
|
v-model="email"
|
|
label="Email"
|
|
type="email"
|
|
autocomplete="username"
|
|
/>
|
|
<BaseInput
|
|
id="sync-password"
|
|
v-model="password"
|
|
label="Password"
|
|
type="password"
|
|
autocomplete="current-password"
|
|
/>
|
|
</template>
|
|
<template v-else>
|
|
<BaseInput
|
|
id="sync-token"
|
|
v-model="token"
|
|
label="Device token"
|
|
placeholder="Paste the token from Account → Linked devices"
|
|
/>
|
|
</template>
|
|
|
|
<BaseInput
|
|
id="sync-device-name"
|
|
v-model="deviceName"
|
|
label="Name for this device (optional)"
|
|
placeholder="e.g. My laptop"
|
|
/>
|
|
</fieldset>
|
|
|
|
<p v-if="linkError" class="text-sm text-red-600 dark:text-red-400">{{ linkError }}</p>
|
|
|
|
<div>
|
|
<BaseButton type="submit" :loading="linking" :disabled="!canLink">
|
|
<Icon name="sync" /> Connect and sync
|
|
</BaseButton>
|
|
</div>
|
|
</template>
|
|
</form>
|
|
</template>
|
|
|
|
<!-- Quick capture. Outside the linked/unlinked split for the same reason as
|
|
updates: a hotkey that writes to the local store needs no server. -->
|
|
<section class="mt-10 border-t border-neutral-200 pt-8 dark:border-neutral-800">
|
|
<h2 class="text-sm font-semibold">Quick capture</h2>
|
|
<p class="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
|
|
A system-wide shortcut that opens a small window to write a note in, without
|
|
bringing this one forward.
|
|
</p>
|
|
|
|
<div class="mt-4 flex items-end gap-3">
|
|
<BaseInput
|
|
id="capture-shortcut"
|
|
v-model="shortcutDraft"
|
|
label="Shortcut"
|
|
:placeholder="SUGGESTED_CAPTURE_SHORTCUT"
|
|
class="flex-1"
|
|
/>
|
|
<BaseButton :loading="savingShortcut" @click="saveShortcut(shortcutDraft)">Save</BaseButton>
|
|
<BaseButton
|
|
v-if="shortcut.shortcut"
|
|
variant="ghost"
|
|
:loading="savingShortcut"
|
|
@click="saveShortcut('')"
|
|
>
|
|
Turn off
|
|
</BaseButton>
|
|
</div>
|
|
|
|
<p v-if="shortcutError" class="mt-2 text-sm text-red-600 dark:text-red-400">
|
|
{{ shortcutError }}
|
|
</p>
|
|
|
|
<!-- Stored and LIVE are reported separately because they can disagree: a
|
|
combination another app grabbed first is saved here and does nothing when
|
|
pressed, and saying only "your shortcut is X" would be a lie with a
|
|
keystroke attached. -->
|
|
<p
|
|
v-else-if="shortcut.shortcut && !shortcut.registered"
|
|
class="mt-2 text-sm text-amber-700 dark:text-amber-400"
|
|
>
|
|
{{ shortcut.shortcut }} is saved but isn't active — something else on this
|
|
system is holding it. Try a different combination.
|
|
</p>
|
|
<p v-else-if="shortcut.registered" class="mt-2 text-sm text-neutral-500 dark:text-neutral-400">
|
|
Press {{ shortcut.shortcut }} anywhere to capture a note.
|
|
</p>
|
|
<p v-else class="mt-2 text-sm text-neutral-500 dark:text-neutral-400">
|
|
Off. There's no default on purpose — any combination picked for you is one
|
|
taken away from something else on your machine.
|
|
<button
|
|
type="button"
|
|
class="underline hover:text-neutral-700 dark:hover:text-neutral-300"
|
|
@click="saveShortcut(SUGGESTED_CAPTURE_SHORTCUT)"
|
|
>
|
|
Use {{ SUGGESTED_CAPTURE_SHORTCUT }}
|
|
</button>
|
|
</p>
|
|
</section>
|
|
|
|
<!-- Updates sit outside the linked/unlinked split on purpose: an install that
|
|
has never touched a server still updates itself. -->
|
|
<section class="mt-10 border-t border-neutral-200 pt-8 dark:border-neutral-800">
|
|
<h2 class="text-sm font-semibold">App updates</h2>
|
|
<p class="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
|
|
This is version {{ update?.current_version ?? "—" }}.
|
|
</p>
|
|
|
|
<fieldset class="mt-4">
|
|
<legend class="text-xs font-semibold uppercase tracking-wide text-neutral-400">
|
|
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')"
|
|
/>
|
|
<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>
|
|
</label>
|
|
</div>
|
|
</fieldset>
|
|
|
|
<p
|
|
v-if="update?.blocked_reason"
|
|
class="mt-4 rounded-lg bg-black/5 px-3 py-2 text-sm text-neutral-600 dark:bg-white/10 dark:text-neutral-300"
|
|
>
|
|
{{ update.blocked_reason }}
|
|
</p>
|
|
|
|
<div v-if="updateAvailable" class="mt-4 rounded-lg bg-brand/10 px-3 py-2.5">
|
|
<p class="text-sm font-medium">Version {{ update?.available }} is available.</p>
|
|
<p v-if="update?.notes" class="mt-1 whitespace-pre-line text-sm text-neutral-600 dark:text-neutral-300">
|
|
{{ update.notes }}
|
|
</p>
|
|
</div>
|
|
<p
|
|
v-else-if="checkedOnce && !updateError"
|
|
class="mt-4 text-sm text-neutral-500 dark:text-neutral-400"
|
|
>
|
|
You're on the newest {{ channel === "dev" ? "development" : "stable" }} build.
|
|
</p>
|
|
|
|
<p v-if="updateError" class="mt-4 text-sm text-red-600 dark:text-red-400">{{ updateError }}</p>
|
|
|
|
<div class="mt-4 flex flex-wrap gap-2">
|
|
<BaseButton variant="ghost" :loading="checking" @click="checkUpdates">
|
|
Check for updates
|
|
</BaseButton>
|
|
<BaseButton
|
|
v-if="updateAvailable && update?.can_install"
|
|
:loading="installing"
|
|
@click="installUpdate"
|
|
>
|
|
Install and restart
|
|
</BaseButton>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</template>
|