stan44 7562cf6fad Add gateway root adoption and mobile polish
- Add Tauri commands to inspect and adopt the gateway repo root
- Retry locked vault commands by prompting for unlock
- Improve mobile layout, editor mode toggles, and settings UI
2026-03-30 00:00:25 -05:00

925 lines
23 KiB
Svelte

<!-- @format -->
<script lang="ts">
import { goto } from "$app/navigation";
import AppModal from "$lib/components/AppModal.svelte";
import Navbar from "$lib/components/Navbar.svelte";
import {
addFragmentType,
addSettingsTag,
removeFragmentType,
removeSettingsTag,
setDefaultStartupView,
settingsDefaultStartupView,
settingsFragmentTypes,
settingsTags,
updateFragmentType,
updateSettingsTag,
} from "$lib/stores/settings";
import { invoke, isTauriRuntime } from "$lib/runtime/invoke";
import { onMount } from "svelte";
const activeSection = "settings";
let modalOpen = false;
let modalTitle = "";
let modalMessage = "";
let modalConfirmText = "OK";
let modalCancelText = "Cancel";
let modalShowCancel = false;
let modalTone: "default" | "danger" = "default";
let modalAction: "logout-confirm" | "logout-info" | null = null;
let newTag = "";
let newFragmentType = "";
let editingTagIndex: number | null = null;
let editingTagValue = "";
let editingFragmentTypeIndex: number | null = null;
let editingFragmentTypeValue = "";
let sidecarRoot = "";
let sidecarRootIsCustom = false;
let sidecarRootError = "";
let sidecarBrowseBusy = false;
let gatewayRootBusy = false;
let gatewayRootError = "";
let gatewayRootStatus: {
authoritativeRoot: string;
gatewayConfigPath: string;
configuredRoot?: string | null;
needsAdoption: boolean;
} | null = null;
let returnSection = "entries";
onMount(async () => {
const queryReturn =
new URLSearchParams(window.location.search)
.get("return")
?.trim()
.toLowerCase() ?? "";
if (
["entries", "calendar", "fragments", "todos", "lists"].includes(
queryReturn,
)
) {
returnSection = queryReturn;
}
try {
const result: any = await invoke("get_sidecar_root");
sidecarRoot = result.root;
sidecarRootIsCustom = result.isCustom;
} catch (e) {
sidecarRootError = String(e);
}
await refreshGatewayRootStatus();
});
async function saveSidecarRoot() {
sidecarRootError = "";
try {
const result: any = await invoke("set_sidecar_root", {
path: sidecarRoot,
});
sidecarRoot = result.root;
sidecarRootIsCustom = result.isCustom;
await refreshGatewayRootStatus();
} catch (e) {
sidecarRootError = String(e);
}
}
async function resetSidecarRoot() {
sidecarRootError = "";
try {
const result: any = await invoke("set_sidecar_root", { path: "" });
sidecarRoot = result.root;
sidecarRootIsCustom = result.isCustom;
await refreshGatewayRootStatus();
} catch (e) {
sidecarRootError = String(e);
}
}
async function browseSidecarRoot() {
sidecarRootError = "";
if (!isTauriRuntime()) {
sidecarRootError = "Folder picker is only available in desktop app.";
return;
}
sidecarBrowseBusy = true;
try {
const { open } = await import("@tauri-apps/plugin-dialog");
const picked = await open({
directory: true,
multiple: false,
title: "Select Sidecar Root Directory",
});
if (typeof picked === "string" && picked.trim()) {
sidecarRoot = picked;
await saveSidecarRoot();
}
} catch (e) {
sidecarRootError = String(e);
} finally {
sidecarBrowseBusy = false;
}
}
async function refreshGatewayRootStatus() {
gatewayRootError = "";
gatewayRootStatus = null;
if (!isTauriRuntime()) {
return;
}
try {
gatewayRootStatus = await invoke<any>("get_gateway_root_status");
} catch (e) {
gatewayRootError = String(e);
}
}
async function adoptGatewayRoot() {
gatewayRootError = "";
if (!isTauriRuntime()) {
gatewayRootError =
"Gateway root adoption is only available in the desktop app.";
return;
}
gatewayRootBusy = true;
try {
gatewayRootStatus = await invoke<any>("adopt_sidecar_root_for_gateway");
} catch (e) {
gatewayRootError = String(e);
} finally {
gatewayRootBusy = false;
}
}
function showModal(options: {
action: "logout-confirm" | "logout-info";
title: string;
message: string;
confirmText?: string;
cancelText?: string;
showCancel?: boolean;
tone?: "default" | "danger";
}) {
modalAction = options.action;
modalTitle = options.title;
modalMessage = options.message;
modalConfirmText = options.confirmText ?? "OK";
modalCancelText = options.cancelText ?? "Cancel";
modalShowCancel = options.showCancel ?? false;
modalTone = options.tone ?? "default";
modalOpen = true;
}
function closeModal() {
modalOpen = false;
modalAction = null;
}
function handleModalConfirm() {
if (modalAction === "logout-confirm") {
showModal({
action: "logout-info",
title: "Logout Requested",
message: "You have been logged out.",
confirmText: "Close",
});
return;
}
closeModal();
}
function handleSelect(id: string) {
if (id === "logout") {
showModal({
action: "logout-confirm",
title: "Confirm Logout",
message: "Are you sure you want to log out?",
confirmText: "Log Out",
cancelText: "Cancel",
showCancel: true,
tone: "danger",
});
return;
}
if (id === "settings") {
return;
}
if (id === "account") {
goto("/account");
return;
}
goto(`/?section=${encodeURIComponent(id)}`);
}
function closeSettings() {
goto(`/?section=${encodeURIComponent(returnSection)}`);
}
function addTag() {
if (addSettingsTag(newTag)) {
newTag = "";
}
}
function startEditTag(index: number, tag: string) {
editingTagIndex = index;
editingTagValue = tag;
}
function saveEditTag() {
if (editingTagIndex === null) return;
if (updateSettingsTag(editingTagIndex, editingTagValue)) {
editingTagIndex = null;
editingTagValue = "";
}
}
function cancelEditTag() {
editingTagIndex = null;
editingTagValue = "";
}
function removeTag(index: number) {
if (!removeSettingsTag(index)) return;
if (editingTagIndex === index) {
cancelEditTag();
}
}
function addFragmentTypeLocal() {
if (addFragmentType(newFragmentType)) {
newFragmentType = "";
}
}
function startEditFragmentType(index: number, fragmentType: string) {
editingFragmentTypeIndex = index;
editingFragmentTypeValue = fragmentType;
}
function saveEditFragmentType() {
if (editingFragmentTypeIndex === null) return;
if (
updateFragmentType(editingFragmentTypeIndex, editingFragmentTypeValue)
) {
editingFragmentTypeIndex = null;
editingFragmentTypeValue = "";
}
}
function cancelEditFragmentType() {
editingFragmentTypeIndex = null;
editingFragmentTypeValue = "";
}
function removeFragmentTypeLocal(index: number) {
if (!removeFragmentType(index)) return;
if (editingFragmentTypeIndex === index) {
cancelEditFragmentType();
}
}
function updateDefaultStartupView(value: string) {
setDefaultStartupView(value);
}
</script>
<div class="app-shell panel-closed">
<Navbar {activeSection} onSelect={handleSelect} />
<main class="route-view">
<header class="route-header">
<div class="route-header-main">
<h1>Settings</h1>
<p>Configure app behavior and interface options.</p>
</div>
<button
type="button"
class="header-close-btn"
on:click={closeSettings}
aria-label="Close settings"
>
<span class="material-symbols-outlined" aria-hidden="true">close</span>
</button>
</header>
<div class="settings-grid">
<section class="route-card">
<div class="card-head">
<h2 class="card-title">
<span class="material-symbols-outlined" aria-hidden="true"
>rocket_launch</span
>
Startup
</h2>
</div>
<label>
Default startup view
<select
value={$settingsDefaultStartupView}
on:change={(event) =>
updateDefaultStartupView(
(event.currentTarget as HTMLSelectElement).value,
)}
>
<option value="entries">Entries</option>
<option value="calendar">Calendar</option>
<option value="fragments">Fragments</option>
<option value="todos">To-Do List</option>
<option value="lists">Lists</option>
</select>
</label>
</section>
<section class="route-card list-card">
<div class="card-head">
<h2 class="card-title">
<span class="material-symbols-outlined" aria-hidden="true"
>label</span
>
Tags
</h2>
<p class="section-copy">
Add and manage tags used for notes and entries.
</p>
</div>
<div class="create-row">
<input
type="text"
placeholder="Add tag (example: Research)"
bind:value={newTag}
on:keydown={(event) => event.key === "Enter" && addTag()}
/>
<button type="button" class="secondary-btn" on:click={addTag}
>Add</button
>
</div>
<ul class="item-list">
{#each $settingsTags as tag, index}
<li class="item-row">
{#if editingTagIndex === index}
<input
type="text"
bind:value={editingTagValue}
on:keydown={(event) => {
if (event.key === "Enter") saveEditTag();
if (event.key === "Escape") cancelEditTag();
}}
/>
<div class="row-actions">
<button
type="button"
class="secondary-btn"
on:click={saveEditTag}>Save</button
>
<button
type="button"
class="ghost-btn"
on:click={cancelEditTag}>Cancel</button
>
</div>
{:else}
<span>{tag}</span>
<div class="row-actions">
<button
type="button"
class="ghost-btn"
on:click={() => startEditTag(index, tag)}>Edit</button
>
<button
type="button"
class="danger-btn"
on:click={() => removeTag(index)}>Remove</button
>
</div>
{/if}
</li>
{/each}
</ul>
</section>
<section class="route-card list-card">
<div class="card-head">
<h2 class="card-title">
<span class="material-symbols-outlined" aria-hidden="true"
>category</span
>
Fragment Types
</h2>
<p class="section-copy">
Configure custom fragment types for the Fragments section.
</p>
</div>
<div class="create-row">
<input
type="text"
placeholder="Add fragment type (example: Observation)"
bind:value={newFragmentType}
on:keydown={(event) =>
event.key === "Enter" && addFragmentTypeLocal()}
/>
<button
type="button"
class="secondary-btn"
on:click={addFragmentTypeLocal}>Add</button
>
</div>
<ul class="item-list">
{#each $settingsFragmentTypes as type, index}
<li class="item-row">
{#if editingFragmentTypeIndex === index}
<input
type="text"
bind:value={editingFragmentTypeValue}
on:keydown={(event) => {
if (event.key === "Enter") saveEditFragmentType();
if (event.key === "Escape") cancelEditFragmentType();
}}
/>
<div class="row-actions">
<button
type="button"
class="secondary-btn"
on:click={saveEditFragmentType}>Save</button
>
<button
type="button"
class="ghost-btn"
on:click={cancelEditFragmentType}>Cancel</button
>
</div>
{:else}
<span>{type}</span>
<div class="row-actions">
<button
type="button"
class="ghost-btn"
on:click={() => startEditFragmentType(index, type)}
>Edit</button
>
<button
type="button"
class="danger-btn"
on:click={() => removeFragmentTypeLocal(index)}
>Remove</button
>
</div>
{/if}
</li>
{/each}
</ul>
</section>
<section class="route-card">
<div class="card-head">
<h2 class="card-title">
<span class="material-symbols-outlined" aria-hidden="true">hub</span
>
Sidecar
</h2>
<p class="section-copy">
Root directory containing the Journal.Sidecar project.
</p>
</div>
<div class="create-row">
<input
type="text"
placeholder="Auto-detected from working directory"
bind:value={sidecarRoot}
on:keydown={(event) => event.key === "Enter" && saveSidecarRoot()}
/>
<button
type="button"
class="ghost-btn"
on:click={browseSidecarRoot}
disabled={sidecarBrowseBusy}
>
{sidecarBrowseBusy ? "Browsing..." : "Browse"}
</button>
{#if sidecarRootIsCustom}
<button type="button" class="ghost-btn" on:click={resetSidecarRoot}
>Reset</button
>
{/if}
</div>
{#if sidecarRootError}
<p class="error-text">{sidecarRootError}</p>
{/if}
</section>
<section class="route-card">
<div class="card-head">
<h2 class="card-title">
<span class="material-symbols-outlined" aria-hidden="true"
>publish</span
>
Gateway Root
</h2>
<p class="section-copy">
Published WebGateway builds should point at one authoritative root.
Use this one-time adopt action to align the packaged gateway with
the desktop app.
</p>
</div>
{#if isTauriRuntime()}
<div class="row-actions">
<button
type="button"
class="ghost-btn"
on:click={refreshGatewayRootStatus}
disabled={gatewayRootBusy}
>
Refresh
</button>
<button
type="button"
class="secondary-btn"
on:click={adoptGatewayRoot}
disabled={gatewayRootBusy}
>
{gatewayRootBusy ? "Adopting..." : "Adopt Current Root"}
</button>
</div>
{#if gatewayRootStatus}
<label>
Desktop authoritative root
<input
type="text"
value={gatewayRootStatus.authoritativeRoot}
readonly
/>
</label>
<label>
Gateway config path
<input
type="text"
value={gatewayRootStatus.gatewayConfigPath}
readonly
/>
</label>
<label>
Gateway configured root
<input
type="text"
value={gatewayRootStatus.configuredRoot ?? "(not set)"}
readonly
/>
</label>
<label>
Status
<input
type="text"
value={gatewayRootStatus.needsAdoption ? "Needs adoption" : "Aligned"}
readonly
/>
</label>
{/if}
{:else}
<p class="section-copy">
Configure `GatewaySettings:RepoRoot` or `JOURNAL_PROJECT_ROOT`
before starting the published gateway.
</p>
{/if}
{#if gatewayRootError}
<p class="error-text">{gatewayRootError}</p>
{/if}
</section>
</div>
</main>
</div>
<AppModal
open={modalOpen}
title={modalTitle}
message={modalMessage}
confirmText={modalConfirmText}
cancelText={modalCancelText}
showCancel={modalShowCancel}
tone={modalTone}
onConfirm={handleModalConfirm}
onCancel={closeModal}
/>
<style>
.route-view {
height: 100vh;
height: 100dvh;
min-height: 0;
padding: 20px;
display: flex;
flex-direction: column;
gap: 18px;
overflow: hidden;
background: var(--bg-editor);
color: var(--text-primary);
}
.route-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
padding: 2px 2px 0;
}
.route-header-main {
display: flex;
flex-direction: column;
gap: 4px;
}
.route-header h1 {
font-size: 1.2rem;
font-weight: 700;
letter-spacing: 0.01em;
}
.route-header p {
color: var(--text-muted);
font-size: 0.88rem;
}
.header-close-btn {
width: 32px;
height: 32px;
flex-shrink: 0;
border-radius: 8px;
border: 1px solid var(--border-soft);
background: color-mix(in srgb, var(--surface-1) 92%, var(--bg-editor) 8%);
color: var(--text-muted);
display: grid;
place-items: center;
cursor: pointer;
transition:
background-color 120ms ease,
color 120ms ease,
border-color 120ms ease,
transform 120ms ease;
}
.header-close-btn .material-symbols-outlined {
font-size: 1rem;
}
.header-close-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
border-color: var(--border-strong);
transform: translateY(-1px);
}
.settings-grid {
flex: 1;
min-height: 0;
columns: 2;
column-gap: 14px;
overflow: hidden;
}
.route-card {
break-inside: avoid;
border: 1px solid var(--border-soft);
background: color-mix(in srgb, var(--surface-1) 92%, var(--bg-editor) 8%);
border-radius: 12px;
padding: 14px 14px 13px;
display: flex;
flex-direction: column;
gap: 12px;
min-height: 0;
overflow: hidden;
box-shadow:
inset 0 1px 0 color-mix(in srgb, var(--zinc-300) 8%, transparent 92%),
0 8px 24px color-mix(in srgb, var(--bg-app) 32%, transparent 68%);
margin-bottom: 14px;
}
.list-card {
overflow: hidden;
max-height: clamp(280px, 46vh, 520px);
}
.card-head {
display: flex;
flex-direction: column;
gap: 5px;
}
.card-title {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 0.96rem;
font-weight: 650;
color: var(--text-primary);
letter-spacing: 0.01em;
}
.card-title .material-symbols-outlined {
font-size: 1rem;
color: var(--text-dim);
}
.route-card label {
display: flex;
flex-direction: column;
gap: 6px;
color: var(--text-muted);
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 0.01em;
}
.route-card select {
border: 1px solid var(--border-soft);
border-radius: 8px;
background-color: color-mix(
in srgb,
var(--surface-2) 88%,
var(--bg-editor) 12%
);
color: var(--text-primary);
padding: 9px 34px 9px 10px;
font-size: 0.84rem;
}
.section-copy {
font-size: 0.8rem;
color: var(--text-muted);
line-height: 1.4;
}
.create-row {
display: flex;
gap: 8px;
align-items: center;
}
.create-row input {
flex: 1;
min-width: 0;
}
.item-list {
list-style: none;
display: flex;
flex-direction: column;
gap: 7px;
flex: 1;
min-height: 0;
overflow: auto;
padding-right: 2px;
}
.item-row {
border: 1px solid var(--border-soft);
border-radius: 8px;
padding: 9px 10px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
font-size: 0.83rem;
color: var(--text-primary);
background: color-mix(in srgb, var(--surface-1) 90%, var(--bg-editor) 10%);
}
.item-row input,
.route-card input {
border: 1px solid var(--border-soft);
border-radius: 8px;
background: color-mix(in srgb, var(--surface-2) 88%, var(--bg-editor) 12%);
color: var(--text-primary);
padding: 8px 10px;
font-size: 0.84rem;
min-width: 200px;
}
.row-actions {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.secondary-btn,
.ghost-btn,
.danger-btn {
border: 1px solid var(--border-soft);
border-radius: 8px;
padding: 7px 11px;
font-size: 0.76rem;
font-weight: 600;
letter-spacing: 0.01em;
cursor: pointer;
color: var(--text-primary);
transition:
background-color 120ms ease,
color 120ms ease,
border-color 120ms ease,
transform 120ms ease;
}
.secondary-btn {
background: color-mix(in srgb, var(--surface-2) 84%, var(--bg-hover) 16%);
border-color: var(--border-strong);
}
.ghost-btn {
background: color-mix(in srgb, var(--surface-1) 92%, var(--bg-editor) 8%);
color: var(--text-muted);
}
.danger-btn {
background: transparent;
color: var(--text-muted);
}
.secondary-btn:hover,
.ghost-btn:hover,
.danger-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
border-color: var(--border-strong);
transform: translateY(-1px);
}
.error-text {
color: #e74c3c;
font-size: 0.82rem;
}
@media (max-width: 1100px) {
.settings-grid {
columns: 1;
overflow: auto;
padding-right: 2px;
}
.list-card {
max-height: min(42dvh, 460px);
}
}
@media (max-width: 720px) {
.route-view {
padding: 14px 12px;
gap: 14px;
}
.route-card {
border-radius: 10px;
padding: 12px;
gap: 10px;
}
.route-header {
align-items: center;
}
.create-row {
flex-wrap: wrap;
}
.create-row > * {
flex: 1 1 auto;
}
.list-card {
max-height: min(48dvh, 360px);
}
.item-row {
align-items: flex-start;
flex-direction: column;
gap: 8px;
}
.item-row input,
.route-card input {
min-width: 0;
}
.row-actions {
width: 100%;
justify-content: flex-end;
}
}
</style>