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.
|
||||
|
||||
## 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).
|
||||
|
||||
@ -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("&", "&")
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
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">
|
||||
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>
|
||||
|
||||
|
||||
@ -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 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>
|
||||
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user