Polish editors/settings UI and wire startup view + navigation

This commit is contained in:
Jacob Schmidt 2026-02-27 19:26:02 -06:00
parent 8b766a54f2
commit 0dde7c40f3
18 changed files with 1193 additions and 214 deletions

View File

@ -10,6 +10,7 @@
"license": "MIT",
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-opener": "^2"
},
"devDependencies": {
@ -1208,6 +1209,15 @@
"node": ">= 10"
}
},
"node_modules/@tauri-apps/plugin-dialog": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.6.0.tgz",
"integrity": "sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-opener": {
"version": "2.5.3",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.3.tgz",

View File

@ -14,16 +14,17 @@
"license": "MIT",
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-opener": "^2"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.6",
"@sveltejs/kit": "^2.9.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tauri-apps/cli": "^2",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"typescript": "~5.6.2",
"vite": "^6.0.3",
"@tauri-apps/cli": "^2"
"vite": "^6.0.3"
}
}

View File

@ -698,6 +698,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec"
dependencies = [
"bitflags 2.11.0",
"block2",
"libc",
"objc2",
]
@ -1776,6 +1778,7 @@ dependencies = [
"serde_json",
"tauri",
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-opener",
"tokio",
]
@ -2940,6 +2943,30 @@ dependencies = [
"web-sys",
]
[[package]]
name = "rfd"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
dependencies = [
"block2",
"dispatch2",
"glib-sys",
"gobject-sys",
"gtk-sys",
"js-sys",
"log",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation",
"raw-window-handle",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows-sys 0.60.2",
]
[[package]]
name = "rustc_version"
version = "0.4.1"
@ -3626,6 +3653,46 @@ dependencies = [
"walkdir",
]
[[package]]
name = "tauri-plugin-dialog"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9204b425d9be8d12aa60c2a83a289cf7d1caae40f57f336ed1155b3a5c0e359b"
dependencies = [
"log",
"raw-window-handle",
"rfd",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"tauri-plugin-fs",
"thiserror 2.0.18",
"url",
]
[[package]]
name = "tauri-plugin-fs"
version = "2.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed390cc669f937afeb8b28032ce837bac8ea023d975a2e207375ec05afaf1804"
dependencies = [
"anyhow",
"dunce",
"glob",
"percent-encoding",
"schemars 0.8.22",
"serde",
"serde_json",
"serde_repr",
"tauri",
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.18",
"toml 0.9.12+spec-1.1.0",
"url",
]
[[package]]
name = "tauri-plugin-opener"
version = "2.5.3"

View File

@ -19,6 +19,7 @@ tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-dialog = "2"
tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"

View File

@ -5,6 +5,7 @@
"windows": ["main"],
"permissions": [
"core:default",
"dialog:default",
"opener:default"
]
}

View File

@ -28,6 +28,7 @@ struct CommandEnvelope {
const DEFAULT_SETTINGS_TAGS: &[&str] = &["Personal", "Work", "Ideas", "Journal"];
const DEFAULT_FRAGMENT_TYPES: &[&str] = &["Quote", "Snippet", "Reference"];
const DEFAULT_STARTUP_VIEW: &str = "entries";
#[derive(Deserialize, Serialize)]
struct AppSettings {
@ -37,6 +38,8 @@ struct AppSettings {
tags: Vec<String>,
#[serde(default = "default_fragment_types")]
fragment_types: Vec<String>,
#[serde(default = "default_startup_view")]
default_startup_view: String,
}
impl Default for AppSettings {
@ -45,6 +48,7 @@ impl Default for AppSettings {
sidecar_root: None,
tags: default_settings_tags(),
fragment_types: default_fragment_types(),
default_startup_view: default_startup_view(),
}
}
}
@ -63,6 +67,10 @@ fn default_fragment_types() -> Vec<String> {
.collect()
}
fn default_startup_view() -> String {
DEFAULT_STARTUP_VIEW.to_string()
}
fn normalize_items(values: Vec<String>, fallback: &[&str]) -> Vec<String> {
let mut seen = HashSet::new();
let mut normalized = Vec::new();
@ -84,6 +92,17 @@ fn normalize_items(values: Vec<String>, fallback: &[&str]) -> Vec<String> {
normalized
}
fn normalize_startup_view(value: Option<String>) -> String {
let normalized = value
.unwrap_or_else(default_startup_view)
.trim()
.to_lowercase();
match normalized.as_str() {
"entries" | "calendar" | "fragments" | "todos" | "lists" => normalized,
_ => default_startup_view(),
}
}
struct ManagedSidecar {
child: Child,
stdin: ChildStdin,
@ -363,10 +382,12 @@ async fn get_ui_settings(state: tauri::State<'_, SidecarState>) -> Result<Value,
let settings = load_settings(&state.config_path);
let tags = normalize_items(settings.tags, DEFAULT_SETTINGS_TAGS);
let fragment_types = normalize_items(settings.fragment_types, DEFAULT_FRAGMENT_TYPES);
let startup_view = normalize_startup_view(Some(settings.default_startup_view));
Ok(serde_json::json!({
"tags": tags,
"fragmentTypes": fragment_types
"fragmentTypes": fragment_types,
"defaultStartupView": startup_view
}))
}
@ -375,15 +396,18 @@ async fn set_ui_settings(
state: tauri::State<'_, SidecarState>,
tags: Vec<String>,
fragment_types: Vec<String>,
default_startup_view: Option<String>,
) -> Result<Value, String> {
let mut settings = load_settings(&state.config_path);
settings.tags = normalize_items(tags, DEFAULT_SETTINGS_TAGS);
settings.fragment_types = normalize_items(fragment_types, DEFAULT_FRAGMENT_TYPES);
settings.default_startup_view = normalize_startup_view(default_startup_view);
save_settings(&state.config_path, &settings)?;
Ok(serde_json::json!({
"tags": settings.tags,
"fragmentTypes": settings.fragment_types
"fragmentTypes": settings.fragment_types,
"defaultStartupView": settings.default_startup_view
}))
}
@ -416,6 +440,7 @@ async fn sidecar_command(
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let app = tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![
sidecar_command,

View File

@ -1,5 +1,6 @@
<script lang="ts">
import FragmentEditor from "$lib/components/editor/FragmentEditor.svelte";
import ListEditor from "$lib/components/editor/ListEditor.svelte";
import TodoEditor from "$lib/components/editor/TodoEditor.svelte";
import MarkdownEditor from "$lib/components/editor/MarkdownEditor.svelte";
@ -36,6 +37,13 @@
{openDocumentContent}
{onDocumentContentChange}
/>
{:else if activeSection === "lists"}
<ListEditor
{openDocumentId}
{openDocumentName}
{openDocumentContent}
{onDocumentContentChange}
/>
{:else}
<MarkdownEditor
{openDocumentId}
@ -49,7 +57,7 @@
<style>
.editor-panel {
background: linear-gradient(180deg, var(--surface-2) 0%, var(--bg-editor) 100%);
background: var(--bg-editor);
padding: 18px 20px;
display: flex;
flex-direction: column;

View File

@ -208,6 +208,29 @@
queueMicrotask(() => newItemInput?.focus());
}
function toDailyNoteLabel(date: Date): string {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
}
function handleAddDailyNote() {
if (activeSection !== "entries") return;
createTemplateMode = false;
showNewItemInput = false;
newItemName = "";
const label = toDailyNoteLabel(new Date());
const existing = $entriesStore.find((item) => item.label === label && !item.id.startsWith("entries/template-draft-"));
if (existing) {
onEditItem(existing);
return;
}
const id = `entries/draft-${Date.now()}`;
const item = { id, label, initialContent: `# ${label}\n\n` };
entriesStore.update((items) => [item, ...items]);
onEditItem(item);
}
async function confirmNewItem() {
const label = newItemName.trim();
if (!label) {
@ -319,9 +342,12 @@
<h2>{panelTitle}</h2>
<div class="panel-header-actions">
{#if activeSection === "entries"}
<button type="button" class="panel-action" aria-label="Add template" title="Add template" on:click={handleAddTemplate}>
<span class="material-symbols-outlined">palette</span>
</button>
<button type="button" class="panel-action" aria-label="Add template" title="Add template" on:click={handleAddTemplate}>
<span class="material-symbols-outlined">palette</span>
</button>
<button type="button" class="panel-action" aria-label="Add daily note" title="Add daily note" on:click={handleAddDailyNote}>
<span class="material-symbols-outlined">calendar_month</span>
</button>
{/if}
<button type="button" class="panel-action" aria-label="Add item" title="Add item" on:click={handleAddItem}>
<span class="material-symbols-outlined">add</span>

View File

@ -31,6 +31,7 @@
let lastFragmentDocumentId = "";
let fragmentTypeOptions: string[] = [];
let tagOptions: string[] = [];
let suppressExternalEditRequest = false;
const customTypeValue = "__custom_type__";
const customTagValue = "__custom_tag__";
@ -129,10 +130,12 @@
function cancelFragmentEdit() {
if (fragmentMode === "create") {
fragmentMode = "view";
suppressExternalEditRequest = true;
return;
}
loadFragmentFormFromDocument();
fragmentMode = "view";
suppressExternalEditRequest = true;
}
function loadFragmentFormFromDocument() {
@ -184,7 +187,10 @@
$: if (!fragmentTag || (!tagOptions.includes(fragmentTag) && fragmentTag !== customTagValue)) {
fragmentTag = tagOptions[0] ?? customTagValue;
}
$: if (externalEditRequested && fragmentMode === "view") {
$: if (!externalEditRequested) {
suppressExternalEditRequest = false;
}
$: if (externalEditRequested && !suppressExternalEditRequest && fragmentMode === "view") {
fragmentMode = "edit";
}
</script>
@ -195,7 +201,10 @@
{@html renderMarkdown(openDocumentContent)}
</article>
{:else}
<form class="fragment-form" on:submit|preventDefault={fragmentMode === "create" ? createNewFragment : saveFragmentEdits}>
<form
class="fragment-form"
on:submit|preventDefault={fragmentMode === "create" ? createNewFragment : saveFragmentEdits}
>
<h2>{fragmentMode === "create" ? "New Fragment" : "Edit Fragment"}</h2>
<input type="text" placeholder="Title" bind:value={fragmentTitle} aria-label="Fragment title" />
<div class="fragment-form-row">
@ -254,34 +263,35 @@
.fragment-surface {
min-height: 0;
flex: 1;
padding: 8px;
display: grid;
place-items: center;
overflow: auto;
padding: 0 14px 14px;
}
.fragment-form {
width: min(760px, 100%);
border: 1px solid var(--border-soft);
border-radius: 12px;
background: var(--surface-1);
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.25);
padding: 14px;
width: min(100%, 920px);
margin: 0 auto;
border: none;
border-radius: 0;
background: transparent;
padding: 28px 36px;
display: flex;
flex-direction: column;
gap: 10px;
gap: 12px;
}
.fragment-view {
width: min(760px, 100%);
border: 1px solid var(--border-soft);
border-radius: 12px;
background: var(--surface-1);
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.25);
padding: 14px;
width: min(100%, 920px);
margin: 0 auto;
border: none;
border-radius: 0;
background: transparent;
padding: 28px 36px;
display: flex;
flex-direction: column;
gap: 14px;
color: var(--text-primary);
font-size: 0.92rem;
line-height: 1.65;
}
.fragment-view :global(h1),
@ -311,9 +321,10 @@
}
.fragment-form h2 {
font-size: 0.94rem;
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 2px;
}
.fragment-form input,
@ -322,15 +333,16 @@
width: 100%;
border: 1px solid var(--border-soft);
border-radius: 8px;
background: var(--bg-app);
background: color-mix(in srgb, var(--surface-1) 88%, var(--bg-editor) 12%);
color: var(--text-primary);
padding: 9px 10px;
font-size: 0.86rem;
padding: 10px 11px;
font-size: 0.88rem;
}
.fragment-form textarea {
resize: vertical;
min-height: 160px;
min-height: 220px;
line-height: 1.55;
}
.fragment-form-row {
@ -343,7 +355,7 @@
width: fit-content;
border-radius: 8px;
border: 1px solid var(--border-strong);
background: var(--surface-3);
background: color-mix(in srgb, var(--surface-2) 84%, var(--bg-hover) 16%);
color: var(--text-primary);
padding: 8px 12px;
font-size: 0.82rem;
@ -363,7 +375,7 @@
.fragment-secondary {
border-radius: 8px;
border: 1px solid var(--border-soft);
background: var(--surface-1);
background: color-mix(in srgb, var(--surface-1) 90%, var(--bg-editor) 10%);
color: var(--text-muted);
padding: 8px 12px;
font-size: 0.82rem;
@ -374,4 +386,21 @@
background: var(--bg-hover);
color: var(--text-primary);
}
@media (max-width: 980px) {
.fragment-surface {
padding: 4px 8px 10px;
}
.fragment-form,
.fragment-view {
width: 100%;
padding: 18px 16px;
font-size: 0.89rem;
}
.fragment-form-row {
grid-template-columns: 1fr;
}
}
</style>

View File

@ -0,0 +1,278 @@
<script lang="ts">
export let openDocumentId = "";
export let openDocumentName = "";
export let openDocumentContent = "";
export let onDocumentContentChange: (content: string) => void = () => {};
type SimpleListItem = {
id: number;
text: string;
};
let items: SimpleListItem[] = [];
let nextItemId = 1;
let lastDocumentId = "";
let newItemText = "";
let editingItemId: number | null = null;
let editingItemText = "";
function parseListItems(content: string): SimpleListItem[] {
const lines = (content ?? "").split(/\r?\n/);
const parsed: SimpleListItem[] = [];
for (const line of lines) {
const bullet = line.match(/^\s*(?:[-*+]|\d+\.)\s+(.+)$/);
if (bullet) {
parsed.push({ id: parsed.length + 1, text: bullet[1].trim() });
continue;
}
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
parsed.push({ id: parsed.length + 1, text: trimmed });
}
return parsed;
}
function serializeList(title: string, listItems: SimpleListItem[]): string {
const heading = (title ?? "").trim() || "Untitled List";
if (!listItems.length) {
return `# ${heading}\n\n`;
}
const body = listItems
.map((item) => item.text.trim())
.filter(Boolean)
.map((text) => `- ${text}`)
.join("\n");
return `# ${heading}\n\n${body}`;
}
function persist() {
onDocumentContentChange(serializeList(openDocumentName, items));
}
function resetForDocument() {
items = parseListItems(openDocumentContent);
nextItemId = (items[items.length - 1]?.id ?? 0) + 1;
newItemText = "";
editingItemId = null;
editingItemText = "";
}
function addItem() {
const text = newItemText.trim();
if (!text) return;
items = [{ id: nextItemId, text }, ...items];
nextItemId += 1;
newItemText = "";
persist();
}
function startEditItem(id: number) {
const existing = items.find((item) => item.id === id);
if (!existing) return;
editingItemId = id;
editingItemText = existing.text;
}
function saveEditItem() {
if (editingItemId === null) return;
const text = editingItemText.trim();
if (!text) return;
const id = editingItemId;
items = items.map((item) => (item.id === id ? { ...item, text } : item));
editingItemId = null;
editingItemText = "";
persist();
}
function cancelEditItem() {
editingItemId = null;
editingItemText = "";
}
function removeItem(id: number) {
if (editingItemId === id) {
cancelEditItem();
}
items = items.filter((item) => item.id !== id);
persist();
}
$: if (openDocumentId !== lastDocumentId) {
resetForDocument();
lastDocumentId = openDocumentId;
}
</script>
<section class="list-surface">
<div class="list-card">
<form class="list-create" on:submit|preventDefault={addItem}>
<input
type="text"
placeholder="Add a list item"
bind:value={newItemText}
aria-label="Add list item"
/>
<button type="submit" class="list-add-btn">Add</button>
</form>
<ul class="list-items">
{#each items as item}
<li class="list-item">
{#if editingItemId === item.id}
<input
type="text"
class="list-edit-input"
bind:value={editingItemText}
on:keydown={(event) => {
if (event.key === "Enter") saveEditItem();
if (event.key === "Escape") cancelEditItem();
}}
/>
<div class="list-actions">
<button type="button" class="list-btn save" on:click={saveEditItem}>Save</button>
<button type="button" class="list-btn ghost" on:click={cancelEditItem}>Cancel</button>
</div>
{:else}
<span class="list-text">{item.text}</span>
<div class="list-actions">
<button type="button" class="list-btn ghost" on:click={() => startEditItem(item.id)}>Edit</button>
<button type="button" class="list-btn danger" on:click={() => removeItem(item.id)}>Remove</button>
</div>
{/if}
</li>
{/each}
</ul>
</div>
</section>
<style>
.list-surface {
min-height: 0;
flex: 1;
overflow: auto;
padding: 0 14px 14px;
}
.list-card {
width: min(100%, 920px);
margin: 0 auto;
border: none;
border-radius: 0;
background: transparent;
padding: 28px 36px;
display: flex;
flex-direction: column;
gap: 12px;
max-height: 100%;
overflow: visible;
}
.list-create {
display: flex;
gap: 8px;
}
.list-create input,
.list-edit-input {
width: 100%;
border: 1px solid var(--border-soft);
border-radius: 8px;
background: color-mix(in srgb, var(--surface-1) 88%, var(--bg-editor) 12%);
color: var(--text-primary);
padding: 10px 11px;
font-size: 0.88rem;
}
.list-add-btn {
border-radius: 8px;
border: 1px solid var(--border-strong);
background: color-mix(in srgb, var(--surface-2) 84%, var(--bg-hover) 16%);
color: var(--text-primary);
padding: 9px 14px;
font-size: 0.82rem;
font-weight: 600;
cursor: pointer;
}
.list-add-btn:hover {
background: var(--bg-hover);
}
.list-items {
list-style: none;
display: flex;
flex-direction: column;
gap: 10px;
}
.list-item {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
border: 1px solid var(--border-soft);
border-radius: 8px;
padding: 10px 12px;
background: color-mix(in srgb, var(--surface-1) 90%, var(--bg-editor) 10%);
}
.list-text {
font-size: 0.9rem;
color: var(--text-primary);
line-height: 1.45;
word-break: break-word;
}
.list-actions {
display: flex;
gap: 6px;
}
.list-btn {
border-radius: 7px;
border: 1px solid var(--border-soft);
background: color-mix(in srgb, var(--surface-1) 90%, var(--bg-editor) 10%);
color: var(--text-muted);
padding: 6px 10px;
font-size: 0.78rem;
cursor: pointer;
}
.list-btn.save {
border-color: var(--border-strong);
background: color-mix(in srgb, var(--surface-2) 84%, var(--bg-hover) 16%);
color: var(--text-primary);
}
.list-btn.danger:hover,
.list-btn.ghost:hover,
.list-btn.save:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
@media (max-width: 980px) {
.list-surface {
padding: 4px 8px 10px;
}
.list-card {
width: 100%;
padding: 18px 16px;
}
.list-item {
grid-template-columns: minmax(0, 1fr);
row-gap: 8px;
}
.list-actions {
justify-content: flex-end;
}
}
</style>

View File

@ -1,5 +1,6 @@
<script lang="ts">
import { listEntryTemplates, loadEntryTemplate, type EntryTemplateItemDto } from "$lib/backend/templates";
import MarkdownToolbar from "$lib/components/editor/MarkdownToolbar.svelte";
import { renderMarkdown, extractEditorTitle } from "$lib/utils/markdown";
import { onMount } from "svelte";
@ -8,6 +9,8 @@
export let openDocumentContent = "";
export let onDocumentContentChange: (content: string) => void = () => {};
type ListMode = "ul" | "ol" | null;
let markdownText = openDocumentContent;
let lastOpenDocumentId = openDocumentId;
export let previewOnly = true;
@ -16,6 +19,7 @@
let templatesBusy = false;
let templateError = "";
let templateRefreshRequested = false;
let listMode: ListMode = null;
function updateDraft(value: string) {
markdownText = value;
@ -67,6 +71,51 @@
applyWrap("[", "](https://example.com)");
}
function lineMatchesListMode(line: string, mode: Exclude<ListMode, null>): boolean {
if (mode === "ul") {
return /^\s*[-*+]\s/.test(line);
}
return /^\s*\d+\.\s/.test(line);
}
function isMarkerOnlyLine(line: string, mode: Exclude<ListMode, null>): boolean {
if (mode === "ul") {
return /^\s*[-*+]\s$/.test(line);
}
return /^\s*\d+\.\s$/.test(line);
}
function handleMarkdownInput(event: Event) {
const target = event.currentTarget as HTMLTextAreaElement;
const nextValue = target.value;
updateDraft(nextValue);
if (!listMode) return;
const native = event as InputEvent;
if (native.inputType !== "deleteContentBackward") return;
const cursor = target.selectionStart ?? 0;
const lineStart = nextValue.lastIndexOf("\n", Math.max(0, cursor - 1)) + 1;
const lineEndIndex = nextValue.indexOf("\n", cursor);
const lineEnd = lineEndIndex === -1 ? nextValue.length : lineEndIndex;
const line = nextValue.slice(lineStart, lineEnd);
if (!lineMatchesListMode(line, listMode)) {
listMode = null;
}
}
function toggleListMode(mode: Exclude<ListMode, null>) {
if (listMode === mode) {
listMode = null;
return;
}
listMode = mode;
applyLinePrefix(mode === "ul" ? "- " : "1. ");
queueMicrotask(() => editorInput?.focus());
}
async function refreshTemplates() {
templatesBusy = true;
templateError = "";
@ -111,6 +160,72 @@
}
}
function handleMarkdownKeydown(event: KeyboardEvent) {
if (!editorInput) return;
if (event.key === "Escape" && listMode) {
event.preventDefault();
const current = markdownText;
const start = editorInput.selectionStart ?? current.length;
const lineStart = current.lastIndexOf("\n", Math.max(0, start - 1)) + 1;
const lineEndIndex = current.indexOf("\n", start);
const lineEnd = lineEndIndex === -1 ? current.length : lineEndIndex;
const line = current.slice(lineStart, lineEnd);
if (isMarkerOnlyLine(line, listMode)) {
const next = `${current.slice(0, lineStart)}${current.slice(lineEnd)}`;
updateDraft(next);
queueMicrotask(() => {
if (!editorInput) return;
editorInput.focus();
editorInput.setSelectionRange(lineStart, lineStart);
});
}
listMode = null;
return;
}
if (event.key !== "Enter") return;
const current = markdownText;
const start = editorInput.selectionStart ?? current.length;
const lineStart = current.lastIndexOf("\n", Math.max(0, start - 1)) + 1;
const lineUntilCursor = current.slice(lineStart, start);
let effectiveListMode: ListMode = listMode;
if (!effectiveListMode) {
if (/^\s*[-*+]\s/.test(lineUntilCursor)) {
effectiveListMode = "ul";
} else if (/^\s*\d+\.\s/.test(lineUntilCursor)) {
effectiveListMode = "ol";
}
}
if (!effectiveListMode) return;
event.preventDefault();
let marker = "- ";
if (effectiveListMode === "ol") {
const match = lineUntilCursor.match(/^(\s*)(\d+)\.\s/);
marker = match ? `${match[1]}${Number(match[2]) + 1}. ` : "1. ";
} else {
const match = lineUntilCursor.match(/^(\s*)[-*+]\s/);
marker = match ? `${match[1]}- ` : "- ";
}
listMode = effectiveListMode;
const insertion = `\n${marker}`;
const next = `${current.slice(0, start)}${insertion}${current.slice(start)}`;
updateDraft(next);
queueMicrotask(() => {
if (!editorInput) return;
const cursor = start + insertion.length;
editorInput.focus();
editorInput.setSelectionRange(cursor, cursor);
});
}
onMount(() => {
void refreshTemplates();
});
@ -127,6 +242,7 @@
$: if (openDocumentId !== lastOpenDocumentId) {
markdownText = openDocumentContent;
lastOpenDocumentId = openDocumentId;
listMode = null;
}
$: isEntryDocument = openDocumentId.startsWith("entries/file/") || openDocumentId.startsWith("entries/draft-");
$: editorTitle = extractEditorTitle(markdownText, openDocumentName);
@ -139,52 +255,20 @@
<section class="editor-surface" class:preview-only={previewOnly}>
{#if !previewOnly}
<div class="editor-toolbar">
<select
class="toolbar-select"
aria-label="Header size"
on:change={(event) => {
const target = event.currentTarget as HTMLSelectElement;
const level = Number(target.value);
if (level) applyHeading(level);
target.value = "";
}}
>
<option value="">Heading</option>
<option value="1">H1</option>
<option value="2">H2</option>
<option value="3">H3</option>
<option value="4">H4</option>
<option value="5">H5</option>
<option value="6">H6</option>
</select>
{#if isEntryDocument}
<select
class="toolbar-select"
aria-label="Insert template"
disabled={templatesBusy}
on:change={(event) => {
const target = event.currentTarget as HTMLSelectElement;
const filePath = target.value;
if (filePath) {
void applyTemplateByPath(filePath);
}
target.value = "";
}}
>
<option value="">{templatesBusy ? "Loading templates..." : "Template"}</option>
{#each templateOptions as template}
<option value={template.filePath}>{template.fileName.replace(/\.template\.md$/i, "")}</option>
{/each}
</select>
{/if}
<button type="button" on:click={() => applyWrap("**")}>Bold</button>
<button type="button" on:click={() => applyWrap("*")}>Italic</button>
<button type="button" on:click={insertLink}>Link</button>
<button type="button" on:click={() => applyLinePrefix("- ")}>UL</button>
<button type="button" on:click={() => applyLinePrefix("1. ")}>OL</button>
<button type="button" on:click={() => applyWrap("`")}>Code</button>
</div>
<MarkdownToolbar
{isEntryDocument}
{templatesBusy}
{templateOptions}
{listMode}
onApplyHeading={applyHeading}
onApplyTemplate={(filePath) => void applyTemplateByPath(filePath)}
onBold={() => applyWrap("**")}
onItalic={() => applyWrap("*")}
onLink={insertLink}
onToggleUl={() => toggleListMode("ul")}
onToggleOl={() => toggleListMode("ol")}
onCode={() => applyWrap("`")}
/>
{#if templateError}
<p class="template-error">{templateError}</p>
{/if}
@ -200,7 +284,8 @@
bind:this={editorInput}
class="markdown-input"
bind:value={markdownText}
on:input={(event) => updateDraft((event.currentTarget as HTMLTextAreaElement).value)}
on:input={handleMarkdownInput}
on:keydown={handleMarkdownKeydown}
aria-label="Markdown input"
></textarea>
{/if}
@ -224,94 +309,59 @@
.editor-surface {
min-height: 0;
flex: 1;
border-radius: 10px;
border: 1px solid var(--border-soft);
background: var(--surface-1);
padding: 10px;
border-radius: 0;
background: transparent;
padding: 0;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: 8px;
gap: 10px;
}
.editor-surface.preview-only {
grid-template-rows: minmax(0, 1fr);
}
.editor-toolbar {
display: flex;
flex-wrap: wrap;
gap: 6px;
border: 1px solid var(--border-soft);
border-radius: 8px;
background: var(--bg-app);
padding: 6px;
}
.editor-toolbar button {
border-radius: 6px;
border: 1px solid var(--border-soft);
background: var(--surface-2);
color: var(--text-muted);
padding: 5px 8px;
font-size: 0.74rem;
cursor: pointer;
}
.toolbar-select {
border-radius: 6px;
border: 1px solid var(--border-soft);
background: var(--surface-2);
color: var(--text-muted);
padding: 5px 8px;
font-size: 0.74rem;
cursor: pointer;
}
.editor-toolbar button:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.toolbar-select:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.template-error {
color: #e74c3c;
font-size: 0.78rem;
margin: -2px 0 0;
padding: 0 14px;
}
.editor-workspace {
min-height: 0;
height: 100%;
display: block;
overflow: auto;
padding: 0 14px 14px;
}
.markdown-input,
.markdown-preview {
min-height: 0;
width: 100%;
height: 100%;
border: 1px solid var(--border-soft);
border-radius: 8px;
background: var(--bg-app);
width: min(100%, 920px);
height: auto;
min-height: 100%;
margin: 0 auto;
border: none;
border-radius: 0;
background: transparent;
color: var(--text-primary);
padding: 12px;
font-size: 0.88rem;
line-height: 1.5;
overflow: auto;
padding: 28px 36px;
font-size: 0.92rem;
line-height: 1.65;
overflow: visible;
}
.markdown-input {
display: block;
resize: none;
overflow: auto;
font-family: "Segoe UI Variable", "Segoe UI", Inter, Roboto, Helvetica, Arial, sans-serif;
}
.markdown-input:focus {
outline: none;
border-color: var(--border-strong);
box-shadow: none;
}
.markdown-preview :global(h1),
@ -362,4 +412,22 @@
color: var(--text-primary);
text-decoration: underline;
}
@media (max-width: 980px) {
.editor-workspace {
padding: 4px 8px 10px;
}
.template-error {
padding: 0 8px;
}
.markdown-input,
.markdown-preview {
width: 100%;
border-radius: 0;
padding: 18px 16px;
font-size: 0.89rem;
}
}
</style>

View File

@ -0,0 +1,172 @@
<script lang="ts">
import type { EntryTemplateItemDto } from "$lib/backend/templates";
export let isEntryDocument = false;
export let templatesBusy = false;
export let templateOptions: EntryTemplateItemDto[] = [];
export let listMode: "ul" | "ol" | null = null;
export let onApplyHeading: (level: number) => void = () => {};
export let onApplyTemplate: (filePath: string) => void = () => {};
export let onBold: () => void = () => {};
export let onItalic: () => void = () => {};
export let onLink: () => void = () => {};
export let onToggleUl: () => void = () => {};
export let onToggleOl: () => void = () => {};
export let onCode: () => void = () => {};
</script>
<div class="editor-toolbar">
<div class="toolbar-group">
<select
class="toolbar-select"
aria-label="Header size"
on:change={(event) => {
const target = event.currentTarget as HTMLSelectElement;
const level = Number(target.value);
if (level) onApplyHeading(level);
target.value = "";
}}
>
<option value="">Heading</option>
<option value="1">H1</option>
<option value="2">H2</option>
<option value="3">H3</option>
<option value="4">H4</option>
<option value="5">H5</option>
<option value="6">H6</option>
</select>
{#if isEntryDocument}
<select
class="toolbar-select"
aria-label="Insert template"
disabled={templatesBusy}
on:change={(event) => {
const target = event.currentTarget as HTMLSelectElement;
const filePath = target.value;
if (filePath) {
onApplyTemplate(filePath);
}
target.value = "";
}}
>
<option value="">{templatesBusy ? "Loading templates..." : "Template"}</option>
{#each templateOptions as template}
<option value={template.filePath}>{template.fileName.replace(/\.template\.md$/i, "")}</option>
{/each}
</select>
{/if}
</div>
<div class="toolbar-divider" aria-hidden="true"></div>
<div class="toolbar-group">
<button type="button" class="toolbar-btn" on:click={onBold}>Bold</button>
<button type="button" class="toolbar-btn" on:click={onItalic}>Italic</button>
<button type="button" class="toolbar-btn" on:click={onLink}>Link</button>
<button type="button" class="toolbar-btn" class:is-active={listMode === "ul"} on:click={onToggleUl}>UL</button>
<button type="button" class="toolbar-btn" class:is-active={listMode === "ol"} on:click={onToggleOl}>OL</button>
<button type="button" class="toolbar-btn" on:click={onCode}>Code</button>
</div>
</div>
<style>
.editor-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
border: 1px solid var(--border-soft);
border-radius: 12px;
background: color-mix(in srgb, var(--surface-1) 90%, var(--zinc-700) 10%);
box-shadow:
inset 0 1px 0 color-mix(in srgb, var(--zinc-300) 9%, transparent 91%),
0 8px 24px color-mix(in srgb, var(--bg-app) 38%, transparent 62%);
padding: 8px 10px;
margin: 0 14px;
}
.toolbar-group {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
}
.toolbar-divider {
width: 1px;
height: 22px;
background: color-mix(in srgb, var(--border-soft) 78%, transparent 22%);
}
.toolbar-btn {
border-radius: 8px;
border: 1px solid var(--border-soft);
background: color-mix(in srgb, var(--surface-2) 92%, var(--zinc-700) 8%);
color: color-mix(in srgb, var(--text-muted) 86%, var(--text-primary) 14%);
padding: 6px 10px;
min-height: 30px;
font-size: 0.73rem;
letter-spacing: 0.01em;
font-weight: 600;
cursor: pointer;
transition: background-color 120ms ease, color 120ms ease, border-color 120ms ease, transform 120ms ease;
}
.toolbar-select {
border-radius: 8px;
border: 1px solid var(--border-soft);
background: color-mix(in srgb, var(--surface-2) 92%, var(--zinc-700) 8%);
color: color-mix(in srgb, var(--text-muted) 86%, var(--text-primary) 14%);
padding: 6px 10px;
min-height: 30px;
font-size: 0.73rem;
letter-spacing: 0.01em;
font-weight: 600;
cursor: pointer;
transition: background-color 120ms ease, color 120ms ease, border-color 120ms ease;
}
.toolbar-btn:hover,
.toolbar-btn:focus-visible {
background: color-mix(in srgb, var(--bg-hover) 88%, var(--surface-2) 12%);
color: var(--text-primary);
border-color: var(--border-strong);
transform: translateY(-1px);
}
.toolbar-btn:focus-visible {
outline: none;
}
.toolbar-btn.is-active {
background: color-mix(in srgb, var(--bg-active) 84%, var(--surface-2) 16%);
border-color: var(--border-strong);
color: var(--text-primary);
}
.toolbar-select:hover,
.toolbar-select:focus-visible {
background: color-mix(in srgb, var(--bg-hover) 88%, var(--surface-2) 12%);
color: var(--text-primary);
border-color: var(--border-strong);
outline: none;
}
.toolbar-select:disabled,
.toolbar-btn:disabled {
opacity: 0.55;
cursor: default;
transform: none;
}
@media (max-width: 980px) {
.editor-toolbar {
margin: 0 8px;
padding: 7px 8px;
gap: 6px;
}
.toolbar-divider {
display: none;
}
}
</style>

View File

@ -172,23 +172,22 @@
.todo-surface {
min-height: 0;
flex: 1;
padding: 8px;
display: grid;
place-items: center;
overflow: auto;
padding: 0 14px 14px;
}
.todo-card {
width: min(760px, 100%);
border: 1px solid var(--border-soft);
border-radius: 12px;
background: var(--surface-1);
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.25);
padding: 14px;
width: min(100%, 920px);
margin: 0 auto;
border: none;
border-radius: 0;
background: transparent;
padding: 28px 36px;
display: flex;
flex-direction: column;
gap: 10px;
gap: 12px;
max-height: 100%;
overflow: auto;
overflow: visible;
}
.todo-create {
@ -201,19 +200,20 @@
width: 100%;
border: 1px solid var(--border-soft);
border-radius: 8px;
background: var(--bg-app);
background: color-mix(in srgb, var(--surface-1) 88%, var(--bg-editor) 12%);
color: var(--text-primary);
padding: 8px 10px;
font-size: 0.86rem;
padding: 10px 11px;
font-size: 0.88rem;
}
.todo-add-btn {
border-radius: 8px;
border: 1px solid var(--border-strong);
background: var(--surface-3);
background: color-mix(in srgb, var(--surface-2) 84%, var(--bg-hover) 16%);
color: var(--text-primary);
padding: 8px 12px;
padding: 9px 14px;
font-size: 0.82rem;
font-weight: 600;
cursor: pointer;
}
@ -225,7 +225,7 @@
list-style: none;
display: flex;
flex-direction: column;
gap: 8px;
gap: 10px;
}
.todo-item {
@ -235,8 +235,8 @@
gap: 10px;
border: 1px solid var(--border-soft);
border-radius: 8px;
padding: 8px 10px;
background: var(--bg-app);
padding: 10px 12px;
background: color-mix(in srgb, var(--surface-1) 90%, var(--bg-editor) 10%);
}
.todo-check {
@ -245,8 +245,9 @@
}
.todo-text {
font-size: 0.86rem;
font-size: 0.9rem;
color: var(--text-primary);
line-height: 1.45;
}
.todo-text.is-done {
@ -262,7 +263,7 @@
.todo-btn {
border-radius: 7px;
border: 1px solid var(--border-soft);
background: var(--surface-1);
background: color-mix(in srgb, var(--surface-1) 90%, var(--bg-editor) 10%);
color: var(--text-muted);
padding: 6px 10px;
font-size: 0.78rem;
@ -271,7 +272,7 @@
.todo-btn.save {
border-color: var(--border-strong);
background: var(--surface-3);
background: color-mix(in srgb, var(--surface-2) 84%, var(--bg-hover) 16%);
color: var(--text-primary);
}
@ -281,4 +282,25 @@
background: var(--bg-hover);
color: var(--text-primary);
}
@media (max-width: 980px) {
.todo-surface {
padding: 4px 8px 10px;
}
.todo-card {
width: 100%;
padding: 18px 16px;
}
.todo-item {
grid-template-columns: auto minmax(0, 1fr);
row-gap: 8px;
}
.todo-actions {
grid-column: 1 / -1;
justify-content: flex-end;
}
}
</style>

View File

@ -9,6 +9,7 @@ type WindowWithTauri = Window & {
type UiSettingsPayload = {
tags?: string[];
fragmentTypes?: string[];
defaultStartupView?: string;
};
type FetchJsonOptions = {
@ -48,7 +49,8 @@ function readUiSettingsFromLocalStorage(): UiSettingsPayload {
const parsed = JSON.parse(raw) as UiSettingsPayload;
return {
tags: Array.isArray(parsed.tags) ? parsed.tags : undefined,
fragmentTypes: Array.isArray(parsed.fragmentTypes) ? parsed.fragmentTypes : undefined
fragmentTypes: Array.isArray(parsed.fragmentTypes) ? parsed.fragmentTypes : undefined,
defaultStartupView: typeof parsed.defaultStartupView === "string" ? parsed.defaultStartupView : undefined
};
} catch {
return {};
@ -62,7 +64,8 @@ function writeUiSettingsToLocalStorage(payload: UiSettingsPayload): void {
const safePayload: UiSettingsPayload = {
tags: Array.isArray(payload.tags) ? payload.tags : undefined,
fragmentTypes: Array.isArray(payload.fragmentTypes) ? payload.fragmentTypes : undefined
fragmentTypes: Array.isArray(payload.fragmentTypes) ? payload.fragmentTypes : undefined,
defaultStartupView: typeof payload.defaultStartupView === "string" ? payload.defaultStartupView : undefined
};
window.localStorage.setItem(UI_SETTINGS_KEY, JSON.stringify(safePayload));
@ -131,8 +134,12 @@ export async function invoke<T>(command: string, args?: InvokeArgs): Promise<T>
Array.isArray(args?.fragmentTypes) ? (args?.fragmentTypes as string[]) :
Array.isArray(args?.fragment_types) ? (args?.fragment_types as string[]) :
undefined;
const defaultStartupView =
typeof args?.defaultStartupView === "string" ? args.defaultStartupView :
typeof args?.default_startup_view === "string" ? args.default_startup_view :
undefined;
writeUiSettingsToLocalStorage({ tags, fragmentTypes });
writeUiSettingsToLocalStorage({ tags, fragmentTypes, defaultStartupView });
return undefined as T;
}
case "shutdown":

View File

@ -4,9 +4,13 @@ import { invoke } from "$lib/runtime/invoke";
const defaultTags = ["Personal", "Work", "Ideas", "Journal"];
const defaultFragmentTypes = ["Quote", "Snippet", "Reference"];
const startupViews = ["entries", "calendar", "fragments", "todos", "lists"] as const;
const defaultStartupView = "entries";
export type StartupView = typeof startupViews[number];
export const settingsTags = writable<string[]>([...defaultTags]);
export const settingsFragmentTypes = writable<string[]>([...defaultFragmentTypes]);
export const settingsDefaultStartupView = writable<StartupView>(defaultStartupView);
let hydrationComplete = false;
let hydrating = false;
@ -14,6 +18,7 @@ let hydrating = false;
type UiSettingsPayload = {
tags?: string[];
fragmentTypes?: string[];
defaultStartupView?: string;
};
function normalize(value: string): string {
@ -39,6 +44,14 @@ function normalizeValues(values: string[], fallback: string[]): string[] {
return result.length ? result : [...fallback];
}
function normalizeStartupView(value: string | undefined): StartupView {
const normalized = (value ?? "").trim().toLowerCase();
if (startupViews.includes(normalized as StartupView)) {
return normalized as StartupView;
}
return defaultStartupView;
}
export async function hydrateUiSettings(): Promise<void> {
if (hydrating || hydrationComplete) return;
hydrating = true;
@ -46,6 +59,7 @@ export async function hydrateUiSettings(): Promise<void> {
const payload = await invoke<UiSettingsPayload>("get_ui_settings");
settingsTags.set(normalizeValues(payload.tags ?? [], defaultTags));
settingsFragmentTypes.set(normalizeValues(payload.fragmentTypes ?? [], defaultFragmentTypes));
settingsDefaultStartupView.set(normalizeStartupView(payload.defaultStartupView));
} catch (error) {
console.error("[settings] hydrate failed", error);
} finally {
@ -57,13 +71,17 @@ export async function hydrateUiSettings(): Promise<void> {
export async function persistUiSettings(): Promise<void> {
const tags = normalizeValues(get(settingsTags), defaultTags);
const fragmentTypes = normalizeValues(get(settingsFragmentTypes), defaultFragmentTypes);
const startupView = normalizeStartupView(get(settingsDefaultStartupView));
settingsTags.set(tags);
settingsFragmentTypes.set(fragmentTypes);
settingsDefaultStartupView.set(startupView);
await invoke("set_ui_settings", {
tags,
fragmentTypes,
fragment_types: fragmentTypes
fragment_types: fragmentTypes,
defaultStartupView: startupView,
default_startup_view: startupView
});
}
@ -132,3 +150,11 @@ export function removeFragmentType(index: number): boolean {
return true;
}
export function setDefaultStartupView(value: string): boolean {
const next = normalizeStartupView(value);
if (get(settingsDefaultStartupView) === next) return false;
settingsDefaultStartupView.set(next);
queuePersist();
return true;
}

View File

@ -6,6 +6,7 @@
import { deleteEntryByStoreId, entriesStore, getDefaultEntry, hydrateEntries, loadEntryByStoreId, saveEntryFromStore } from "$lib/stores/entries";
import { deleteFragmentByStoreId, hydrateFragments } from "$lib/stores/fragments";
import { deleteListByStoreId, hydrateLists, updateListByStoreId } from "$lib/stores/lists";
import { hydrateUiSettings, settingsDefaultStartupView, type StartupView } from "$lib/stores/settings";
import { isVaultReady, setFlushCallback, setVaultSession } from "$lib/stores/session";
import { deleteTodoListByStoreId, hydrateTodos } from "$lib/stores/todos";
import Navbar from "$lib/components/Navbar.svelte";
@ -48,6 +49,43 @@
let pendingDeleteItemId = "";
let templateRefreshToken = 0;
function resolveStartupSection(value: string): StartupView {
switch (value) {
case "calendar":
case "fragments":
case "todos":
case "lists":
case "entries":
return value;
default:
return "entries";
}
}
function parseSectionQuery(value: string | null): StartupView | null {
if (!value) return null;
const normalized = value.trim().toLowerCase();
switch (normalized) {
case "entries":
case "calendar":
case "fragments":
case "todos":
case "lists":
return normalized;
default:
return null;
}
}
function applyStartupSection(section: StartupView) {
selectedSection = section;
editMode = false;
if (section !== "entries") {
activeDocumentId = "";
activeDocumentLabel = "";
}
}
function toTemplatePath(id: string): string | null {
const prefix = "entries/template-file/";
if (!id.startsWith(prefix)) return null;
@ -274,8 +312,13 @@
}
async function handleSelect(id: string) {
if (id === "account" || id === "settings") {
goto(`/${id}`);
if (id === "account") {
goto("/account");
return;
}
if (id === "settings") {
goto(`/settings?return=${encodeURIComponent(selectedSection)}`);
return;
}
@ -415,7 +458,13 @@
onMount(() => {
setFlushCallback(saveCurrentDocument);
bootstrapFragmentsWithUnlock();
void (async () => {
await hydrateUiSettings();
const startupSection = resolveStartupSection(get(settingsDefaultStartupView));
const sectionFromQuery = parseSectionQuery(new URLSearchParams(window.location.search).get("section"));
applyStartupSection(sectionFromQuery ?? startupSection);
await bootstrapFragmentsWithUnlock();
})();
});
</script>

View File

@ -7,12 +7,14 @@
addSettingsTag,
removeFragmentType,
removeSettingsTag,
setDefaultStartupView,
settingsDefaultStartupView,
settingsFragmentTypes,
settingsTags,
updateFragmentType,
updateSettingsTag
} from "$lib/stores/settings";
import { invoke } from "$lib/runtime/invoke";
import { invoke, isTauriRuntime } from "$lib/runtime/invoke";
import { onMount } from "svelte";
const activeSection = "settings";
@ -34,8 +36,15 @@
let sidecarRoot = "";
let sidecarRootIsCustom = false;
let sidecarRootError = "";
let sidecarBrowseBusy = false;
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;
@ -67,6 +76,31 @@
}
}
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;
}
}
function showModal(options: {
action: "logout-confirm" | "logout-info";
title: string;
@ -128,7 +162,11 @@
return;
}
goto("/");
goto(`/?section=${encodeURIComponent(id)}`);
}
function closeSettings() {
goto(`/?section=${encodeURIComponent(returnSection)}`);
}
function addTag() {
@ -193,6 +231,10 @@
}
}
function updateDefaultStartupView(value: string) {
setDefaultStartupView(value);
}
</script>
<div class="app-shell panel-closed">
@ -200,25 +242,46 @@
<main class="route-view">
<header class="route-header">
<h1>Settings</h1>
<p>Configure app behavior and interface options.</p>
<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>
<option>Entries</option>
<option>Calendar</option>
<option>Fragments</option>
<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">
<h2>Tags</h2>
<p class="section-copy">Add and manage tags used for notes and entries.</p>
<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
@ -259,8 +322,13 @@
</section>
<section class="route-card">
<h2>Fragment Types</h2>
<p class="section-copy">Configure custom fragment types for the Fragments section.</p>
<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
@ -301,8 +369,13 @@
</section>
<section class="route-card">
<h2>Sidecar</h2>
<p class="section-copy">Root directory containing the Journal.Sidecar project.</p>
<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
@ -311,7 +384,9 @@
bind:value={sidecarRoot}
on:keydown={(event) => event.key === "Enter" && saveSidecarRoot()}
/>
<button type="button" class="secondary-btn" on:click={saveSidecarRoot}>Save</button>
<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}
@ -343,126 +418,196 @@
padding: 20px;
display: flex;
flex-direction: column;
gap: 16px;
background: linear-gradient(180deg, var(--surface-2) 0%, var(--bg-editor) 100%);
gap: 18px;
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.1rem;
margin-bottom: 4px;
font-size: 1.2rem;
font-weight: 700;
letter-spacing: 0.01em;
}
.route-header p {
color: var(--text-muted);
font-size: 0.9rem;
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 {
columns: 2;
column-gap: 16px;
column-gap: 14px;
}
.route-card {
break-inside: avoid;
border: 1px solid var(--border-soft);
background: var(--surface-1);
border-radius: 10px;
padding: 14px;
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;
margin-bottom: 16px;
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;
}
.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: 5px;
gap: 6px;
color: var(--text-muted);
font-size: 0.82rem;
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 0.01em;
}
.route-card select {
border: 1px solid var(--border-soft);
border-radius: 7px;
background: var(--bg-app);
color: var(--text-primary);
padding: 8px 10px;
}
.route-card h2 {
font-size: 0.95rem;
border-radius: 8px;
background: color-mix(in srgb, var(--surface-2) 88%, var(--bg-editor) 12%);
color: var(--text-primary);
padding: 9px 10px;
font-size: 0.84rem;
}
.section-copy {
font-size: 0.82rem;
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: 8px;
gap: 7px;
}
.item-row {
border: 1px solid var(--border-soft);
border-radius: 8px;
padding: 8px 10px;
padding: 9px 10px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
font-size: 0.84rem;
font-size: 0.83rem;
color: var(--text-primary);
background: var(--bg-app);
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: 7px;
background: var(--bg-app);
border-radius: 8px;
background: color-mix(in srgb, var(--surface-2) 88%, var(--bg-editor) 12%);
color: var(--text-primary);
padding: 6px 9px;
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: 7px;
padding: 6px 10px;
font-size: 0.78rem;
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: var(--surface-3);
background: color-mix(in srgb, var(--surface-2) 84%, var(--bg-hover) 16%);
border-color: var(--border-strong);
}
.ghost-btn {
background: var(--surface-1);
background: color-mix(in srgb, var(--surface-1) 92%, var(--bg-editor) 8%);
color: var(--text-muted);
}
@ -476,11 +621,55 @@
.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;
}
}
@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;
}
.item-row {
align-items: flex-start;
flex-direction: column;
gap: 8px;
}
.row-actions {
width: 100%;
justify-content: flex-end;
}
}
</style>

View File

@ -18,7 +18,7 @@
--bg-app: var(--zinc-950);
--bg-navbar: var(--zinc-900);
--bg-panel: var(--zinc-900);
--bg-panel: var(--zinc-800);
--bg-editor: var(--zinc-900);
--bg-hover: var(--zinc-800);
--bg-active: var(--zinc-700);