Refactor frontend state to store-first architecture

- add and expand feature stores for entries, fragments, todos, lists, settings

- move CRUD logic into store helpers and simplify component state handling

- update SidePanel + button to create section-specific items

- switch fragment UX to view-first with edit/create modes

- add and update docs for store-based state management

- remove deprecated account route
This commit is contained in:
Jacob Schmidt 2026-02-25 20:52:46 -06:00
parent a39e634b7b
commit 54bef33f0b
13 changed files with 1401 additions and 381 deletions

View File

@ -2,6 +2,49 @@
This template should help get you started developing with Tauri, SvelteKit and TypeScript in Vite.
## Frontend State Management
This app uses Svelte stores as the source of truth for feature state.
### Current Stores
- `src/lib/stores/entries.ts`
- state: `entriesStore`
- helpers: `getDefaultEntry`, `createEntryDraft`
- `src/lib/stores/fragments.ts`
- state: `fragmentsStore`
- helpers: parse/serialize + fragment CRUD helpers (`createFragmentItem`, `updateFragmentItem`, `prependFragmentItem`, `removeFragmentItem`)
- `src/lib/stores/todos.ts`
- state: `todoListsStore`, `todosStore`
- helpers: parse/serialize + todo list/item CRUD helpers
- `src/lib/stores/lists.ts`
- state: `listsStore`
- helpers: `createListDraft`
- `src/lib/stores/settings.ts`
- state: `settingsTags`, `settingsFragmentTypes`
### Store-First Rule
- Components should call store helper functions for CRUD operations.
- Components should avoid embedding feature-specific mutation/parsing logic.
- UI components should focus on rendering, local form state, and invoking store operations.
### What Still Needs Setup
1. Move settings CRUD helpers into `settings.ts` (currently add/edit/remove logic lives in `routes/settings/+page.svelte`).
2. Add full CRUD helpers for `entries` and `lists` stores (update/remove/reorder and optional find-by-id helpers).
3. Consolidate todo state into a single custom store API (or a single store object) so `todoListsStore` and `todosStore` updates are atomic.
4. Move calendar-created notes into a dedicated calendar store (currently local to `SidePanel.svelte`).
5. Add persistence/hydration layer so store state survives app restart and can be synchronized with backend commands.
### Suggested Next Refactor
- Introduce feature service wrappers per store (for example `entriesService`, `fragmentsService`) that handle:
- in-memory store mutation
- backend command call (`sendCommand`)
- optimistic update / rollback policy
- error normalization for UI
## Recommended IDE Setup
[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer).

View File

@ -1,13 +1,57 @@
<script lang="ts">
import {
createFragmentItem,
fragmentsStore,
parseFragmentContent,
prependFragmentItem,
removeFragmentItem,
serializeFragment,
updateFragmentItem,
type FragmentItem
} from "$lib/stores/fragments";
import { settingsFragmentTypes, settingsTags } from "$lib/stores/settings";
import {
addTodoItem,
getOrCreateTodoList,
removeTodoItem,
serializeTodoList,
setTodoList,
toggleTodoItem,
todosStore,
type TodoItem,
updateTodoItemText
} from "$lib/stores/todos";
import { get } from "svelte/store";
export let activeSection = "entries";
export let openDocumentId = "entries/daily-notes";
export let openDocumentName = "Daily Notes";
export let openDocumentContent = "";
export let onDocumentContentChange: (content: string) => void = () => {};
export let onOpenDocument: (doc: { id: string; label: string; initialContent: string }) => void = () => {};
export let onDeleteDocument: (id: string) => void = () => {};
let markdownText = openDocumentContent;
let lastOpenDocumentId = openDocumentId;
let previewOnly = false;
let editorInput: HTMLTextAreaElement | null = null;
let fragmentTitle = "";
let fragmentType = "";
let customFragmentType = "";
let fragmentTag = "";
let customFragmentTags = "";
let fragmentBody = "";
let fragmentMode: "view" | "edit" | "create" = "view";
let lastFragmentDocumentId = "";
let fragmentTypeOptions: string[] = [];
let tagOptions: string[] = [];
const customTypeValue = "__custom_type__";
const customTagValue = "__custom_tag__";
let todoItems: TodoItem[] = [];
let lastTodoDocumentId = "";
let newTodoText = "";
let editingTodoId: number | null = null;
let editingTodoText = "";
function updateDraft(value: string) {
markdownText = value;
@ -59,6 +103,193 @@
applyWrap("[", "](https://example.com)");
}
function buildFragmentContent(): { title: string; resolvedType: string; tagsLine: string; body: string; content: string } | null {
const title = fragmentTitle.trim();
if (!title) return null;
const resolvedType = fragmentType === customTypeValue ? customFragmentType.trim() : fragmentType;
if (!resolvedType) return null;
const selectedTags = fragmentTag && fragmentTag !== customTagValue ? [fragmentTag] : [];
const customTags = customFragmentTags
.split(",")
.map((tag) => tag.trim())
.filter(Boolean);
const tagList = [...selectedTags, ...customTags];
const uniqueTagList = [...new Set(tagList.map((tag) => tag.toLowerCase()))].map((lower) => {
return tagList.find((tag) => tag.toLowerCase() === lower) ?? lower;
});
const tagsLine = uniqueTagList.length ? uniqueTagList.map((tag) => `#${tag}`).join(" ") : "(none)";
const body = fragmentBody.trim() || "Add details for this fragment.";
const content = serializeFragment({
title,
type: resolvedType,
tags: uniqueTagList,
body
});
return { title, resolvedType, tagsLine, body, content };
}
function saveFragmentEdits() {
if (activeSection !== "fragments") return;
const payload = buildFragmentContent();
if (!payload) return;
const fragments = get(fragmentsStore);
const exists = fragments.some((item) => item.id === openDocumentId);
if (!exists) {
createNewFragment();
return;
}
fragmentsStore.set(updateFragmentItem(fragments, openDocumentId, payload.title, payload.content));
markdownText = payload.content;
onDocumentContentChange(payload.content);
fragmentMode = "view";
}
function createNewFragment() {
const payload = buildFragmentContent();
if (!payload) return;
const item: FragmentItem = createFragmentItem(payload.title, payload.content);
fragmentsStore.update((items) => prependFragmentItem(items, item));
onOpenDocument(item);
fragmentMode = "view";
}
function deleteCurrentFragment() {
if (activeSection !== "fragments") return;
const fragments = get(fragmentsStore);
const exists = fragments.some((item) => item.id === openDocumentId);
if (!exists) return;
const remaining = removeFragmentItem(fragments, openDocumentId);
fragmentsStore.set(remaining);
onDeleteDocument(openDocumentId);
if (remaining.length > 0) {
onOpenDocument(remaining[0]);
fragmentMode = "view";
return;
}
fragmentTitle = "";
customFragmentType = "";
fragmentTag = customTagValue;
customFragmentTags = "";
fragmentBody = "";
fragmentMode = "create";
}
function startEditFragment() {
fragmentMode = "edit";
}
function cancelFragmentEdit() {
if (fragmentMode === "create") {
fragmentMode = "view";
return;
}
loadFragmentFormFromDocument();
fragmentMode = "view";
}
function loadFragmentFormFromDocument() {
const content = openDocumentContent ?? "";
const isDraftFragment = openDocumentId.startsWith("fragments/new-");
const parsed = parseFragmentContent(content, openDocumentName || "Untitled Fragment");
fragmentTitle = parsed.title;
const parsedType = parsed.type;
if (!parsedType) {
fragmentType = fragmentTypeOptions[0] ?? customTypeValue;
customFragmentType = "";
} else if (fragmentTypeOptions.includes(parsedType)) {
fragmentType = parsedType;
customFragmentType = "";
} else {
fragmentType = customTypeValue;
customFragmentType = parsedType;
}
const parsedTags = parsed.tags;
if (parsedTags.length === 0) {
fragmentTag = tagOptions[0] ?? customTagValue;
customFragmentTags = "";
} else {
const primary = parsedTags[0];
if (tagOptions.includes(primary)) {
fragmentTag = primary;
customFragmentTags = parsedTags.slice(1).join(", ");
} else {
fragmentTag = customTagValue;
customFragmentTags = parsedTags.join(", ");
}
}
fragmentBody = parsed.body;
fragmentMode = isDraftFragment ? "create" : "view";
}
function addTodo() {
const text = newTodoText.trim();
if (!text) return;
todoItems = addTodoItem(todoItems, text);
newTodoText = "";
persistTodosForCurrentList();
}
function toggleTodoDone(id: number) {
todoItems = toggleTodoItem(todoItems, id);
persistTodosForCurrentList();
}
function startEditTodo(id: number) {
const todo = todoItems.find((item) => item.id === id);
if (!todo) return;
editingTodoId = id;
editingTodoText = todo.text;
}
function saveEditTodo() {
if (editingTodoId === null) return;
const text = editingTodoText.trim();
if (!text) return;
todoItems = updateTodoItemText(todoItems, editingTodoId, text);
editingTodoId = null;
editingTodoText = "";
persistTodosForCurrentList();
}
function cancelEditTodo() {
editingTodoId = null;
editingTodoText = "";
}
function removeTodo(id: number) {
todoItems = removeTodoItem(todoItems, id);
if (editingTodoId === id) {
cancelEditTodo();
}
persistTodosForCurrentList();
}
function loadTodosForDocument(documentId: string) {
if (!documentId) {
todoItems = [];
return;
}
const lists = get(todosStore);
const result = getOrCreateTodoList(lists, documentId, openDocumentContent);
if (result.lists !== lists) {
todosStore.set(result.lists);
}
todoItems = result.todos;
}
function persistTodosForCurrentList() {
if (activeSection !== "todos" || !openDocumentId) return;
const lists = get(todosStore);
todosStore.set(setTodoList(lists, openDocumentId, todoItems));
const markdown = serializeTodoList(openDocumentName, todoItems);
onDocumentContentChange(markdown);
}
function escapeHtml(input: string): string {
return input
.replaceAll("&", "&amp;")
@ -179,6 +410,25 @@
markdownText = openDocumentContent;
lastOpenDocumentId = openDocumentId;
}
$: if (activeSection === "todos" && openDocumentId !== lastTodoDocumentId) {
loadTodosForDocument(openDocumentId);
editingTodoId = null;
editingTodoText = "";
newTodoText = "";
lastTodoDocumentId = openDocumentId;
}
$: fragmentTypeOptions = $settingsFragmentTypes.length ? $settingsFragmentTypes : ["General"];
$: tagOptions = $settingsTags;
$: if (activeSection === "fragments" && openDocumentId !== lastFragmentDocumentId) {
loadFragmentFormFromDocument();
lastFragmentDocumentId = openDocumentId;
}
$: if (!fragmentType || (!fragmentTypeOptions.includes(fragmentType) && fragmentType !== customTypeValue)) {
fragmentType = fragmentTypeOptions[0];
}
$: if (!fragmentTag || (!tagOptions.includes(fragmentTag) && fragmentTag !== customTagValue)) {
fragmentTag = tagOptions[0] ?? customTagValue;
}
$: editorTitle = extractEditorTitle(markdownText);
$: renderedHtml = renderMarkdown(markdownText);
@ -186,59 +436,177 @@
<main class="editor-panel" aria-label="Editor area">
<header class="editor-header">
<h1>{editorTitle}</h1>
<div class="editor-actions">
<button type="button" class:primary={!previewOnly} on:click={() => (previewOnly = false)}>Write</button>
<button type="button" class:primary={previewOnly} on:click={() => (previewOnly = true)}>Preview</button>
</div>
</header>
<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>
<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>
<h1>{activeSection === "fragments" ? "Create Fragment" : activeSection === "todos" ? "To-Do List" : editorTitle}</h1>
{#if activeSection !== "fragments" && activeSection !== "todos"}
<div class="editor-actions">
<button type="button" class:primary={!previewOnly} on:click={() => (previewOnly = false)}>Write</button>
<button type="button" class:primary={previewOnly} on:click={() => (previewOnly = true)}>Preview</button>
</div>
{/if}
</header>
<div class="editor-workspace">
{#if previewOnly}
<article class="markdown-preview" aria-label="Markdown preview">
{@html renderedHtml}
{#if activeSection === "fragments"}
<section class="fragment-surface">
{#if fragmentMode === "view"}
<article class="fragment-view">
{@html renderMarkdown(openDocumentContent)}
<div class="fragment-actions">
<button type="button" class="fragment-submit" on:click={startEditFragment}>Edit</button>
<button type="button" class="fragment-danger" on:click={deleteCurrentFragment}>Delete</button>
</div>
</article>
{:else}
<textarea
bind:this={editorInput}
class="markdown-input"
bind:value={markdownText}
on:input={(event) => updateDraft((event.currentTarget as HTMLTextAreaElement).value)}
aria-label="Markdown input"
></textarea>
<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">
<select bind:value={fragmentType} aria-label="Fragment type">
{#each fragmentTypeOptions as type}
<option value={type}>{type}</option>
{/each}
<option value={customTypeValue}>Custom</option>
</select>
{#if fragmentType === customTypeValue}
<input
type="text"
placeholder="Custom type"
bind:value={customFragmentType}
aria-label="Custom fragment type"
/>
{:else}
<input type="text" value={fragmentType} disabled aria-label="Selected fragment type" />
{/if}
</div>
<div class="fragment-form-row">
<select bind:value={fragmentTag} aria-label="Primary fragment tag">
{#if tagOptions.length === 0}
<option value={customTagValue}>Custom</option>
{:else}
{#each tagOptions as tag}
<option value={tag}>{tag}</option>
{/each}
<option value={customTagValue}>Custom</option>
{/if}
</select>
<input
type="text"
placeholder={fragmentTag === customTagValue
? "Custom tags (comma separated)"
: "Additional tags (optional, comma separated)"}
bind:value={customFragmentTags}
aria-label="Custom fragment tags"
/>
</div>
<textarea
rows="5"
placeholder="Fragment text"
bind:value={fragmentBody}
aria-label="Fragment body"
></textarea>
<div class="fragment-actions">
<button type="submit" class="fragment-submit">{fragmentMode === "create" ? "Create Fragment" : "Save Fragment"}</button>
<button type="button" class="fragment-secondary" on:click={cancelFragmentEdit}>Cancel</button>
{#if fragmentMode !== "create"}
<button type="button" class="fragment-danger" on:click={deleteCurrentFragment}>Delete</button>
{/if}
</div>
</form>
{/if}
</div>
</section>
</section>
{:else if activeSection === "todos"}
<section class="todo-surface">
<div class="todo-card">
<form class="todo-create" on:submit|preventDefault={addTodo}>
<input
type="text"
placeholder="Add a new to-do"
bind:value={newTodoText}
aria-label="Add to-do"
/>
<button type="submit" class="todo-add-btn">Add</button>
</form>
<ul class="todo-list">
{#each todoItems as todo}
<li class="todo-item">
<label class="todo-check">
<input type="checkbox" checked={todo.done} on:change={() => toggleTodoDone(todo.id)} />
</label>
{#if editingTodoId === todo.id}
<input
type="text"
class="todo-edit-input"
bind:value={editingTodoText}
on:keydown={(event) => {
if (event.key === "Enter") saveEditTodo();
if (event.key === "Escape") cancelEditTodo();
}}
/>
<div class="todo-actions">
<button type="button" class="todo-btn save" on:click={saveEditTodo}>Save</button>
<button type="button" class="todo-btn ghost" on:click={cancelEditTodo}>Cancel</button>
</div>
{:else}
<span class="todo-text" class:is-done={todo.done}>{todo.text}</span>
<div class="todo-actions">
<button type="button" class="todo-btn ghost" on:click={() => startEditTodo(todo.id)}>Edit</button>
<button type="button" class="todo-btn danger" on:click={() => removeTodo(todo.id)}>Remove</button>
</div>
{/if}
</li>
{/each}
</ul>
</div>
</section>
{:else}
<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>
<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>
{/if}
<div class="editor-workspace">
{#if previewOnly}
<article class="markdown-preview" aria-label="Markdown preview">
{@html renderedHtml}
</article>
{:else}
<textarea
bind:this={editorInput}
class="markdown-input"
bind:value={markdownText}
on:input={(event) => updateDraft((event.currentTarget as HTMLTextAreaElement).value)}
aria-label="Markdown input"
></textarea>
{/if}
</div>
</section>
{/if}
</main>
<style>
@ -309,6 +677,245 @@
grid-template-rows: minmax(0, 1fr);
}
.fragment-surface {
min-height: 0;
flex: 1;
padding: 8px;
display: grid;
place-items: center;
}
.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;
display: flex;
flex-direction: column;
gap: 10px;
}
.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;
display: flex;
flex-direction: column;
gap: 14px;
color: var(--text-primary);
}
.fragment-view :global(h1),
.fragment-view :global(h2),
.fragment-view :global(h3),
.fragment-view :global(h4),
.fragment-view :global(h5),
.fragment-view :global(h6) {
margin: 0 0 8px;
}
.fragment-view :global(p),
.fragment-view :global(ul),
.fragment-view :global(ol),
.fragment-view :global(blockquote),
.fragment-view :global(pre) {
margin: 0 0 10px;
}
.fragment-view :global(code) {
background: var(--surface-2);
border: 1px solid var(--border-soft);
border-radius: 4px;
padding: 1px 4px;
font-family: Consolas, "Courier New", monospace;
font-size: 0.82rem;
}
.fragment-form h2 {
font-size: 0.94rem;
font-weight: 600;
color: var(--text-primary);
}
.fragment-form input,
.fragment-form select,
.fragment-form textarea {
width: 100%;
border: 1px solid var(--border-soft);
border-radius: 8px;
background: var(--bg-app);
color: var(--text-primary);
padding: 9px 10px;
font-size: 0.86rem;
}
.fragment-form textarea {
resize: vertical;
min-height: 160px;
}
.fragment-form-row {
display: grid;
grid-template-columns: 180px minmax(0, 1fr);
gap: 8px;
}
.fragment-submit {
width: fit-content;
border-radius: 8px;
border: 1px solid var(--border-strong);
background: var(--surface-3);
color: var(--text-primary);
padding: 8px 12px;
font-size: 0.82rem;
cursor: pointer;
}
.fragment-submit:hover {
background: var(--bg-hover);
}
.fragment-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.fragment-secondary,
.fragment-danger {
border-radius: 8px;
border: 1px solid var(--border-soft);
background: var(--surface-1);
color: var(--text-muted);
padding: 8px 12px;
font-size: 0.82rem;
cursor: pointer;
}
.fragment-secondary:hover,
.fragment-danger:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.todo-surface {
min-height: 0;
flex: 1;
padding: 8px;
display: grid;
place-items: center;
}
.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;
display: flex;
flex-direction: column;
gap: 10px;
max-height: 100%;
overflow: auto;
}
.todo-create {
display: flex;
gap: 8px;
}
.todo-create input,
.todo-edit-input {
width: 100%;
border: 1px solid var(--border-soft);
border-radius: 8px;
background: var(--bg-app);
color: var(--text-primary);
padding: 8px 10px;
font-size: 0.86rem;
}
.todo-add-btn {
border-radius: 8px;
border: 1px solid var(--border-strong);
background: var(--surface-3);
color: var(--text-primary);
padding: 8px 12px;
font-size: 0.82rem;
cursor: pointer;
}
.todo-add-btn:hover {
background: var(--bg-hover);
}
.todo-list {
list-style: none;
display: flex;
flex-direction: column;
gap: 8px;
}
.todo-item {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
border: 1px solid var(--border-soft);
border-radius: 8px;
padding: 8px 10px;
background: var(--bg-app);
}
.todo-check {
display: grid;
place-items: center;
}
.todo-text {
font-size: 0.86rem;
color: var(--text-primary);
}
.todo-text.is-done {
color: var(--text-dim);
text-decoration: line-through;
}
.todo-actions {
display: flex;
gap: 6px;
}
.todo-btn {
border-radius: 7px;
border: 1px solid var(--border-soft);
background: var(--surface-1);
color: var(--text-muted);
padding: 6px 10px;
font-size: 0.78rem;
cursor: pointer;
}
.todo-btn.save {
border-color: var(--border-strong);
background: var(--surface-3);
color: var(--text-primary);
}
.todo-btn.danger:hover,
.todo-btn.ghost:hover,
.todo-btn.save:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.editor-toolbar {
display: flex;
flex-wrap: wrap;

View File

@ -16,40 +16,11 @@
{ id: "lists", label: "Lists", icon: "lists" }
];
const profileItems: NavItem[] = [
{ id: "account", label: "Account", icon: "account_circle" },
{ id: "settings", label: "Settings", icon: "settings" },
{ id: "logout", label: "Logout", icon: "logout" }
];
let profileMenuOpen = false;
function selectItem(id: string) {
onSelect(id);
profileMenuOpen = false;
}
function handleProfileItemClick(item: NavItem) {
selectItem(item.id);
}
function toggleProfileMenu() {
profileMenuOpen = !profileMenuOpen;
}
function closeProfileMenu() {
profileMenuOpen = false;
}
function handleWindowKeydown(event: KeyboardEvent) {
if (event.key === "Escape") {
closeProfileMenu();
}
}
</script>
<svelte:window on:click={closeProfileMenu} on:keydown={handleWindowKeydown} />
<aside class="navbar" aria-label="Primary navigation">
<div class="navbar-header">
<img src="svelte.svg" alt="Journal logo" />
@ -73,31 +44,15 @@
</nav>
<button
class="user-chip"
type="button"
class:is-active={profileMenuOpen}
aria-label="Profile menu"
on:click|stopPropagation={toggleProfileMenu}
class="settings-chip"
class:is-active={activeSection === "settings"}
aria-label="Settings"
on:click={() => selectItem("settings")}
>
<img src="https://placehold.co/800x600/09090b/jpg" alt="Profile" />
<span class="nav-tooltip" role="tooltip">John Doe</span>
<span class="material-symbols-outlined">settings</span>
<span class="nav-tooltip" role="tooltip">Settings</span>
</button>
{#if profileMenuOpen}
<div class="profile-menu" role="menu" tabindex="-1">
{#each profileItems as item}
<button
type="button"
role="menuitem"
class="profile-menu-item"
on:click={() => handleProfileItemClick(item)}
>
<span class="material-symbols-outlined">{item.icon}</span>
<span>{item.label}</span>
</button>
{/each}
</div>
{/if}
</aside>
<style>
@ -137,7 +92,7 @@
}
.nav-button,
.user-chip {
.settings-chip {
position: relative;
width: 44px;
height: 44px;
@ -154,6 +109,10 @@
font-size: 1.18rem;
}
.settings-chip .material-symbols-outlined {
font-size: 1.18rem;
}
.nav-tooltip {
position: absolute;
left: calc(100% + 12px);
@ -175,8 +134,8 @@
.nav-button:hover,
.nav-button:focus-visible,
.user-chip:hover,
.user-chip:focus-visible {
.settings-chip:hover,
.settings-chip:focus-visible {
color: var(--text-primary);
background: var(--bg-hover);
border-color: var(--border-soft);
@ -184,8 +143,8 @@
.nav-button:hover .nav-tooltip,
.nav-button:focus-visible .nav-tooltip,
.user-chip:hover .nav-tooltip,
.user-chip:focus-visible .nav-tooltip {
.settings-chip:hover .nav-tooltip,
.settings-chip:focus-visible .nav-tooltip {
opacity: 1;
transform: translateY(-50%) translateX(0);
}
@ -200,72 +159,25 @@
color: var(--accent);
}
.user-chip {
.settings-chip {
margin-top: auto;
}
.user-chip img {
width: 30px;
height: 30px;
border-radius: 50%;
border: 1px solid var(--border-strong);
}
.profile-menu {
position: absolute;
left: calc(100% + 12px);
bottom: 14px;
min-width: 158px;
padding: 6px;
border-radius: 10px;
border: 1px solid var(--border-strong);
background: var(--surface-1);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.4);
display: flex;
flex-direction: column;
gap: 2px;
}
.profile-menu-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 7px 8px;
border-radius: 7px;
border: 1px solid transparent;
color: var(--text-muted);
cursor: pointer;
font-size: 0.82rem;
text-align: left;
}
.profile-menu-item .material-symbols-outlined {
font-size: 1rem;
color: var(--text-dim);
}
.profile-menu-item:hover {
background: var(--bg-hover);
border-color: var(--border-soft);
.settings-chip.is-active {
color: var(--text-primary);
background: var(--bg-active);
border-color: var(--border-strong);
}
.profile-menu-item:hover .material-symbols-outlined {
.settings-chip.is-active .material-symbols-outlined {
color: var(--accent);
}
@media (max-width: 980px) {
.nav-button,
.user-chip {
.settings-chip {
width: 40px;
height: 40px;
}
.profile-menu {
left: calc(100% + 8px);
bottom: 10px;
min-width: 144px;
}
}
</style>

View File

@ -1,5 +1,9 @@
<script lang="ts">
import CalendarWidget from "$lib/components/CalendarWidget.svelte";
import { createEntryDraft, entriesStore } from "$lib/stores/entries";
import { createFragmentDraft, fragmentsStore } from "$lib/stores/fragments";
import { createListDraft, listsStore } from "$lib/stores/lists";
import { createTodoListDraft, serializeTodoList, todoListsStore, todosStore } from "$lib/stores/todos";
export let activeSection = "entries";
export let activeDocumentId = "";
@ -10,50 +14,15 @@
label: string;
initialContent: string;
};
let todoDocuments: SidePanelItem[] = [];
let customCalendarEntries: SidePanelItem[] = [];
const sectionTitles: Record<string, string> = {
entries: "Entries",
calendar: "Calendar",
fragments: "Fragments",
todos: "To-Do List",
lists: "Lists",
account: "Account",
settings: "Settings",
logout: "Logout"
};
const sectionItems: Record<string, SidePanelItem[]> = {
entries: [
{ id: "entries/daily-notes", label: "Daily Notes", initialContent: "# Daily Notes\n\nStart writing today's entry..." },
{ id: "entries/ideas", label: "Ideas", initialContent: "# Ideas\n\nCapture ideas before they disappear." },
{ id: "entries/archive", label: "Archive", initialContent: "# Archive\n\nOlder entries and references." }
],
fragments: [
{ id: "fragments/highlights", label: "Highlights", initialContent: "# Highlights\n\nImportant highlights and excerpts." },
{ id: "fragments/quotes", label: "Quotes", initialContent: "# Quotes\n\nQuotes worth revisiting." },
{ id: "fragments/scratchpad", label: "Scratchpad", initialContent: "# Scratchpad\n\nTemporary notes and rough thoughts." }
],
todos: [
{ id: "todos/today", label: "Today", initialContent: "# Today\n\n- [ ] Top priority\n- [ ] Secondary task" },
{ id: "todos/scheduled", label: "Scheduled", initialContent: "# Scheduled\n\nTasks planned for later dates." },
{ id: "todos/completed", label: "Completed", initialContent: "# Completed\n\nFinished tasks log." }
],
lists: [
{ id: "lists/reading", label: "Reading", initialContent: "# Reading\n\nBooks and articles to read." },
{ id: "lists/projects", label: "Projects", initialContent: "# Projects\n\nActive and planned projects." },
{ id: "lists/someday", label: "Someday", initialContent: "# Someday\n\nLong-term ideas." }
],
account: [
{ id: "account/profile", label: "Profile", initialContent: "# Profile\n\nAccount profile notes." },
{ id: "account/appearance", label: "Appearance", initialContent: "# Appearance\n\nTheme and layout preferences." },
{ id: "account/connections", label: "Connections", initialContent: "# Connections\n\nIntegrations and linked services." }
],
settings: [
{ id: "settings/general", label: "General", initialContent: "# General Settings\n\nGeneral application settings." },
{ id: "settings/hotkeys", label: "Hotkeys", initialContent: "# Hotkeys\n\nKeyboard shortcut configuration." },
{ id: "settings/plugins", label: "Plugins", initialContent: "# Plugins\n\nPlugin management notes." }
],
logout: [{ id: "logout/confirm", label: "Confirm Logout", initialContent: "# Logout\n\nConfirm logout request." }]
lists: "Lists"
};
const today = new Date();
@ -149,16 +118,82 @@
.map(({ day, dateKey, ...item }) => item);
}
function handleAddItem() {
if (activeSection === "entries") {
const item = createEntryDraft();
entriesStore.update((items) => [item, ...items]);
onOpenDocument(item);
return;
}
if (activeSection === "fragments") {
onOpenDocument(createFragmentDraft());
return;
}
if (activeSection === "todos") {
const draft = createTodoListDraft();
todoListsStore.update((lists) => [draft.meta, ...lists]);
todosStore.update((lists) => ({ ...lists, [draft.meta.id]: draft.items }));
onOpenDocument({
id: draft.meta.id,
label: draft.meta.label,
initialContent: serializeTodoList(draft.meta.label, draft.items)
});
return;
}
if (activeSection === "lists") {
const item = createListDraft();
listsStore.update((items) => [item, ...items]);
onOpenDocument(item);
return;
}
if (activeSection === "calendar") {
const selected = selectedCalendarDate ?? {
year: calendarYear,
month: calendarMonth,
day: 1,
key: toDateKey(calendarYear, calendarMonth, 1)
};
const monthLabel = new Date(selected.year, selected.month, selected.day).toLocaleString(undefined, {
month: "short"
});
const label = `${monthLabel} ${selected.day} Note`;
const item: SidePanelItem = {
id: `calendar/${selected.key}/note-${Date.now()}`,
label,
initialContent: `# ${label}\n\nDate: ${selected.key}\n\nAdd your note...`
};
customCalendarEntries = [item, ...customCalendarEntries];
onOpenDocument(item);
}
}
$: panelTitle = sectionTitles[activeSection] ?? "Entries";
$: items = sectionItems[activeSection] ?? sectionItems.entries;
$: todoDocuments = $todoListsStore.map(({ id, label }) => ({
id,
label,
initialContent: serializeTodoList(label, $todosStore[id] ?? [])
}));
$: items = activeSection === "entries"
? $entriesStore
: activeSection === "todos"
? todoDocuments
: activeSection === "fragments"
? $fragmentsStore
: activeSection === "lists"
? $listsStore
: [];
$: isCalendarSection = activeSection === "calendar";
$: calendarEntries = getCalendarEntries(calendarYear, calendarMonth);
$: calendarEntries = [...customCalendarEntries, ...getCalendarEntries(calendarYear, calendarMonth)];
</script>
<section class="side-panel" aria-label="Section panel">
<header class="panel-header">
<h2>{panelTitle}</h2>
<button type="button" class="panel-action" aria-label="Add item">
<button type="button" class="panel-action" aria-label="Add item" on:click={handleAddItem}>
<span class="material-symbols-outlined">add</span>
</button>
</header>

View File

@ -0,0 +1,28 @@
import { writable } from "svelte/store";
export type EntryItem = {
id: string;
label: string;
initialContent: string;
};
const initialEntries: EntryItem[] = [
{ id: "entries/daily-notes", label: "Daily Notes", initialContent: "# Daily Notes\n\nStart writing today's entry..." },
{ id: "entries/ideas", label: "Ideas", initialContent: "# Ideas\n\nCapture ideas before they disappear." },
{ id: "entries/archive", label: "Archive", initialContent: "# Archive\n\nOlder entries and references." }
];
export const entriesStore = writable<EntryItem[]>(initialEntries);
export function getDefaultEntry(items: EntryItem[]): EntryItem | undefined {
return items.find((entry) => entry.id === "entries/daily-notes") ?? items[0];
}
export function createEntryDraft(): EntryItem {
const id = `entries/entry-${Date.now()}`;
return {
id,
label: "Untitled Entry",
initialContent: "# Untitled Entry\n\nStart writing..."
};
}

View File

@ -0,0 +1,96 @@
import { writable } from "svelte/store";
export type FragmentItem = {
id: string;
label: string;
initialContent: string;
};
const initialFragments: FragmentItem[] = [
{ id: "fragments/highlights", label: "Highlights", initialContent: "# Highlights\n\nType: Reference\nTags: #Personal\n\nImportant highlights and excerpts." },
{ id: "fragments/quotes", label: "Quotes", initialContent: "# Quotes\n\nType: Quote\nTags: #Ideas\n\nQuotes worth revisiting." },
{ id: "fragments/scratchpad", label: "Scratchpad", initialContent: "# Scratchpad\n\nType: Snippet\nTags: (none)\n\nTemporary notes and rough thoughts." }
];
export const fragmentsStore = writable<FragmentItem[]>(initialFragments);
export type ParsedFragment = {
title: string;
type: string;
tags: string[];
body: string;
};
export function createFragmentId(title: string): string {
const slug = title
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 40);
return `fragments/${slug || "fragment"}-${Date.now()}`;
}
export function serializeFragment(payload: ParsedFragment): string {
const title = payload.title.trim() || "Untitled Fragment";
const type = payload.type.trim();
const tagsLine = payload.tags.length ? payload.tags.map((tag) => `#${tag}`).join(" ") : "(none)";
const body = payload.body.trim() || "Add details for this fragment.";
return `# ${title}\n\nType: ${type}\nTags: ${tagsLine}\n\n${body}`;
}
export function parseFragmentContent(content: string, fallbackTitle = "Untitled Fragment"): ParsedFragment {
const headingMatch = content.match(/^#\s+(.+)$/m);
const typeMatch = content.match(/^Type:\s*(.+)$/m);
const tagsMatch = content.match(/^Tags:\s*(.+)$/m);
const bodyMatch = content.match(/^#.*\n\nType:.*\nTags:.*\n\n([\s\S]*)$/);
const rawTags = tagsMatch?.[1]?.trim() ?? "(none)";
const tags =
rawTags.toLowerCase() === "(none)"
? []
: rawTags
.split(/\s+/)
.map((tag) => tag.replace(/^#/, "").trim())
.filter(Boolean);
return {
title: headingMatch?.[1]?.trim() || fallbackTitle,
type: typeMatch?.[1]?.trim() || "",
tags,
body: bodyMatch?.[1]?.trim() || ""
};
}
export function createFragmentDraft(): FragmentItem {
const id = `fragments/new-${Date.now()}`;
return {
id,
label: "New Fragment",
initialContent: "# New Fragment\n\nType: \nTags: (none)\n\n"
};
}
export function createFragmentItem(title: string, content: string): FragmentItem {
return {
id: createFragmentId(title),
label: title.trim() || "Untitled Fragment",
initialContent: content
};
}
export function updateFragmentItem(items: FragmentItem[], id: string, title: string, content: string): FragmentItem[] {
return items.map((item) =>
item.id === id
? { ...item, label: title.trim() || "Untitled Fragment", initialContent: content }
: item
);
}
export function prependFragmentItem(items: FragmentItem[], item: FragmentItem): FragmentItem[] {
return [item, ...items];
}
export function removeFragmentItem(items: FragmentItem[], id: string): FragmentItem[] {
return items.filter((item) => item.id !== id);
}

View File

@ -0,0 +1,24 @@
import { writable } from "svelte/store";
export type ListItem = {
id: string;
label: string;
initialContent: string;
};
const initialLists: ListItem[] = [
{ id: "lists/reading", label: "Reading", initialContent: "# Reading\n\nBooks and articles to read." },
{ id: "lists/projects", label: "Projects", initialContent: "# Projects\n\nActive and planned projects." },
{ id: "lists/someday", label: "Someday", initialContent: "# Someday\n\nLong-term ideas." }
];
export const listsStore = writable<ListItem[]>(initialLists);
export function createListDraft(): ListItem {
const id = `lists/list-${Date.now()}`;
return {
id,
label: "Untitled List",
initialContent: "# Untitled List\n\n- Item 1"
};
}

View File

@ -0,0 +1,66 @@
import { writable } from "svelte/store";
import { get } from "svelte/store";
export const settingsTags = writable<string[]>(["Personal", "Work", "Ideas", "Journal"]);
export const settingsFragmentTypes = writable<string[]>(["Quote", "Snippet", "Reference"]);
function normalize(value: string): string {
return value.trim().toLowerCase();
}
function hasDuplicate(values: string[], candidate: string, excludeIndex?: number): boolean {
const normalized = normalize(candidate);
return values.some((value, index) => index !== excludeIndex && normalize(value) === normalized);
}
export function addSettingsTag(value: string): boolean {
const next = value.trim();
if (!next) return false;
const tags = get(settingsTags);
if (hasDuplicate(tags, next)) return false;
settingsTags.set([...tags, next]);
return true;
}
export function updateSettingsTag(index: number, value: string): boolean {
const next = value.trim();
if (!next) return false;
const tags = get(settingsTags);
if (index < 0 || index >= tags.length) return false;
if (hasDuplicate(tags, next, index)) return false;
settingsTags.set(tags.map((tag, idx) => (idx === index ? next : tag)));
return true;
}
export function removeSettingsTag(index: number): boolean {
const tags = get(settingsTags);
if (index < 0 || index >= tags.length) return false;
settingsTags.set(tags.filter((_, idx) => idx !== index));
return true;
}
export function addFragmentType(value: string): boolean {
const next = value.trim();
if (!next) return false;
const types = get(settingsFragmentTypes);
if (hasDuplicate(types, next)) return false;
settingsFragmentTypes.set([...types, next]);
return true;
}
export function updateFragmentType(index: number, value: string): boolean {
const next = value.trim();
if (!next) return false;
const types = get(settingsFragmentTypes);
if (index < 0 || index >= types.length) return false;
if (hasDuplicate(types, next, index)) return false;
settingsFragmentTypes.set(types.map((type, idx) => (idx === index ? next : type)));
return true;
}
export function removeFragmentType(index: number): boolean {
const types = get(settingsFragmentTypes);
if (index < 0 || index >= types.length) return false;
settingsFragmentTypes.set(types.filter((_, idx) => idx !== index));
return true;
}

View File

@ -0,0 +1,99 @@
import { writable } from "svelte/store";
export type TodoItem = { id: number; text: string; done: boolean };
export type TodoListMeta = { id: string; label: string };
const initialTodoListMeta: TodoListMeta[] = [
{ id: "todos/today", label: "Today" },
{ id: "todos/scheduled", label: "Scheduled" },
{ id: "todos/completed", label: "Completed" }
];
export const todoListsStore = writable<TodoListMeta[]>(initialTodoListMeta);
export const todosStore = writable<Record<string, TodoItem[]>>({
"todos/today": [
{ id: 1, text: "Finalize journal sidebar interactions", done: false },
{ id: 2, text: "Review fragment taxonomy updates", done: false },
{ id: 3, text: "Capture daily notes summary", done: false }
],
"todos/scheduled": [
{ id: 4, text: "Tuesday: sync settings to backend store", done: false },
{ id: 5, text: "Thursday: polish editor keyboard shortcuts", done: false },
{ id: 6, text: "Friday: QA fragment and todo workflows", done: false }
],
"todos/completed": [
{ id: 7, text: "Replaced navbar profile with settings shortcut", done: true },
{ id: 8, text: "Centered modal presentation", done: true },
{ id: 9, text: "Added fragment creation form mode", done: true }
]
});
export function serializeTodoList(title: string, todos: TodoItem[]): string {
const heading = title?.trim() ? `# ${title}` : "# To-Do List";
const lines = todos.map((todo) => `- [${todo.done ? "x" : " "}] ${todo.text}`);
return `${heading}\n\n${lines.join("\n")}`;
}
export function createTodoId(): number {
return Date.now() + Math.floor(Math.random() * 1000);
}
export function parseTodoList(content: string): TodoItem[] {
const lines = content.replace(/\r\n/g, "\n").split("\n");
const parsed: TodoItem[] = [];
for (const line of lines) {
const match = line.match(/^- \[( |x)\]\s+(.+)$/i);
if (!match) continue;
parsed.push({
id: createTodoId(),
text: match[2].trim(),
done: match[1].toLowerCase() === "x"
});
}
return parsed;
}
export function getOrCreateTodoList(
lists: Record<string, TodoItem[]>,
documentId: string,
fallbackContent: string
): { lists: Record<string, TodoItem[]>; todos: TodoItem[] } {
const existing = lists[documentId];
if (existing) {
return { lists, todos: existing };
}
const parsed = parseTodoList(fallbackContent);
return { lists: { ...lists, [documentId]: parsed }, todos: parsed };
}
export function setTodoList(
lists: Record<string, TodoItem[]>,
documentId: string,
todos: TodoItem[]
): Record<string, TodoItem[]> {
return { ...lists, [documentId]: todos };
}
export function addTodoItem(todos: TodoItem[], text: string): TodoItem[] {
return [{ id: createTodoId(), text: text.trim(), done: false }, ...todos];
}
export function toggleTodoItem(todos: TodoItem[], id: number): TodoItem[] {
return todos.map((todo) => (todo.id === id ? { ...todo, done: !todo.done } : todo));
}
export function updateTodoItemText(todos: TodoItem[], id: number, text: string): TodoItem[] {
return todos.map((todo) => (todo.id === id ? { ...todo, text: text.trim() } : todo));
}
export function removeTodoItem(todos: TodoItem[], id: number): TodoItem[] {
return todos.filter((todo) => todo.id !== id);
}
export function createTodoListDraft(): { meta: TodoListMeta; items: TodoItem[] } {
const id = `todos/list-${Date.now()}`;
return {
meta: { id, label: "New List" },
items: []
};
}

View File

@ -1,9 +1,11 @@
<script lang="ts">
import { goto } from "$app/navigation";
import AppModal from "$lib/components/AppModal.svelte";
import { entriesStore, getDefaultEntry } from "$lib/stores/entries";
import Navbar from "$lib/components/Navbar.svelte";
import SidePanel from "$lib/components/SidePanel.svelte";
import EditorPanel from "$lib/components/EditorPanel.svelte";
import { get } from "svelte/store";
type OpenDocument = {
id: string;
@ -11,13 +13,15 @@
initialContent: string;
};
const initialEntry = getDefaultEntry(get(entriesStore));
let selectedSection = "entries";
let panelOpen = true;
let activeDocumentId = "entries/daily-notes";
let activeDocumentLabel = "Daily Notes";
let openDocuments: Record<string, string> = {
"entries/daily-notes": "# Daily Notes\n\nStart writing today's entry..."
};
let activeDocumentId = initialEntry?.id ?? "entries/daily-notes";
let activeDocumentLabel = initialEntry?.label ?? "Daily Notes";
let openDocuments: Record<string, string> = initialEntry
? { [initialEntry.id]: initialEntry.initialContent }
: { "entries/daily-notes": "# Daily Notes\n\nStart writing today's entry..." };
let modalOpen = false;
let modalTitle = "";
let modalMessage = "";
@ -104,6 +108,11 @@
function handleDocumentContentChange(content: string) {
openDocuments = { ...openDocuments, [activeDocumentId]: content };
}
function handleDeleteDocument(id: string) {
const { [id]: _, ...remaining } = openDocuments;
openDocuments = remaining;
}
</script>
<div class="app-shell" class:panel-closed={!panelOpen}>
@ -121,6 +130,8 @@
openDocumentName={activeDocumentLabel}
openDocumentContent={openDocuments[activeDocumentId] ?? ""}
onDocumentContentChange={handleDocumentContentChange}
onOpenDocument={handleOpenDocument}
onDeleteDocument={handleDeleteDocument}
/>
</div>

View File

@ -1,178 +0,0 @@
<script lang="ts">
import { goto } from "$app/navigation";
import AppModal from "$lib/components/AppModal.svelte";
import Navbar from "$lib/components/Navbar.svelte";
const activeSection = "account";
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;
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 === "account") {
return;
}
if (id === "settings") {
goto("/settings");
return;
}
goto("/");
}
</script>
<div class="app-shell panel-closed">
<Navbar {activeSection} onSelect={handleSelect} />
<main class="route-view">
<header class="route-header">
<h1>Account</h1>
<p>Manage your profile and account preferences.</p>
</header>
<section class="route-card">
<label>
Display Name
<input type="text" value="John Doe" />
</label>
<label>
Email
<input type="email" value="john@example.com" />
</label>
<button type="button">Save Changes</button>
</section>
</main>
</div>
<AppModal
open={modalOpen}
title={modalTitle}
message={modalMessage}
confirmText={modalConfirmText}
cancelText={modalCancelText}
showCancel={modalShowCancel}
tone={modalTone}
onConfirm={handleModalConfirm}
onCancel={closeModal}
/>
<style>
.route-view {
min-height: 100vh;
padding: 20px;
display: flex;
flex-direction: column;
gap: 16px;
background: linear-gradient(180deg, var(--surface-2) 0%, var(--bg-editor) 100%);
color: var(--text-primary);
}
.route-header h1 {
font-size: 1.1rem;
margin-bottom: 4px;
}
.route-header p {
color: var(--text-muted);
font-size: 0.9rem;
}
.route-card {
border: 1px solid var(--border-soft);
background: var(--surface-1);
border-radius: 10px;
padding: 14px;
display: flex;
flex-direction: column;
gap: 10px;
max-width: 420px;
}
.route-card label {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 0.82rem;
color: var(--text-muted);
}
.route-card input {
border: 1px solid var(--border-soft);
border-radius: 7px;
background: var(--bg-app);
color: var(--text-primary);
padding: 8px 10px;
}
.route-card button {
width: fit-content;
padding: 7px 11px;
border-radius: 7px;
border: 1px solid var(--border-strong);
background: var(--surface-3);
color: var(--text-primary);
cursor: pointer;
}
</style>

View File

@ -2,6 +2,16 @@
import { goto } from "$app/navigation";
import AppModal from "$lib/components/AppModal.svelte";
import Navbar from "$lib/components/Navbar.svelte";
import {
addFragmentType,
addSettingsTag,
removeFragmentType,
removeSettingsTag,
settingsFragmentTypes,
settingsTags,
updateFragmentType,
updateSettingsTag
} from "$lib/stores/settings";
const activeSection = "settings";
@ -13,6 +23,12 @@
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 = "";
function showModal(options: {
action: "logout-confirm" | "logout-info";
@ -77,6 +93,68 @@
goto("/");
}
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();
}
}
</script>
<div class="app-shell panel-closed">
@ -108,6 +186,90 @@
</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="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">
<h2>Fragment Types</h2>
<p class="section-copy">Configure custom fragment types for the Fragments section.</p>
<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>
</main>
</div>
@ -152,7 +314,7 @@
display: flex;
flex-direction: column;
gap: 12px;
max-width: 460px;
max-width: 640px;
}
.toggle-row {
@ -178,6 +340,93 @@
color: var(--text-primary);
padding: 8px 10px;
}
.route-card h2 {
font-size: 0.95rem;
color: var(--text-primary);
}
.section-copy {
font-size: 0.82rem;
color: var(--text-muted);
}
.create-row {
display: flex;
gap: 8px;
}
.create-row input {
flex: 1;
}
.item-list {
list-style: none;
display: flex;
flex-direction: column;
gap: 8px;
}
.item-row {
border: 1px solid var(--border-soft);
border-radius: 8px;
padding: 8px 10px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
font-size: 0.84rem;
color: var(--text-primary);
background: var(--bg-app);
}
.item-row input,
.route-card input {
border: 1px solid var(--border-soft);
border-radius: 7px;
background: var(--bg-app);
color: var(--text-primary);
padding: 6px 9px;
min-width: 200px;
}
.row-actions {
display: flex;
gap: 6px;
}
.secondary-btn,
.ghost-btn,
.danger-btn {
border: 1px solid var(--border-soft);
border-radius: 7px;
padding: 6px 10px;
font-size: 0.78rem;
cursor: pointer;
color: var(--text-primary);
}
.secondary-btn {
background: var(--surface-3);
border-color: var(--border-strong);
}
.ghost-btn {
background: var(--surface-1);
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);
}
</style>

View File

@ -188,3 +188,31 @@ That lets UI code remain identical.
3. Wire `SidePanel` item load to `entries.load`.
4. Add vault unlock modal + `vault.load_all` on startup.
5. Keep all backend calls behind `sendCommand` only.
## Frontend Store Architecture (Current)
Current frontend uses feature stores in `Journal.App/src/lib/stores/`:
- `entries.ts` -> `entriesStore`
- `fragments.ts` -> `fragmentsStore`
- `todos.ts` -> `todoListsStore`, `todosStore`
- `lists.ts` -> `listsStore`
- `settings.ts` -> `settingsTags`, `settingsFragmentTypes`
Current pattern is store-first for most feature CRUD and parsing (especially fragments and todos), with UI components invoking store helpers.
## State/CRUD Gaps Still Needed
To fully standardize state management:
1. Move settings add/edit/remove logic into `settings.ts` helper functions (currently in route component code).
2. Add full CRUD helpers for `entries.ts` and `lists.ts` (update/remove/reorder, not only draft creation).
3. Make todo list metadata + todo items update atomically through a single store API wrapper.
4. Move calendar-created entries out of local component state into a dedicated calendar store.
5. Add persistence/hydration strategy between stores and backend (`entries.load/save`, `vault.load_all`, etc.).
## Recommended Rule
- Keep all feature data mutations in store helper APIs.
- Keep route/component files focused on view state and command orchestration.
- Keep backend transport (`sendCommand`) separate from pure local store mutation helpers, then compose both in thin feature services.