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:
parent
a39e634b7b
commit
54bef33f0b
@ -2,6 +2,49 @@
|
|||||||
|
|
||||||
This template should help get you started developing with Tauri, SvelteKit and TypeScript in Vite.
|
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
|
## 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).
|
[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).
|
||||||
|
|||||||
@ -1,13 +1,57 @@
|
|||||||
<script lang="ts">
|
<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 openDocumentId = "entries/daily-notes";
|
||||||
export let openDocumentName = "Daily Notes";
|
export let openDocumentName = "Daily Notes";
|
||||||
export let openDocumentContent = "";
|
export let openDocumentContent = "";
|
||||||
export let onDocumentContentChange: (content: string) => void = () => {};
|
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 markdownText = openDocumentContent;
|
||||||
let lastOpenDocumentId = openDocumentId;
|
let lastOpenDocumentId = openDocumentId;
|
||||||
let previewOnly = false;
|
let previewOnly = false;
|
||||||
let editorInput: HTMLTextAreaElement | null = null;
|
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) {
|
function updateDraft(value: string) {
|
||||||
markdownText = value;
|
markdownText = value;
|
||||||
@ -59,6 +103,193 @@
|
|||||||
applyWrap("[", "](https://example.com)");
|
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 {
|
function escapeHtml(input: string): string {
|
||||||
return input
|
return input
|
||||||
.replaceAll("&", "&")
|
.replaceAll("&", "&")
|
||||||
@ -179,6 +410,25 @@
|
|||||||
markdownText = openDocumentContent;
|
markdownText = openDocumentContent;
|
||||||
lastOpenDocumentId = openDocumentId;
|
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);
|
$: editorTitle = extractEditorTitle(markdownText);
|
||||||
$: renderedHtml = renderMarkdown(markdownText);
|
$: renderedHtml = renderMarkdown(markdownText);
|
||||||
@ -186,59 +436,177 @@
|
|||||||
|
|
||||||
<main class="editor-panel" aria-label="Editor area">
|
<main class="editor-panel" aria-label="Editor area">
|
||||||
<header class="editor-header">
|
<header class="editor-header">
|
||||||
<h1>{editorTitle}</h1>
|
<h1>{activeSection === "fragments" ? "Create Fragment" : activeSection === "todos" ? "To-Do List" : editorTitle}</h1>
|
||||||
<div class="editor-actions">
|
{#if activeSection !== "fragments" && activeSection !== "todos"}
|
||||||
<button type="button" class:primary={!previewOnly} on:click={() => (previewOnly = false)}>Write</button>
|
<div class="editor-actions">
|
||||||
<button type="button" class:primary={previewOnly} on:click={() => (previewOnly = true)}>Preview</button>
|
<button type="button" class:primary={!previewOnly} on:click={() => (previewOnly = false)}>Write</button>
|
||||||
</div>
|
<button type="button" class:primary={previewOnly} on:click={() => (previewOnly = true)}>Preview</button>
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
</header>
|
||||||
|
|
||||||
<div class="editor-workspace">
|
{#if activeSection === "fragments"}
|
||||||
{#if previewOnly}
|
<section class="fragment-surface">
|
||||||
<article class="markdown-preview" aria-label="Markdown preview">
|
{#if fragmentMode === "view"}
|
||||||
{@html renderedHtml}
|
<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>
|
</article>
|
||||||
{:else}
|
{:else}
|
||||||
<textarea
|
<form class="fragment-form" on:submit|preventDefault={fragmentMode === "create" ? createNewFragment : saveFragmentEdits}>
|
||||||
bind:this={editorInput}
|
<h2>{fragmentMode === "create" ? "New Fragment" : "Edit Fragment"}</h2>
|
||||||
class="markdown-input"
|
<input type="text" placeholder="Title" bind:value={fragmentTitle} aria-label="Fragment title" />
|
||||||
bind:value={markdownText}
|
<div class="fragment-form-row">
|
||||||
on:input={(event) => updateDraft((event.currentTarget as HTMLTextAreaElement).value)}
|
<select bind:value={fragmentType} aria-label="Fragment type">
|
||||||
aria-label="Markdown input"
|
{#each fragmentTypeOptions as type}
|
||||||
></textarea>
|
<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}
|
{/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>
|
</main>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@ -309,6 +677,245 @@
|
|||||||
grid-template-rows: minmax(0, 1fr);
|
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 {
|
.editor-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
@ -16,40 +16,11 @@
|
|||||||
{ id: "lists", label: "Lists", icon: "lists" }
|
{ 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) {
|
function selectItem(id: string) {
|
||||||
onSelect(id);
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:window on:click={closeProfileMenu} on:keydown={handleWindowKeydown} />
|
|
||||||
|
|
||||||
<aside class="navbar" aria-label="Primary navigation">
|
<aside class="navbar" aria-label="Primary navigation">
|
||||||
<div class="navbar-header">
|
<div class="navbar-header">
|
||||||
<img src="svelte.svg" alt="Journal logo" />
|
<img src="svelte.svg" alt="Journal logo" />
|
||||||
@ -73,31 +44,15 @@
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="user-chip"
|
|
||||||
type="button"
|
type="button"
|
||||||
class:is-active={profileMenuOpen}
|
class="settings-chip"
|
||||||
aria-label="Profile menu"
|
class:is-active={activeSection === "settings"}
|
||||||
on:click|stopPropagation={toggleProfileMenu}
|
aria-label="Settings"
|
||||||
|
on:click={() => selectItem("settings")}
|
||||||
>
|
>
|
||||||
<img src="https://placehold.co/800x600/09090b/jpg" alt="Profile" />
|
<span class="material-symbols-outlined">settings</span>
|
||||||
<span class="nav-tooltip" role="tooltip">John Doe</span>
|
<span class="nav-tooltip" role="tooltip">Settings</span>
|
||||||
</button>
|
</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>
|
</aside>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@ -137,7 +92,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nav-button,
|
.nav-button,
|
||||||
.user-chip {
|
.settings-chip {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 44px;
|
width: 44px;
|
||||||
height: 44px;
|
height: 44px;
|
||||||
@ -154,6 +109,10 @@
|
|||||||
font-size: 1.18rem;
|
font-size: 1.18rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-chip .material-symbols-outlined {
|
||||||
|
font-size: 1.18rem;
|
||||||
|
}
|
||||||
|
|
||||||
.nav-tooltip {
|
.nav-tooltip {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: calc(100% + 12px);
|
left: calc(100% + 12px);
|
||||||
@ -175,8 +134,8 @@
|
|||||||
|
|
||||||
.nav-button:hover,
|
.nav-button:hover,
|
||||||
.nav-button:focus-visible,
|
.nav-button:focus-visible,
|
||||||
.user-chip:hover,
|
.settings-chip:hover,
|
||||||
.user-chip:focus-visible {
|
.settings-chip:focus-visible {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
border-color: var(--border-soft);
|
border-color: var(--border-soft);
|
||||||
@ -184,8 +143,8 @@
|
|||||||
|
|
||||||
.nav-button:hover .nav-tooltip,
|
.nav-button:hover .nav-tooltip,
|
||||||
.nav-button:focus-visible .nav-tooltip,
|
.nav-button:focus-visible .nav-tooltip,
|
||||||
.user-chip:hover .nav-tooltip,
|
.settings-chip:hover .nav-tooltip,
|
||||||
.user-chip:focus-visible .nav-tooltip {
|
.settings-chip:focus-visible .nav-tooltip {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateY(-50%) translateX(0);
|
transform: translateY(-50%) translateX(0);
|
||||||
}
|
}
|
||||||
@ -200,72 +159,25 @@
|
|||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-chip {
|
.settings-chip {
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-chip img {
|
.settings-chip.is-active {
|
||||||
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);
|
|
||||||
color: var(--text-primary);
|
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);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 980px) {
|
@media (max-width: 980px) {
|
||||||
.nav-button,
|
.nav-button,
|
||||||
.user-chip {
|
.settings-chip {
|
||||||
width: 40px;
|
width: 40px;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-menu {
|
|
||||||
left: calc(100% + 8px);
|
|
||||||
bottom: 10px;
|
|
||||||
min-width: 144px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -1,5 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import CalendarWidget from "$lib/components/CalendarWidget.svelte";
|
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 activeSection = "entries";
|
||||||
export let activeDocumentId = "";
|
export let activeDocumentId = "";
|
||||||
@ -10,50 +14,15 @@
|
|||||||
label: string;
|
label: string;
|
||||||
initialContent: string;
|
initialContent: string;
|
||||||
};
|
};
|
||||||
|
let todoDocuments: SidePanelItem[] = [];
|
||||||
|
let customCalendarEntries: SidePanelItem[] = [];
|
||||||
|
|
||||||
const sectionTitles: Record<string, string> = {
|
const sectionTitles: Record<string, string> = {
|
||||||
entries: "Entries",
|
entries: "Entries",
|
||||||
calendar: "Calendar",
|
calendar: "Calendar",
|
||||||
fragments: "Fragments",
|
fragments: "Fragments",
|
||||||
todos: "To-Do List",
|
todos: "To-Do List",
|
||||||
lists: "Lists",
|
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." }]
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
@ -149,16 +118,82 @@
|
|||||||
.map(({ day, dateKey, ...item }) => item);
|
.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";
|
$: 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";
|
$: isCalendarSection = activeSection === "calendar";
|
||||||
$: calendarEntries = getCalendarEntries(calendarYear, calendarMonth);
|
$: calendarEntries = [...customCalendarEntries, ...getCalendarEntries(calendarYear, calendarMonth)];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="side-panel" aria-label="Section panel">
|
<section class="side-panel" aria-label="Section panel">
|
||||||
<header class="panel-header">
|
<header class="panel-header">
|
||||||
<h2>{panelTitle}</h2>
|
<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>
|
<span class="material-symbols-outlined">add</span>
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
28
Journal.App/src/lib/stores/entries.ts
Normal file
28
Journal.App/src/lib/stores/entries.ts
Normal 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..."
|
||||||
|
};
|
||||||
|
}
|
||||||
96
Journal.App/src/lib/stores/fragments.ts
Normal file
96
Journal.App/src/lib/stores/fragments.ts
Normal 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);
|
||||||
|
}
|
||||||
24
Journal.App/src/lib/stores/lists.ts
Normal file
24
Journal.App/src/lib/stores/lists.ts
Normal 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"
|
||||||
|
};
|
||||||
|
}
|
||||||
66
Journal.App/src/lib/stores/settings.ts
Normal file
66
Journal.App/src/lib/stores/settings.ts
Normal 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;
|
||||||
|
}
|
||||||
99
Journal.App/src/lib/stores/todos.ts
Normal file
99
Journal.App/src/lib/stores/todos.ts
Normal 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: []
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -1,9 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import AppModal from "$lib/components/AppModal.svelte";
|
import AppModal from "$lib/components/AppModal.svelte";
|
||||||
|
import { entriesStore, getDefaultEntry } from "$lib/stores/entries";
|
||||||
import Navbar from "$lib/components/Navbar.svelte";
|
import Navbar from "$lib/components/Navbar.svelte";
|
||||||
import SidePanel from "$lib/components/SidePanel.svelte";
|
import SidePanel from "$lib/components/SidePanel.svelte";
|
||||||
import EditorPanel from "$lib/components/EditorPanel.svelte";
|
import EditorPanel from "$lib/components/EditorPanel.svelte";
|
||||||
|
import { get } from "svelte/store";
|
||||||
|
|
||||||
type OpenDocument = {
|
type OpenDocument = {
|
||||||
id: string;
|
id: string;
|
||||||
@ -11,13 +13,15 @@
|
|||||||
initialContent: string;
|
initialContent: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const initialEntry = getDefaultEntry(get(entriesStore));
|
||||||
|
|
||||||
let selectedSection = "entries";
|
let selectedSection = "entries";
|
||||||
let panelOpen = true;
|
let panelOpen = true;
|
||||||
let activeDocumentId = "entries/daily-notes";
|
let activeDocumentId = initialEntry?.id ?? "entries/daily-notes";
|
||||||
let activeDocumentLabel = "Daily Notes";
|
let activeDocumentLabel = initialEntry?.label ?? "Daily Notes";
|
||||||
let openDocuments: Record<string, string> = {
|
let openDocuments: Record<string, string> = initialEntry
|
||||||
"entries/daily-notes": "# Daily Notes\n\nStart writing today's entry..."
|
? { [initialEntry.id]: initialEntry.initialContent }
|
||||||
};
|
: { "entries/daily-notes": "# Daily Notes\n\nStart writing today's entry..." };
|
||||||
let modalOpen = false;
|
let modalOpen = false;
|
||||||
let modalTitle = "";
|
let modalTitle = "";
|
||||||
let modalMessage = "";
|
let modalMessage = "";
|
||||||
@ -104,6 +108,11 @@
|
|||||||
function handleDocumentContentChange(content: string) {
|
function handleDocumentContentChange(content: string) {
|
||||||
openDocuments = { ...openDocuments, [activeDocumentId]: content };
|
openDocuments = { ...openDocuments, [activeDocumentId]: content };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleDeleteDocument(id: string) {
|
||||||
|
const { [id]: _, ...remaining } = openDocuments;
|
||||||
|
openDocuments = remaining;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="app-shell" class:panel-closed={!panelOpen}>
|
<div class="app-shell" class:panel-closed={!panelOpen}>
|
||||||
@ -121,6 +130,8 @@
|
|||||||
openDocumentName={activeDocumentLabel}
|
openDocumentName={activeDocumentLabel}
|
||||||
openDocumentContent={openDocuments[activeDocumentId] ?? ""}
|
openDocumentContent={openDocuments[activeDocumentId] ?? ""}
|
||||||
onDocumentContentChange={handleDocumentContentChange}
|
onDocumentContentChange={handleDocumentContentChange}
|
||||||
|
onOpenDocument={handleOpenDocument}
|
||||||
|
onDeleteDocument={handleDeleteDocument}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
|
||||||
|
|
||||||
|
|
||||||
@ -2,6 +2,16 @@
|
|||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import AppModal from "$lib/components/AppModal.svelte";
|
import AppModal from "$lib/components/AppModal.svelte";
|
||||||
import Navbar from "$lib/components/Navbar.svelte";
|
import Navbar from "$lib/components/Navbar.svelte";
|
||||||
|
import {
|
||||||
|
addFragmentType,
|
||||||
|
addSettingsTag,
|
||||||
|
removeFragmentType,
|
||||||
|
removeSettingsTag,
|
||||||
|
settingsFragmentTypes,
|
||||||
|
settingsTags,
|
||||||
|
updateFragmentType,
|
||||||
|
updateSettingsTag
|
||||||
|
} from "$lib/stores/settings";
|
||||||
|
|
||||||
const activeSection = "settings";
|
const activeSection = "settings";
|
||||||
|
|
||||||
@ -13,6 +23,12 @@
|
|||||||
let modalShowCancel = false;
|
let modalShowCancel = false;
|
||||||
let modalTone: "default" | "danger" = "default";
|
let modalTone: "default" | "danger" = "default";
|
||||||
let modalAction: "logout-confirm" | "logout-info" | null = null;
|
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: {
|
function showModal(options: {
|
||||||
action: "logout-confirm" | "logout-info";
|
action: "logout-confirm" | "logout-info";
|
||||||
@ -77,6 +93,68 @@
|
|||||||
|
|
||||||
goto("/");
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="app-shell panel-closed">
|
<div class="app-shell panel-closed">
|
||||||
@ -108,6 +186,90 @@
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</section>
|
</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>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -152,7 +314,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
max-width: 460px;
|
max-width: 640px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle-row {
|
.toggle-row {
|
||||||
@ -178,6 +340,93 @@
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
padding: 8px 10px;
|
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>
|
</style>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -188,3 +188,31 @@ That lets UI code remain identical.
|
|||||||
3. Wire `SidePanel` item load to `entries.load`.
|
3. Wire `SidePanel` item load to `entries.load`.
|
||||||
4. Add vault unlock modal + `vault.load_all` on startup.
|
4. Add vault unlock modal + `vault.load_all` on startup.
|
||||||
5. Keep all backend calls behind `sendCommand` only.
|
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.
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user