Improve mobile layout and configure Prettier for Svelte

This commit is contained in:
Jacob Schmidt 2026-02-28 13:00:04 -06:00
parent 8f67269f44
commit c2a94ba6f4
39 changed files with 1845 additions and 700 deletions

View File

@ -0,0 +1,8 @@
node_modules/
build/
.svelte-kit/
.vscode/
dist/
coverage/
target/
src-tauri/target/

View File

@ -18,12 +18,12 @@ npm run tauri dev # Tauri desktop window (connects to dev server)
## Build Targets
| Command | Output | Use case |
|---------|--------|----------|
| `npm run build` | `Journal.App/build/` | Web bundle for `Journal.WebGateway` |
| `.\scripts\publish-app.ps1 -Target web` | `Journal.App/build/` | Same, via script |
| `.\scripts\publish-app.ps1 -Target tauri -TauriBundles none` | `src-tauri/target/release/journalapp.exe` | Raw desktop exe |
| `.\scripts\publish-app.ps1 -Target tauri -TauriBundles nsis` | NSIS installer | Packaged installer |
| Command | Output | Use case |
| ------------------------------------------------------------ | ----------------------------------------- | ----------------------------------- |
| `npm run build` | `Journal.App/build/` | Web bundle for `Journal.WebGateway` |
| `.\scripts\publish-app.ps1 -Target web` | `Journal.App/build/` | Same, via script |
| `.\scripts\publish-app.ps1 -Target tauri -TauriBundles none` | `src-tauri/target/release/journalapp.exe` | Raw desktop exe |
| `.\scripts\publish-app.ps1 -Target tauri -TauriBundles nsis` | NSIS installer | Packaged installer |
## Frontend State Management
@ -31,13 +31,13 @@ Svelte stores are the source of truth for all feature state.
### Current Stores
| Store file | State exports | Notes |
|-----------|---------------|-------|
| `src/lib/stores/entries.ts` | `entriesStore` | Entry list, `getDefaultEntry`, `createEntryDraft` |
| `src/lib/stores/fragments.ts` | `fragmentsStore` | Fragment CRUD + parse/serialize helpers |
| `src/lib/stores/todos.ts` | `todoListsStore`, `todosStore` | Todo list and item CRUD |
| `src/lib/stores/lists.ts` | `listsStore` | Generic list CRUD, `createListDraft` |
| `src/lib/stores/settings.ts` | `settingsTags`, `settingsFragmentTypes` | Tag/type config |
| Store file | State exports | Notes |
| ----------------------------- | --------------------------------------- | ------------------------------------------------- |
| `src/lib/stores/entries.ts` | `entriesStore` | Entry list, `getDefaultEntry`, `createEntryDraft` |
| `src/lib/stores/fragments.ts` | `fragmentsStore` | Fragment CRUD + parse/serialize helpers |
| `src/lib/stores/todos.ts` | `todoListsStore`, `todosStore` | Todo list and item CRUD |
| `src/lib/stores/lists.ts` | `listsStore` | Generic list CRUD, `createListDraft` |
| `src/lib/stores/settings.ts` | `settingsTags`, `settingsFragmentTypes` | Tag/type config |
### Store-First Rule
@ -47,14 +47,14 @@ Svelte stores are the source of truth for all feature state.
## Tauri Commands (Rust → Frontend)
| Command | Description |
|---------|-------------|
| `sidecar_command` | Forward a `CommandEnvelope` to `Journal.Sidecar` stdin/stdout and return parsed JSON |
| `get_sidecar_root` | Get currently resolved sidecar root path |
| `set_sidecar_root` | Override root path (saved to `settings.json`, restarts sidecar) |
| `get_ui_settings` | Load tag/fragment-type settings |
| `set_ui_settings` | Persist tag/fragment-type settings |
| `shutdown` | Stop sidecar, exit app |
| Command | Description |
| ------------------ | ------------------------------------------------------------------------------------ |
| `sidecar_command` | Forward a `CommandEnvelope` to `Journal.Sidecar` stdin/stdout and return parsed JSON |
| `get_sidecar_root` | Get currently resolved sidecar root path |
| `set_sidecar_root` | Override root path (saved to `settings.json`, restarts sidecar) |
| `get_ui_settings` | Load tag/fragment-type settings |
| `set_ui_settings` | Persist tag/fragment-type settings |
| `shutdown` | Stop sidecar, exit app |
## Sidecar Path Resolution

View File

@ -18,6 +18,8 @@
"@sveltejs/kit": "^2.9.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tauri-apps/cli": "^2",
"prettier": "^3.8.1",
"prettier-plugin-svelte": "^3.5.0",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"typescript": "~5.6.2",
@ -906,7 +908,6 @@
"integrity": "sha512-NXsZLvalgI3HrHG6ogoEVzjyV7bSFQNqQeekfU7nNufQFrRyV3EBDfQKEwxx50peu7spZR42JuC1PFhwxuvBrg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@sveltejs/acorn-typescript": "^1.0.5",
@ -949,7 +950,6 @@
"integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@sveltejs/vite-plugin-svelte-inspector": "^4.0.1",
"debug": "^4.4.1",
@ -1256,7 +1256,6 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -1543,7 +1542,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -1580,6 +1578,33 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/prettier": {
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/prettier-plugin-svelte": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.5.0.tgz",
"integrity": "sha512-2lLO/7EupnjO/95t+XZesXs8Bf3nYLIDfCo270h5QWbj/vjLqmrQ1LiRk9LPggxSDsnVYfehamZNf+rgQYApZg==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"prettier": "^3.0.0",
"svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0"
}
},
"node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
@ -1690,7 +1715,6 @@
"integrity": "sha512-pRUBr6j6uQDgBi208gHnGRMykw0Rf2Yr1HmLyRucsvcaYgIUxswJkT93WZJflsmezu5s8Lq+q78EoyLv2yaFCg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@jridgewell/remapping": "^2.3.4",
"@jridgewell/sourcemap-codec": "^1.5.0",
@ -1770,7 +1794,6 @@
"integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -1785,7 +1808,6 @@
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",

View File

@ -9,7 +9,9 @@
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"tauri": "tauri"
"tauri": "tauri",
"format": "prettier --write .",
"format:check": "prettier --check ."
},
"license": "MIT",
"dependencies": {
@ -22,6 +24,8 @@
"@sveltejs/kit": "^2.9.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tauri-apps/cli": "^2",
"prettier": "^3.8.1",
"prettier-plugin-svelte": "^3.5.0",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"typescript": "~5.6.2",

View File

@ -0,0 +1,11 @@
module.exports = {
plugins: ["prettier-plugin-svelte"],
overrides: [
{
files: "*.svelte",
options: {
parser: "svelte",
},
},
],
};

View File

@ -3,9 +3,5 @@
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"dialog:default",
"opener:default"
]
"permissions": ["core:default", "dialog:default", "opener:default"]
}

View File

@ -32,4 +32,4 @@
"icons/icon.ico"
]
}
}
}

View File

@ -3,12 +3,16 @@
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200" />
<link rel="stylesheet" href="style.css">
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200"
/>
<link rel="stylesheet" href="style.css" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Journal</title>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>

View File

@ -4,7 +4,7 @@ import { pickCase } from "./normalize";
export function hydrateWorkspace(password: string): Promise<unknown> {
return sendCommand<unknown>({
action: "db.hydrate_workspace",
payload: { password }
payload: { password },
});
}
@ -24,17 +24,19 @@ type PersistOptions = {
keepalive?: boolean;
};
async function getRuntimeConfig(options: PersistOptions = {}): Promise<RuntimeConfig> {
async function getRuntimeConfig(
options: PersistOptions = {},
): Promise<RuntimeConfig> {
const data = await sendCommand<RuntimeConfigRaw>(
{
action: "config.get"
action: "config.get",
},
options
options,
);
return {
dataDirectory: pickCase(data, "dataDirectory", "DataDirectory", ""),
vaultDirectory: pickCase(data, "vaultDirectory", "VaultDirectory", "")
vaultDirectory: pickCase(data, "vaultDirectory", "VaultDirectory", ""),
};
}
@ -45,8 +47,8 @@ export async function unlockVaultWorkspace(password: string): Promise<void> {
payload: {
password,
vaultDirectory: config.vaultDirectory,
dataDirectory: config.dataDirectory
}
dataDirectory: config.dataDirectory,
},
});
if (!loaded) {
@ -57,12 +59,15 @@ export async function unlockVaultWorkspace(password: string): Promise<void> {
action: "db.hydrate_workspace",
payload: {
password,
dataDirectory: config.dataDirectory
}
dataDirectory: config.dataDirectory,
},
});
}
export async function persistAndClearVault(password: string, options: PersistOptions = {}): Promise<void> {
export async function persistAndClearVault(
password: string,
options: PersistOptions = {},
): Promise<void> {
const config = await getRuntimeConfig(options);
await sendCommand<boolean>(
@ -71,19 +76,19 @@ export async function persistAndClearVault(password: string, options: PersistOpt
payload: {
password,
vaultDirectory: config.vaultDirectory,
dataDirectory: config.dataDirectory
}
dataDirectory: config.dataDirectory,
},
},
options
options,
);
await sendCommand<boolean>(
{
action: "vault.clear_data_directory",
payload: {
dataDirectory: config.dataDirectory
}
dataDirectory: config.dataDirectory,
},
},
options
options,
);
}

View File

@ -9,14 +9,17 @@ type SendCommandOptions = {
keepalive?: boolean;
};
export async function sendCommand<T>(command: BackendCommand, options: SendCommandOptions = {}): Promise<T> {
export async function sendCommand<T>(
command: BackendCommand,
options: SendCommandOptions = {},
): Promise<T> {
const envelope: BackendCommand = {
...command,
correlationId: command.correlationId ?? newCorrelationId()
correlationId: command.correlationId ?? newCorrelationId(),
};
const response = await invoke<BackendResponse<T>>("sidecar_command", {
command: envelope,
keepalive: options.keepalive === true
keepalive: options.keepalive === true,
});
if (!response.ok) {

View File

@ -1,5 +1,9 @@
import { sendCommand } from "./client";
import { normalizeFragment, type FragmentDto, type FragmentDtoRaw } from "./fragments";
import {
normalizeFragment,
type FragmentDto,
type FragmentDtoRaw,
} from "./fragments";
import { pickCase } from "./normalize";
export type ParsedSectionDto = {
@ -107,80 +111,126 @@ function normalizeSection(raw: ParsedSectionDtoRaw): ParsedSectionDto {
return {
title: pickCase(raw, "title", "Title", ""),
content: pickCase(raw, "content", "Content", [] as string[]),
checkboxes: pickCase(raw, "checkboxes", "Checkboxes", {} as Record<string, boolean>)
checkboxes: pickCase(
raw,
"checkboxes",
"Checkboxes",
{} as Record<string, boolean>,
),
};
}
function normalizeJournalEntry(raw: JournalEntryDtoRaw | undefined): JournalEntryDto {
const fragments = pickCase(raw, "fragments", "Fragments", [] as FragmentDtoRaw[]);
const sections = pickCase(raw, "sections", "Sections", {} as Record<string, ParsedSectionDtoRaw>);
function normalizeJournalEntry(
raw: JournalEntryDtoRaw | undefined,
): JournalEntryDto {
const fragments = pickCase(
raw,
"fragments",
"Fragments",
[] as FragmentDtoRaw[],
);
const sections = pickCase(
raw,
"sections",
"Sections",
{} as Record<string, ParsedSectionDtoRaw>,
);
return {
date: pickCase(raw, "date", "Date", ""),
fragments: fragments.map(normalizeFragment),
rawContent: pickCase(raw, "rawContent", "RawContent", ""),
sections: Object.fromEntries(
Object.entries(sections).map(([key, value]) => [key, normalizeSection(value)])
)
Object.entries(sections).map(([key, value]) => [
key,
normalizeSection(value),
]),
),
};
}
function normalizeEntryListItem(raw: EntryListItemDtoRaw): EntryListItemDto {
return {
fileName: pickCase(raw, "fileName", "FileName", ""),
filePath: pickCase(raw, "filePath", "FilePath", "")
filePath: pickCase(raw, "filePath", "FilePath", ""),
};
}
function normalizeEntryLoadResult(raw: EntryLoadResultDtoRaw): EntryLoadResultDto {
const nestedEntry = pickCase(raw, "entry", "Entry", undefined as JournalEntryDtoRaw | undefined);
const entry =
nestedEntry
? normalizeJournalEntry(nestedEntry)
: normalizeJournalEntry({
date: pickCase(raw, "date", "Date", undefined as string | undefined),
rawContent: pickCase(raw, "rawContent", "RawContent", undefined as string | undefined),
fragments: [],
sections: {}
});
function normalizeEntryLoadResult(
raw: EntryLoadResultDtoRaw,
): EntryLoadResultDto {
const nestedEntry = pickCase(
raw,
"entry",
"Entry",
undefined as JournalEntryDtoRaw | undefined,
);
const entry = nestedEntry
? normalizeJournalEntry(nestedEntry)
: normalizeJournalEntry({
date: pickCase(raw, "date", "Date", undefined as string | undefined),
rawContent: pickCase(
raw,
"rawContent",
"RawContent",
undefined as string | undefined,
),
fragments: [],
sections: {},
});
return {
fileName: pickCase(raw, "fileName", "FileName", ""),
filePath: pickCase(raw, "filePath", "FilePath", ""),
entry
entry,
};
}
function normalizeEntrySearchResult(raw: EntrySearchResultDtoRaw): EntrySearchResultDto {
const nestedEntry = pickCase(raw, "entry", "Entry", undefined as JournalEntryDtoRaw | undefined);
const entry =
nestedEntry
? normalizeJournalEntry(nestedEntry)
: normalizeJournalEntry({
date: pickCase(raw, "date", "Date", undefined as string | undefined),
rawContent: pickCase(raw, "rawContent", "RawContent", undefined as string | undefined),
fragments: [],
sections: {}
});
function normalizeEntrySearchResult(
raw: EntrySearchResultDtoRaw,
): EntrySearchResultDto {
const nestedEntry = pickCase(
raw,
"entry",
"Entry",
undefined as JournalEntryDtoRaw | undefined,
);
const entry = nestedEntry
? normalizeJournalEntry(nestedEntry)
: normalizeJournalEntry({
date: pickCase(raw, "date", "Date", undefined as string | undefined),
rawContent: pickCase(
raw,
"rawContent",
"RawContent",
undefined as string | undefined,
),
fragments: [],
sections: {},
});
return {
fileName: pickCase(raw, "fileName", "FileName", ""),
entry
entry,
};
}
export async function listEntries(dataDirectory?: string): Promise<EntryListItemDto[]> {
export async function listEntries(
dataDirectory?: string,
): Promise<EntryListItemDto[]> {
const data = await sendCommand<EntryListItemDtoRaw[]>({
action: "entries.list",
payload: { dataDirectory }
payload: { dataDirectory },
});
return data.map(normalizeEntryListItem).filter((item) => Boolean(item.filePath));
return data
.map(normalizeEntryListItem)
.filter((item) => Boolean(item.filePath));
}
export async function loadEntry(filePath: string): Promise<EntryLoadResultDto> {
const data = await sendCommand<EntryLoadResultDtoRaw>({
action: "entries.load",
payload: { filePath }
payload: { filePath },
});
return normalizeEntryLoadResult(data);
@ -194,26 +244,30 @@ export async function saveEntry(payload: {
}): Promise<EntrySaveResultDto> {
const data = await sendCommand<EntrySaveResultDtoRaw>({
action: "entries.save",
payload
payload,
});
return {
filePath: pickCase(data, "filePath", "FilePath", "")
filePath: pickCase(data, "filePath", "FilePath", ""),
};
}
export async function deleteEntry(filePath: string): Promise<boolean> {
return sendCommand<boolean>({
action: "entries.delete",
payload: { filePath }
payload: { filePath },
});
}
export async function searchEntries(payload: EntrySearchRequestDto): Promise<EntrySearchResultDto[]> {
export async function searchEntries(
payload: EntrySearchRequestDto,
): Promise<EntrySearchResultDto[]> {
const data = await sendCommand<EntrySearchResultDtoRaw[]>({
action: "search.entries",
payload
payload,
});
return data.map(normalizeEntrySearchResult).filter((item) => Boolean(item.fileName));
return data
.map(normalizeEntrySearchResult)
.filter((item) => Boolean(item.fileName));
}

View File

@ -41,13 +41,13 @@ export function normalizeFragment(raw: FragmentDtoRaw): FragmentDto {
type: pickCase(raw, "type", "Type", ""),
description: pickCase(raw, "description", "Description", ""),
time: pickCase(raw, "time", "Time", ""),
tags: pickCase(raw, "tags", "Tags", [] as string[])
tags: pickCase(raw, "tags", "Tags", [] as string[]),
};
}
export async function listFragments(): Promise<FragmentDto[]> {
const data = await sendCommand<FragmentDtoRaw[]>({
action: "fragments.list"
action: "fragments.list",
});
return data.map(normalizeFragment).filter((item) => Boolean(item.id));
}
@ -55,32 +55,37 @@ export async function listFragments(): Promise<FragmentDto[]> {
export async function getFragment(id: string): Promise<FragmentDto | null> {
const data = await sendCommand<FragmentDtoRaw | null>({
action: "fragments.get",
id
id,
});
if (!data) return null;
const normalized = normalizeFragment(data);
return normalized.id ? normalized : null;
}
export async function createFragment(payload: CreateFragmentPayload): Promise<FragmentDto> {
export async function createFragment(
payload: CreateFragmentPayload,
): Promise<FragmentDto> {
const data = await sendCommand<FragmentDtoRaw>({
action: "fragments.create",
payload
payload,
});
return normalizeFragment(data);
}
export function updateFragment(id: string, payload: UpdateFragmentPayload): Promise<boolean> {
export function updateFragment(
id: string,
payload: UpdateFragmentPayload,
): Promise<boolean> {
return sendCommand<boolean>({
action: "fragments.update",
id,
payload
payload,
});
}
export function deleteFragment(id: string): Promise<boolean> {
return sendCommand<boolean>({
action: "fragments.delete",
id
id,
});
}

View File

@ -38,13 +38,13 @@ export function normalizeList(raw: ListDocumentDtoRaw): ListDocumentDto {
label: pickCase(raw, "label", "Label", ""),
content: pickCase(raw, "content", "Content", ""),
createdAt: pickCase(raw, "createdAt", "CreatedAt", ""),
updatedAt: pickCase(raw, "updatedAt", "UpdatedAt", "")
updatedAt: pickCase(raw, "updatedAt", "UpdatedAt", ""),
};
}
export async function listLists(): Promise<ListDocumentDto[]> {
const data = await sendCommand<ListDocumentDtoRaw[]>({
action: "lists.list"
action: "lists.list",
});
return data.map(normalizeList).filter((item) => Boolean(item.id));
}
@ -52,32 +52,37 @@ export async function listLists(): Promise<ListDocumentDto[]> {
export async function getList(id: string): Promise<ListDocumentDto | null> {
const data = await sendCommand<ListDocumentDtoRaw | null>({
action: "lists.get",
id
id,
});
if (!data) return null;
const normalized = normalizeList(data);
return normalized.id ? normalized : null;
}
export async function createList(payload: CreateListPayload): Promise<ListDocumentDto> {
export async function createList(
payload: CreateListPayload,
): Promise<ListDocumentDto> {
const data = await sendCommand<ListDocumentDtoRaw>({
action: "lists.create",
payload
payload,
});
return normalizeList(data);
}
export function updateList(id: string, payload: UpdateListPayload): Promise<boolean> {
export function updateList(
id: string,
payload: UpdateListPayload,
): Promise<boolean> {
return sendCommand<boolean>({
action: "lists.update",
id,
payload
payload,
});
}
export function deleteList(id: string): Promise<boolean> {
return sendCommand<boolean>({
action: "lists.delete",
id
id,
});
}

View File

@ -1,14 +1,16 @@
type UnknownObject = Record<string, unknown>;
function asObject(value: unknown): UnknownObject | undefined {
return value && typeof value === "object" ? (value as UnknownObject) : undefined;
return value && typeof value === "object"
? (value as UnknownObject)
: undefined;
}
export function pickCase<T>(
source: unknown,
camelKey: string,
pascalKey: string,
fallback: T
fallback: T,
): T {
const obj = asObject(source);
if (!obj) return fallback;

View File

@ -37,32 +37,40 @@ type EntryTemplateSaveResultDtoRaw = {
FilePath?: string;
};
function normalizeTemplateItem(raw: EntryTemplateItemDtoRaw): EntryTemplateItemDto {
function normalizeTemplateItem(
raw: EntryTemplateItemDtoRaw,
): EntryTemplateItemDto {
return {
fileName: pickCase(raw, "fileName", "FileName", ""),
filePath: pickCase(raw, "filePath", "FilePath", "")
filePath: pickCase(raw, "filePath", "FilePath", ""),
};
}
export async function listEntryTemplates(dataDirectory?: string): Promise<EntryTemplateItemDto[]> {
export async function listEntryTemplates(
dataDirectory?: string,
): Promise<EntryTemplateItemDto[]> {
const data = await sendCommand<EntryTemplateItemDtoRaw[]>({
action: "templates.list",
payload: { dataDirectory }
payload: { dataDirectory },
});
return data.map(normalizeTemplateItem).filter((item) => Boolean(item.filePath));
return data
.map(normalizeTemplateItem)
.filter((item) => Boolean(item.filePath));
}
export async function loadEntryTemplate(filePath: string): Promise<EntryTemplateLoadResultDto> {
export async function loadEntryTemplate(
filePath: string,
): Promise<EntryTemplateLoadResultDto> {
const data = await sendCommand<EntryTemplateLoadResultDtoRaw>({
action: "templates.load",
payload: { filePath }
payload: { filePath },
});
return {
fileName: pickCase(data, "fileName", "FileName", ""),
filePath: pickCase(data, "filePath", "FilePath", ""),
content: pickCase(data, "content", "Content", "")
content: pickCase(data, "content", "Content", ""),
};
}
@ -74,17 +82,17 @@ export async function saveEntryTemplate(payload: {
}): Promise<EntryTemplateSaveResultDto> {
const data = await sendCommand<EntryTemplateSaveResultDtoRaw>({
action: "templates.save",
payload
payload,
});
return {
filePath: pickCase(data, "filePath", "FilePath", "")
filePath: pickCase(data, "filePath", "FilePath", ""),
};
}
export async function deleteEntryTemplate(filePath: string): Promise<boolean> {
return sendCommand<boolean>({
action: "templates.delete",
payload: { filePath }
payload: { filePath },
});
}

View File

@ -66,7 +66,7 @@ function normalizeItem(raw: TodoItemDtoRaw): TodoItemDto {
listId: pickCase(raw, "listId", "ListId", ""),
text: pickCase(raw, "text", "Text", ""),
done: pickCase(raw, "done", "Done", false),
sortOrder: pickCase(raw, "sortOrder", "SortOrder", 0)
sortOrder: pickCase(raw, "sortOrder", "SortOrder", 0),
};
}
@ -76,13 +76,13 @@ function normalizeList(raw: TodoListDtoRaw): TodoListDto {
id: pickCase(raw, "id", "Id", ""),
label: pickCase(raw, "label", "Label", ""),
createdAt: pickCase(raw, "createdAt", "CreatedAt", ""),
items: rawItems.map(normalizeItem)
items: rawItems.map(normalizeItem),
};
}
export async function listTodoLists(): Promise<TodoListDto[]> {
const data = await sendCommand<TodoListDtoRaw[]>({
action: "todos.list"
action: "todos.list",
});
return data.map(normalizeList).filter((item) => Boolean(item.id));
}
@ -90,55 +90,65 @@ export async function listTodoLists(): Promise<TodoListDto[]> {
export async function getTodoList(id: string): Promise<TodoListDto | null> {
const data = await sendCommand<TodoListDtoRaw | null>({
action: "todos.get",
id
id,
});
if (!data) return null;
const normalized = normalizeList(data);
return normalized.id ? normalized : null;
}
export async function createTodoList(payload: CreateTodoListPayload): Promise<TodoListDto> {
export async function createTodoList(
payload: CreateTodoListPayload,
): Promise<TodoListDto> {
const data = await sendCommand<TodoListDtoRaw>({
action: "todos.create",
payload
payload,
});
return normalizeList(data);
}
export function updateTodoList(id: string, payload: UpdateTodoListPayload): Promise<boolean> {
export function updateTodoList(
id: string,
payload: UpdateTodoListPayload,
): Promise<boolean> {
return sendCommand<boolean>({
action: "todos.update",
id,
payload
payload,
});
}
export function deleteTodoList(id: string): Promise<boolean> {
return sendCommand<boolean>({
action: "todos.delete",
id
id,
});
}
export async function createTodoItem(payload: CreateTodoItemPayload): Promise<TodoItemDto> {
export async function createTodoItem(
payload: CreateTodoItemPayload,
): Promise<TodoItemDto> {
const data = await sendCommand<TodoItemDtoRaw>({
action: "todos.items.create",
payload
payload,
});
return normalizeItem(data);
}
export function updateTodoItem(id: string, payload: UpdateTodoItemPayload): Promise<boolean> {
export function updateTodoItem(
id: string,
payload: UpdateTodoItemPayload,
): Promise<boolean> {
return sendCommand<boolean>({
action: "todos.items.update",
id,
payload
payload,
});
}
export function deleteTodoItem(id: string): Promise<boolean> {
return sendCommand<boolean>({
action: "todos.items.delete",
id
id,
});
}

View File

@ -10,4 +10,3 @@ export type BackendCommand = {
export type BackendOk<T> = { ok: true; data: T };
export type BackendErr = { ok: false; error: string };
export type BackendResponse<T> = BackendOk<T> | BackendErr;

View File

@ -1,3 +1,4 @@
<!-- @format -->
<script lang="ts">
import { createEventDispatcher } from "svelte";
@ -67,9 +68,15 @@
<div class="modal-actions">
{#if showCancel}
<button type="button" class="secondary" on:click={handleCancel}>{cancelText}</button>
<button type="button" class="secondary" on:click={handleCancel}
>{cancelText}</button
>
{/if}
<button type="button" class:danger={tone === "danger"} on:click={handleConfirm}>
<button
type="button"
class:danger={tone === "danger"}
on:click={handleConfirm}
>
{confirmText}
</button>
</div>

View File

@ -1,13 +1,31 @@
<!-- @format -->
<script lang="ts">
export let onVisibleMonthChange: (month: { year: number; month: number; label: string }) => void = () => {};
export let onSelectedDateChange: (payload: { year: number; month: number; day: number; key: string }) => void =
() => {};
export let onDateActivate: (payload: { year: number; month: number; day: number; key: string }) => void = () => {};
export let onVisibleMonthChange: (month: {
year: number;
month: number;
label: string;
}) => void = () => {};
export let onSelectedDateChange: (payload: {
year: number;
month: number;
day: number;
key: string;
}) => void = () => {};
export let onDateActivate: (payload: {
year: number;
month: number;
day: number;
key: string;
}) => void = () => {};
const today = new Date();
let currentYear = today.getFullYear();
let currentMonth = today.getMonth();
let selectedDateKey = getDateKey(today.getFullYear(), today.getMonth(), today.getDate());
let selectedDateKey = getDateKey(
today.getFullYear(),
today.getMonth(),
today.getDate(),
);
const weekdays = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
@ -46,7 +64,7 @@
year: cell.year,
month: cell.month,
day: cell.day,
key
key,
});
}
@ -62,14 +80,20 @@
for (let i = 0; i < startOffset; i += 1) {
const day = prevMonthLastDay - startOffset + i + 1;
const prevMonthDate = new Date(year, month - 1, day);
const key = getDateKey(prevMonthDate.getFullYear(), prevMonthDate.getMonth(), day);
const key = getDateKey(
prevMonthDate.getFullYear(),
prevMonthDate.getMonth(),
day,
);
nextCells.push({
day,
month: prevMonthDate.getMonth(),
year: prevMonthDate.getFullYear(),
inMonth: false,
isToday: key === getDateKey(today.getFullYear(), today.getMonth(), today.getDate()),
isSelected: key === selectedDateKey
isToday:
key ===
getDateKey(today.getFullYear(), today.getMonth(), today.getDate()),
isSelected: key === selectedDateKey,
});
}
@ -80,43 +104,73 @@
month,
year,
inMonth: true,
isToday: key === getDateKey(today.getFullYear(), today.getMonth(), today.getDate()),
isSelected: key === selectedDateKey
isToday:
key ===
getDateKey(today.getFullYear(), today.getMonth(), today.getDate()),
isSelected: key === selectedDateKey,
});
}
const trailing = (7 - (nextCells.length % 7)) % 7;
for (let day = 1; day <= trailing; day += 1) {
const nextMonthDate = new Date(year, month + 1, day);
const key = getDateKey(nextMonthDate.getFullYear(), nextMonthDate.getMonth(), day);
const key = getDateKey(
nextMonthDate.getFullYear(),
nextMonthDate.getMonth(),
day,
);
nextCells.push({
day,
month: nextMonthDate.getMonth(),
year: nextMonthDate.getFullYear(),
inMonth: false,
isToday: key === getDateKey(today.getFullYear(), today.getMonth(), today.getDate()),
isSelected: key === selectedDateKey
isToday:
key ===
getDateKey(today.getFullYear(), today.getMonth(), today.getDate()),
isSelected: key === selectedDateKey,
});
}
return nextCells;
}
$: monthLabel = new Date(currentYear, currentMonth, 1).toLocaleString(undefined, { month: "long" });
$: monthLabel = new Date(currentYear, currentMonth, 1).toLocaleString(
undefined,
{ month: "long" },
);
$: cells = getCalendarCells(currentYear, currentMonth);
$: onVisibleMonthChange({ year: currentYear, month: currentMonth, label: monthLabel });
$: onVisibleMonthChange({
year: currentYear,
month: currentMonth,
label: monthLabel,
});
$: {
const parts = selectedDateKey.split("-");
const [year, month, day] = parts.map((value) => Number(value));
if (parts.length === 3 && !Number.isNaN(year) && !Number.isNaN(month) && !Number.isNaN(day)) {
onSelectedDateChange({ year, month: month - 1, day, key: selectedDateKey });
if (
parts.length === 3 &&
!Number.isNaN(year) &&
!Number.isNaN(month) &&
!Number.isNaN(day)
) {
onSelectedDateChange({
year,
month: month - 1,
day,
key: selectedDateKey,
});
}
}
</script>
<section class="calendar-widget" aria-label="Monthly calendar">
<header class="calendar-header">
<button type="button" class="nav-icon" aria-label="Previous month" on:click={() => changeMonth(-1)}>
<button
type="button"
class="nav-icon"
aria-label="Previous month"
on:click={() => changeMonth(-1)}
>
<span class="material-symbols-outlined">chevron_left</span>
</button>
@ -125,7 +179,12 @@
<span>{currentYear}</span>
</div>
<button type="button" class="nav-icon" aria-label="Next month" on:click={() => changeMonth(1)}>
<button
type="button"
class="nav-icon"
aria-label="Next month"
on:click={() => changeMonth(1)}
>
<span class="material-symbols-outlined">chevron_right</span>
</button>
</header>

View File

@ -1,3 +1,4 @@
<!-- @format -->
<script lang="ts">
import FragmentEditor from "$lib/components/editor/FragmentEditor.svelte";
import ListEditor from "$lib/components/editor/ListEditor.svelte";
@ -23,7 +24,11 @@
export let onDeleteDocument: (id: string) => void = () => {};
export let showLinkedBackButton = false;
export let onLinkedBack: () => void = () => {};
export let calendarItems: Array<{ id: string; label: string; initialContent: string }> = [];
export let calendarItems: Array<{
id: string;
label: string;
initialContent: string;
}> = [];
export let calendarBusy = false;
export let calendarError = "";
export let previewOnly = true;
@ -69,16 +74,25 @@
return label?.trim() || "Untitled Entry";
}
function toCalendarCard(item: { id: string; label: string; initialContent: string }): CalendarCard {
function toCalendarCard(item: {
id: string;
label: string;
initialContent: string;
}): CalendarCard {
const content = item.initialContent ?? "";
const lower = content.toLowerCase();
return {
...item,
title: deriveTitle(item.label, content),
summary: deriveSummary(content),
hasTrigger: lower.includes("!trigger") || lower.includes("#trigger") || lower.includes("#stress"),
hasMood: lower.includes("mental / emotional snapshot") || lower.includes("cognitive state"),
hasOpenTodos: /-\s*\[\s\]/.test(content)
hasTrigger:
lower.includes("!trigger") ||
lower.includes("#trigger") ||
lower.includes("#stress"),
hasMood:
lower.includes("mental / emotional snapshot") ||
lower.includes("cognitive state"),
hasOpenTodos: /-\s*\[\s\]/.test(content),
};
}
@ -88,8 +102,15 @@
<main class="editor-panel" aria-label="Editor area">
{#if showLinkedBackButton}
<div class="editor-nav">
<button type="button" class="back-btn" on:click={onLinkedBack} aria-label="Back to source entry">
<span class="material-symbols-outlined" aria-hidden="true">arrow_back</span>
<button
type="button"
class="back-btn"
on:click={onLinkedBack}
aria-label="Back to source entry"
>
<span class="material-symbols-outlined" aria-hidden="true"
>arrow_back</span
>
</button>
</div>
{/if}
@ -109,7 +130,11 @@
<ul class="calendar-list">
{#each calendarCards as item}
<li class:is-active={item.id === openDocumentId}>
<button type="button" class="calendar-item-btn" on:click={() => onOpenDocument(item)}>
<button
type="button"
class="calendar-item-btn"
on:click={() => onOpenDocument(item)}
>
<div class="calendar-item-head">
<h3>{item.title}</h3>
<span class="calendar-date">{item.label}</span>
@ -117,8 +142,11 @@
<p class="calendar-summary">{item.summary}</p>
<div class="calendar-badges">
{#if item.hasMood}<span class="badge mood">Mood</span>{/if}
{#if item.hasTrigger}<span class="badge trigger">Trigger</span>{/if}
{#if item.hasOpenTodos}<span class="badge todo">Open To-Dos</span>{/if}
{#if item.hasTrigger}<span class="badge trigger">Trigger</span
>{/if}
{#if item.hasOpenTodos}<span class="badge todo"
>Open To-Dos</span
>{/if}
</div>
</button>
</li>

View File

@ -1,3 +1,4 @@
<!-- @format -->
<script lang="ts">
export let activeSection: string | null = "entries";
export let onSelect: (id: string) => void = () => {};
@ -13,7 +14,7 @@
{ id: "calendar", label: "Calendar", icon: "calendar_month" },
{ id: "fragments", label: "Fragments", icon: "auto_stories" },
{ id: "todos", label: "To-Do List", icon: "checklist" },
{ id: "lists", label: "Lists", icon: "lists" }
{ id: "lists", label: "Lists", icon: "lists" },
];
function selectItem(id: string) {
@ -64,7 +65,11 @@
align-items: center;
gap: 14px;
padding: 14px 10px;
background: linear-gradient(180deg, var(--surface-2) 0%, var(--bg-navbar) 100%);
background: linear-gradient(
180deg,
var(--surface-2) 0%,
var(--bg-navbar) 100%
);
border-right: 1px solid var(--border-soft);
}
@ -102,7 +107,10 @@
color: var(--text-dim);
border: 1px solid transparent;
cursor: pointer;
transition: background-color 0.14s ease, color 0.14s ease, border-color 0.14s ease;
transition:
background-color 0.14s ease,
color 0.14s ease,
border-color 0.14s ease;
}
.nav-button .material-symbols-outlined {
@ -129,7 +137,9 @@
background: var(--surface-1);
border: 1px solid var(--border-strong);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
transition: opacity 0.12s ease, transform 0.12s ease;
transition:
opacity 0.12s ease,
transform 0.12s ease;
}
.nav-button:hover,

View File

@ -1,23 +1,60 @@
<!-- @format -->
<script lang="ts">
import { listEntryTemplates, type EntryTemplateItemDto } from "$lib/backend/templates";
import {
listEntryTemplates,
type EntryTemplateItemDto,
} from "$lib/backend/templates";
import { listFragments, type FragmentDto } from "$lib/backend/fragments";
import { listLists, type ListDocumentDto } from "$lib/backend/lists";
import { listTodoLists, type TodoListDto } from "$lib/backend/todos";
import { sendCommand } from "$lib/backend/client";
import CalendarWidget from "$lib/components/CalendarWidget.svelte";
import { entriesBusyStore, entriesStore, searchEntriesAsItems } from "$lib/stores/entries";
import { createFragmentDraft, fragmentsStore, serializeFragment } from "$lib/stores/fragments";
import { createListDraft, createListFromLabel, listsStore } from "$lib/stores/lists";
import { createTodoListDraft, createTodoListFromLabel, serializeTodoList, todoListsStore, todosStore } from "$lib/stores/todos";
import {
entriesBusyStore,
entriesStore,
searchEntriesAsItems,
} from "$lib/stores/entries";
import {
createFragmentDraft,
fragmentsStore,
serializeFragment,
} from "$lib/stores/fragments";
import {
createListDraft,
createListFromLabel,
listsStore,
} from "$lib/stores/lists";
import {
createTodoListDraft,
createTodoListFromLabel,
serializeTodoList,
todoListsStore,
todosStore,
} from "$lib/stores/todos";
import { extractEntryTags } from "$lib/utils/metadata";
export let activeSection = "entries";
export let activeDocumentId = "";
export let templateRefreshToken = 0;
export let onOpenDocument: (doc: { id: string; label: string; initialContent: string }) => void = () => {};
export let onEditItem: (doc: { id: string; label: string; initialContent: string }) => void = () => {};
export let onDeleteItem: (doc: { id: string; label: string }) => void = () => {};
export let onCalendarStateChange: (state: { items: SidePanelItem[]; busy: boolean; error: string }) => void = () => {};
export let onOpenDocument: (doc: {
id: string;
label: string;
initialContent: string;
}) => void = () => {};
export let onEditItem: (doc: {
id: string;
label: string;
initialContent: string;
}) => void = () => {};
export let onDeleteItem: (doc: {
id: string;
label: string;
}) => void = () => {};
export let onCalendarStateChange: (state: {
items: SidePanelItem[];
busy: boolean;
error: string;
}) => void = () => {};
let showNewItemInput = false;
let newItemName = "";
@ -56,14 +93,18 @@
calendar: "Calendar",
fragments: "Fragments",
todos: "To-Do List",
lists: "Lists"
lists: "Lists",
};
const today = new Date();
let calendarYear = today.getFullYear();
let calendarMonth = today.getMonth();
let calendarMonthLabel = new Date(calendarYear, calendarMonth, 1).toLocaleString(undefined, {
month: "long"
let calendarMonthLabel = new Date(
calendarYear,
calendarMonth,
1,
).toLocaleString(undefined, {
month: "long",
});
let calendarViewMode: CalendarViewMode = "month";
let calendarSortMode: CalendarSortMode = "desc";
@ -86,14 +127,23 @@
let calendarDateExplicitlySelected = false;
const CALENDAR_SAVED_VIEWS_KEY = "journal.calendar.savedViews";
let selectedCalendarDate: { year: number; month: number; day: number; key: string } | null = {
let selectedCalendarDate: {
year: number;
month: number;
day: number;
key: string;
} | null = {
year: today.getFullYear(),
month: today.getMonth(),
day: today.getDate(),
key: `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`
key: `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`,
};
function handleVisibleMonthChange(payload: { year: number; month: number; label: string }) {
function handleVisibleMonthChange(payload: {
year: number;
month: number;
label: string;
}) {
calendarYear = payload.year;
calendarMonth = payload.month;
calendarMonthLabel = payload.label;
@ -111,7 +161,10 @@
}
function parseDateLabel(value: string): Date | null {
const token = value.trim().split(/\s*[|·]\s*/)[0].trim();
const token = value
.trim()
.split(/\s*[|·]\s*/)[0]
.trim();
if (!/^\d{4}-\d{2}-\d{2}$/.test(token)) return null;
const date = new Date(`${token}T00:00:00`);
return Number.isNaN(date.getTime()) ? null : date;
@ -127,7 +180,11 @@
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
}
function isWithinRange(date: Date, startDate: string, endDate: string): boolean {
function isWithinRange(
date: Date,
startDate: string,
endDate: string,
): boolean {
const start = parseDateLabel(startDate);
const end = parseDateLabel(endDate);
if (!start || !end) return true;
@ -149,7 +206,7 @@
const selected = selectedCalendarDate ?? {
year: calendarYear,
month: calendarMonth,
day: 1
day: 1,
};
const selectedDate = new Date(selected.year, selected.month, selected.day);
@ -173,8 +230,11 @@
}
async function getDataDirectory(): Promise<string> {
const config = await sendCommand<{ dataDirectory?: string; DataDirectory?: string }>({
action: "config.get"
const config = await sendCommand<{
dataDirectory?: string;
DataDirectory?: string;
}>({
action: "config.get",
});
return (config.dataDirectory ?? config.DataDirectory ?? "").trim();
}
@ -182,7 +242,8 @@
function toFragmentTimelineItem(fragment: FragmentDto): SidePanelItem {
const split = fragment.description.split(/\n{2,}/);
const title = (split[0] ?? "").trim() || "Untitled Fragment";
const body = split.slice(1).join("\n\n").trim() || "Add details for this fragment.";
const body =
split.slice(1).join("\n\n").trim() || "Add details for this fragment.";
const date = parseIsoDate(fragment.time) ?? new Date();
const dateKey = toIsoDate(date);
return {
@ -192,20 +253,23 @@
title,
type: fragment.type,
tags: fragment.tags ?? [],
body
body,
}),
sortDate: date.toISOString()
sortDate: date.toISOString(),
};
}
function toListTimelineItem(list: ListDocumentDto): SidePanelItem {
const created = parseIsoDate(list.createdAt) ?? parseIsoDate(list.updatedAt) ?? new Date();
const created =
parseIsoDate(list.createdAt) ??
parseIsoDate(list.updatedAt) ??
new Date();
const dateKey = toIsoDate(created);
return {
id: `lists/${list.id}`,
label: `${dateKey} | List | ${list.label}`,
initialContent: list.content || `# ${list.label}\n\n`,
sortDate: created.toISOString()
sortDate: created.toISOString(),
};
}
@ -215,13 +279,13 @@
const items = list.items.map((item, index) => ({
id: Date.now() + index,
text: item.text,
done: item.done
done: item.done,
}));
return {
id: `todos/${list.id}`,
label: `${dateKey} | To-Do | ${list.label}`,
initialContent: serializeTodoList(list.label, items),
sortDate: created.toISOString()
sortDate: created.toISOString(),
};
}
@ -235,9 +299,14 @@
return tokens.some((token) => lower.includes(token));
}
function matchesTags(itemTags: string[] | undefined, tagTokens: string[]): boolean {
function matchesTags(
itemTags: string[] | undefined,
tagTokens: string[],
): boolean {
if (tagTokens.length === 0) return true;
const normalized = (itemTags ?? []).map((tag) => tag.toLowerCase().trim()).filter(Boolean);
const normalized = (itemTags ?? [])
.map((tag) => tag.toLowerCase().trim())
.filter(Boolean);
return tagTokens.some((token) => normalized.includes(token));
}
@ -254,13 +323,13 @@
const { startDate, endDate } = getActiveDateRange();
const entryItems = await searchEntriesAsItems({
dataDirectory,
query: calendarQuery.trim() || undefined
query: calendarQuery.trim() || undefined,
});
const [fragmentDtos, listDtos, todoDtos] = await Promise.all([
listFragments(),
listLists(),
listTodoLists()
listTodoLists(),
]);
const query = calendarQuery.trim();
@ -274,32 +343,40 @@
if (!date || !isWithinRange(date, startDate, endDate)) return false;
if (!matchesTextQuery(fragment.description, query)) return false;
if (!matchesTags(fragment.tags, tagTokens)) return false;
if (hasTypeFilter && !matchesAnyToken(fragment.type ?? "", typeTokens)) return false;
if (
hasTypeFilter &&
!matchesAnyToken(fragment.type ?? "", typeTokens)
)
return false;
return true;
})
.map(toFragmentTimelineItem);
const listItems = listDtos
.map(toListTimelineItem)
.filter((item) => {
if (hasTypeFilter) return false;
if (tagTokens.length > 0) return false;
const date = item.sortDate ? parseIsoDate(item.sortDate) : null;
if (!date || !isWithinRange(date, startDate, endDate)) return false;
if (!matchesTextQuery(item.initialContent, query) && !matchesTextQuery(item.label, query)) return false;
return true;
});
const listItems = listDtos.map(toListTimelineItem).filter((item) => {
if (hasTypeFilter) return false;
if (tagTokens.length > 0) return false;
const date = item.sortDate ? parseIsoDate(item.sortDate) : null;
if (!date || !isWithinRange(date, startDate, endDate)) return false;
if (
!matchesTextQuery(item.initialContent, query) &&
!matchesTextQuery(item.label, query)
)
return false;
return true;
});
const todoItems = todoDtos
.map(toTodoTimelineItem)
.filter((item) => {
if (hasTypeFilter) return false;
if (tagTokens.length > 0) return false;
const date = item.sortDate ? parseIsoDate(item.sortDate) : null;
if (!date || !isWithinRange(date, startDate, endDate)) return false;
if (!matchesTextQuery(item.initialContent, query) && !matchesTextQuery(item.label, query)) return false;
return true;
});
const todoItems = todoDtos.map(toTodoTimelineItem).filter((item) => {
if (hasTypeFilter) return false;
if (tagTokens.length > 0) return false;
const date = item.sortDate ? parseIsoDate(item.sortDate) : null;
if (!date || !isWithinRange(date, startDate, endDate)) return false;
if (
!matchesTextQuery(item.initialContent, query) &&
!matchesTextQuery(item.label, query)
)
return false;
return true;
});
const entriesWithKind: SidePanelItem[] = entryItems
.map((item) => {
@ -309,29 +386,43 @@
id: item.id,
label: `${dateKey} | Entry | ${item.label}`,
initialContent: item.initialContent,
sortDate: date ? date.toISOString() : undefined
sortDate: date ? date.toISOString() : undefined,
};
})
.filter((item) => {
if (hasTypeFilter) return false;
if (!matchesTags(extractEntryTags(item.initialContent), tagTokens)) return false;
if (!matchesTextQuery(item.initialContent, query) && !matchesTextQuery(item.label, query)) return false;
if (!matchesTags(extractEntryTags(item.initialContent), tagTokens))
return false;
if (
!matchesTextQuery(item.initialContent, query) &&
!matchesTextQuery(item.label, query)
)
return false;
const date = item.sortDate ? parseIsoDate(item.sortDate) : null;
if (date && !isWithinRange(date, startDate, endDate)) return false;
return true;
});
const merged = [...entriesWithKind, ...fragmentItems, ...listItems, ...todoItems];
const merged = [
...entriesWithKind,
...fragmentItems,
...listItems,
...todoItems,
];
const sorted = merged.sort((a, b) => {
const aDate = a.sortDate ? parseIsoDate(a.sortDate)?.getTime() ?? 0 : parseDateLabel(a.label)?.getTime() ?? 0;
const bDate = b.sortDate ? parseIsoDate(b.sortDate)?.getTime() ?? 0 : parseDateLabel(b.label)?.getTime() ?? 0;
const aDate = a.sortDate
? (parseIsoDate(a.sortDate)?.getTime() ?? 0)
: (parseDateLabel(a.label)?.getTime() ?? 0);
const bDate = b.sortDate
? (parseIsoDate(b.sortDate)?.getTime() ?? 0)
: (parseDateLabel(b.label)?.getTime() ?? 0);
return calendarSortMode === "asc" ? aDate - bDate : bDate - aDate;
});
calendarTimelineItems = sorted;
calendarLastRefreshedAt = new Date().toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
second: "2-digit"
second: "2-digit",
});
} catch (error) {
calendarError = String(error);
@ -341,7 +432,9 @@
}
}
async function forceRefreshCalendar(options?: { allowWhileBusy?: boolean }): Promise<void> {
async function forceRefreshCalendar(options?: {
allowWhileBusy?: boolean;
}): Promise<void> {
if (activeSection !== "calendar") return;
if (calendarBusy && !options?.allowWhileBusy) return;
if (calendarTimelineDebounce) {
@ -369,7 +462,10 @@
function persistSavedViews() {
if (typeof window === "undefined") return;
window.localStorage.setItem(CALENDAR_SAVED_VIEWS_KEY, JSON.stringify(calendarSavedViews));
window.localStorage.setItem(
CALENDAR_SAVED_VIEWS_KEY,
JSON.stringify(calendarSavedViews),
);
}
function applySavedView(view: SavedCalendarView) {
@ -399,7 +495,7 @@
tags: calendarTags,
types: calendarTypes,
startDate: calendarStartDate,
endDate: calendarEndDate
endDate: calendarEndDate,
};
calendarSavedViews = [view, ...calendarSavedViews];
persistSavedViews();
@ -422,7 +518,7 @@
tags: "",
types: "",
startDate: "",
endDate: ""
endDate: "",
},
{
id: "builtin-trigger",
@ -433,13 +529,16 @@
tags: "stress, trigger",
types: "!TRIGGER",
startDate: "",
endDate: ""
endDate: "",
},
];
function openOrCreateDailyEntry(dateKey: string) {
const existing = $entriesStore.find((item) => item.label === dateKey && !item.id.startsWith("entries/template-draft-"));
const existing = $entriesStore.find(
(item) =>
item.label === dateKey &&
!item.id.startsWith("entries/template-draft-"),
);
if (existing) {
onOpenDocument(existing);
return;
@ -448,17 +547,27 @@
const draft: SidePanelItem = {
id: `entries/draft-${Date.now()}`,
label: dateKey,
initialContent: `# ${dateKey}\n\n`
initialContent: `# ${dateKey}\n\n`,
};
entriesStore.update((items) => [draft, ...items]);
onEditItem(draft);
}
function handleSelectedDateChange(payload: { year: number; month: number; day: number; key: string }) {
function handleSelectedDateChange(payload: {
year: number;
month: number;
day: number;
key: string;
}) {
selectedCalendarDate = payload;
}
function handleDateActivate(payload: { year: number; month: number; day: number; key: string }) {
function handleDateActivate(payload: {
year: number;
month: number;
day: number;
key: string;
}) {
selectedCalendarDate = payload;
if (activeSection !== "calendar") return;
calendarDateExplicitlySelected = true;
@ -478,7 +587,7 @@
return {
id: toTemplateStoreId(item.filePath),
label: toTemplateLabel(item.fileName),
initialContent: ""
initialContent: "",
};
}
@ -498,7 +607,11 @@
}
function handleAddItem() {
if (activeSection === "entries" || activeSection === "todos" || activeSection === "lists") {
if (
activeSection === "entries" ||
activeSection === "todos" ||
activeSection === "lists"
) {
if (activeSection === "entries") {
createTemplateMode = false;
}
@ -519,7 +632,7 @@
year: calendarYear,
month: calendarMonth,
day: 1,
key: toDateKey(calendarYear, calendarMonth, 1)
key: toDateKey(calendarYear, calendarMonth, 1),
};
openOrCreateDailyEntry(selected.key);
} else {
@ -553,7 +666,10 @@
newItemName = "";
const label = toDailyNoteLabel(new Date());
const existing = $entriesStore.find((item) => item.label === label && !item.id.startsWith("entries/template-draft-"));
const existing = $entriesStore.find(
(item) =>
item.label === label && !item.id.startsWith("entries/template-draft-"),
);
if (existing) {
onEditItem(existing);
return;
@ -581,8 +697,14 @@
? label
: `${label}_template`
: label;
const id = isTemplate ? `entries/template-draft-${Date.now()}` : `entries/draft-${Date.now()}`;
const item = { id, label: displayLabel, initialContent: `# ${displayLabel}\n\n` };
const id = isTemplate
? `entries/template-draft-${Date.now()}`
: `entries/draft-${Date.now()}`;
const item = {
id,
label: displayLabel,
initialContent: `# ${displayLabel}\n\n`,
};
entriesStore.update((items) => [item, ...items]);
onEditItem(item);
createTemplateMode = false;
@ -597,17 +719,20 @@
onOpenDocument({
id: meta.id,
label: meta.label,
initialContent: serializeTodoList(meta.label, todoItems)
initialContent: serializeTodoList(meta.label, todoItems),
});
} catch (error) {
const draft = createTodoListDraft();
draft.meta.label = label;
todoListsStore.update((lists) => [draft.meta, ...lists]);
todosStore.update((lists) => ({ ...lists, [draft.meta.id]: draft.items }));
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)
initialContent: serializeTodoList(draft.meta.label, draft.items),
});
}
} else if (activeSection === "lists") {
@ -643,28 +768,42 @@
$: todoDocuments = $todoListsStore.map(({ id, label }) => ({
id,
label,
initialContent: serializeTodoList(label, $todosStore[id] ?? [])
initialContent: serializeTodoList(label, $todosStore[id] ?? []),
}));
$: items = activeSection === "entries"
? $entriesStore
: activeSection === "todos"
? todoDocuments
: activeSection === "fragments"
? $fragmentsStore
: activeSection === "lists"
? $listsStore
: [];
$: items =
activeSection === "entries"
? $entriesStore
: activeSection === "todos"
? todoDocuments
: activeSection === "fragments"
? $fragmentsStore
: activeSection === "lists"
? $listsStore
: [];
$: isCalendarSection = activeSection === "calendar";
$: showItemActions = activeSection === "entries" || activeSection === "todos" || activeSection === "lists" || activeSection === "fragments";
$: entryItems = activeSection === "entries"
? $entriesStore.filter((item) => !item.id.startsWith("entries/template-draft-"))
: [];
$: templateDraftItems = activeSection === "entries"
? $entriesStore.filter((item) => item.id.startsWith("entries/template-draft-"))
: [];
$: showItemActions =
activeSection === "entries" ||
activeSection === "todos" ||
activeSection === "lists" ||
activeSection === "fragments";
$: entryItems =
activeSection === "entries"
? $entriesStore.filter(
(item) => !item.id.startsWith("entries/template-draft-"),
)
: [];
$: templateDraftItems =
activeSection === "entries"
? $entriesStore.filter((item) =>
item.id.startsWith("entries/template-draft-"),
)
: [];
$: allTemplateItems = [...templateDraftItems, ...templateItems];
$: if (activeSection === "entries" && (!wasEntriesSection || templateRefreshToken !== lastTemplateRefreshToken)) {
$: if (
activeSection === "entries" &&
(!wasEntriesSection || templateRefreshToken !== lastTemplateRefreshToken)
) {
wasEntriesSection = true;
lastTemplateRefreshToken = templateRefreshToken;
void refreshTemplates();
@ -691,8 +830,12 @@
calendarTypes,
calendarStartDate,
calendarEndDate,
entriesSig: $entriesStore.map((item) => `${item.id}:${item.label}`).join("|"),
fragmentsSig: $fragmentsStore.map((item) => `${item.id}:${item.label}`).join("|"),
entriesSig: $entriesStore
.map((item) => `${item.id}:${item.label}`)
.join("|"),
fragmentsSig: $fragmentsStore
.map((item) => `${item.id}:${item.label}`)
.join("|"),
listsSig: $listsStore.map((item) => `${item.id}:${item.label}`).join("|"),
todosSig: $todoListsStore
.map((item) => {
@ -701,9 +844,12 @@
.join("~");
return `${item.id}:${item.label}:${todos}`;
})
.join("|")
.join("|"),
});
$: if (activeSection === "calendar" && calendarTimelineRefreshKey !== lastCalendarTimelineKey) {
$: if (
activeSection === "calendar" &&
calendarTimelineRefreshKey !== lastCalendarTimelineKey
) {
lastCalendarTimelineKey = calendarTimelineRefreshKey;
if (calendarTimelineDebounce) {
clearTimeout(calendarTimelineDebounce);
@ -726,7 +872,7 @@
onCalendarStateChange({
items: calendarTimelineItems,
busy: calendarBusy,
error: calendarError
error: calendarError,
});
}
</script>
@ -736,19 +882,43 @@
<h2>{panelTitle}</h2>
<div class="panel-header-actions">
{#if activeSection === "calendar"}
<button type="button" class="panel-action" aria-label="Refresh calendar" title="Refresh calendar" on:click={handleRefreshClick}>
<span class="material-symbols-outlined">refresh</span>
</button>
<button
type="button"
class="panel-action"
aria-label="Refresh calendar"
title="Refresh calendar"
on:click={handleRefreshClick}
>
<span class="material-symbols-outlined">refresh</span>
</button>
{/if}
{#if activeSection === "entries"}
<button type="button" class="panel-action" aria-label="Add template" title="Add template" on:click={handleAddTemplate}>
<span class="material-symbols-outlined">palette</span>
</button>
<button type="button" class="panel-action" aria-label="Add daily note" title="Add daily note" on:click={handleAddDailyNote}>
<span class="material-symbols-outlined">calendar_month</span>
</button>
<button
type="button"
class="panel-action"
aria-label="Add template"
title="Add template"
on:click={handleAddTemplate}
>
<span class="material-symbols-outlined">palette</span>
</button>
<button
type="button"
class="panel-action"
aria-label="Add daily note"
title="Add daily note"
on:click={handleAddDailyNote}
>
<span class="material-symbols-outlined">calendar_month</span>
</button>
{/if}
<button type="button" class="panel-action" aria-label="Add item" title="Add item" on:click={handleAddItem}>
<button
type="button"
class="panel-action"
aria-label="Add item"
title="Add item"
on:click={handleAddItem}
>
<span class="material-symbols-outlined">add</span>
</button>
</div>
@ -799,12 +969,24 @@
</div>
<div class="calendar-control-row">
<input type="text" bind:value={calendarQuery} placeholder="Text query" />
<input type="text" bind:value={calendarTags} placeholder="Tags (comma)" />
<input
type="text"
bind:value={calendarQuery}
placeholder="Text query"
/>
<input
type="text"
bind:value={calendarTags}
placeholder="Tags (comma)"
/>
</div>
<div class="calendar-control-row">
<input type="text" bind:value={calendarTypes} placeholder="Fragment types (comma)" />
<input
type="text"
bind:value={calendarTypes}
placeholder="Fragment types (comma)"
/>
<span></span>
</div>
@ -812,9 +994,17 @@
<h3>Saved Views</h3>
<div class="saved-view-actions">
{#each builtInViews as view}
<button type="button" class="saved-view-btn" on:click={() => applySavedView(view)}>{view.name}</button>
<button
type="button"
class="saved-view-btn"
on:click={() => applySavedView(view)}>{view.name}</button
>
{/each}
<button type="button" class="saved-view-btn" on:click={() => (showSaveViewInput = true)}>Save Current</button>
<button
type="button"
class="saved-view-btn"
on:click={() => (showSaveViewInput = true)}>Save Current</button
>
</div>
{#if showSaveViewInput}
<div class="saved-view-input">
@ -837,8 +1027,17 @@
<ul class="saved-view-list">
{#each calendarSavedViews as view}
<li>
<button type="button" class="saved-view-btn" on:click={() => applySavedView(view)}>{view.name}</button>
<button type="button" class="saved-view-delete" on:click={() => deleteSavedView(view.id)} aria-label="Delete saved view">
<button
type="button"
class="saved-view-btn"
on:click={() => applySavedView(view)}>{view.name}</button
>
<button
type="button"
class="saved-view-delete"
on:click={() => deleteSavedView(view.id)}
aria-label="Delete saved view"
>
<span class="material-symbols-outlined">delete</span>
</button>
</li>
@ -856,12 +1055,17 @@
<div class="calendar-entries">
<h3>{calendarMonthLabel} {calendarYear} Timeline</h3>
<p class="section-copy">Last refreshed: {calendarLastRefreshedAt || "Not yet"}</p>
<p class="section-copy">
Last refreshed: {calendarLastRefreshedAt || "Not yet"}
</p>
</div>
{:else}
<div class="panel-search">
<span class="material-symbols-outlined">search</span>
<input type="text" placeholder={`Search ${panelTitle.toLowerCase()}...`} />
<input
type="text"
placeholder={`Search ${panelTitle.toLowerCase()}...`}
/>
</div>
{#if showNewItemInput}
@ -900,10 +1104,20 @@
{item.label}
</button>
<div class="item-actions">
<button type="button" class="item-action" on:click|stopPropagation={() => onEditItem(item)} aria-label="Edit">
<button
type="button"
class="item-action"
on:click|stopPropagation={() => onEditItem(item)}
aria-label="Edit"
>
<span class="material-symbols-outlined">edit</span>
</button>
<button type="button" class="item-action item-action-danger" on:click|stopPropagation={() => onDeleteItem(item)} aria-label="Delete">
<button
type="button"
class="item-action item-action-danger"
on:click|stopPropagation={() => onDeleteItem(item)}
aria-label="Delete"
>
<span class="material-symbols-outlined">delete</span>
</button>
</div>
@ -932,10 +1146,20 @@
{item.label}
</button>
<div class="item-actions">
<button type="button" class="item-action" on:click|stopPropagation={() => onEditItem(item)} aria-label="Edit">
<button
type="button"
class="item-action"
on:click|stopPropagation={() => onEditItem(item)}
aria-label="Edit"
>
<span class="material-symbols-outlined">edit</span>
</button>
<button type="button" class="item-action item-action-danger" on:click|stopPropagation={() => onDeleteItem(item)} aria-label="Delete">
<button
type="button"
class="item-action item-action-danger"
on:click|stopPropagation={() => onDeleteItem(item)}
aria-label="Delete"
>
<span class="material-symbols-outlined">delete</span>
</button>
</div>
@ -963,10 +1187,20 @@
</button>
{#if showItemActions}
<div class="item-actions">
<button type="button" class="item-action" on:click|stopPropagation={() => onEditItem(item)} aria-label="Edit">
<button
type="button"
class="item-action"
on:click|stopPropagation={() => onEditItem(item)}
aria-label="Edit"
>
<span class="material-symbols-outlined">edit</span>
</button>
<button type="button" class="item-action item-action-danger" on:click|stopPropagation={() => onDeleteItem(item)} aria-label="Delete">
<button
type="button"
class="item-action item-action-danger"
on:click|stopPropagation={() => onDeleteItem(item)}
aria-label="Delete"
>
<span class="material-symbols-outlined">delete</span>
</button>
</div>
@ -980,7 +1214,11 @@
<style>
.side-panel {
background: linear-gradient(180deg, var(--surface-2) 0%, var(--bg-panel) 100%);
background: linear-gradient(
180deg,
var(--surface-2) 0%,
var(--bg-panel) 100%
);
border-right: 1px solid var(--border-soft);
padding: 16px 14px;
display: flex;
@ -1312,5 +1550,25 @@
}
}
}
</style>
@media (max-width: 980px) {
.side-panel {
padding: 12px 10px;
}
.panel-list .item-actions {
display: flex;
}
.panel-list .item-action {
width: 28px;
height: 28px;
}
}
@media (max-width: 720px) {
.calendar-control-row {
grid-template-columns: 1fr;
}
}
</style>

View File

@ -1,3 +1,4 @@
<!-- @format -->
<script lang="ts">
import {
createFragmentFromParsed,
@ -7,7 +8,7 @@
parseFragmentContent,
serializeFragment,
updateFragmentFromParsed,
type FragmentItem
type FragmentItem,
} from "$lib/stores/fragments";
import { settingsFragmentTypes, settingsTags } from "$lib/stores/settings";
import { renderMarkdown } from "$lib/utils/markdown";
@ -17,7 +18,11 @@
export let openDocumentName = "";
export let openDocumentContent = "";
export let onDocumentContentChange: (content: string) => void = () => {};
export let onOpenDocument: (doc: { id: string; label: string; initialContent: string }) => void = () => {};
export let onOpenDocument: (doc: {
id: string;
label: string;
initialContent: string;
}) => void = () => {};
export let onDeleteDocument: (id: string) => void = () => {};
export let externalEditRequested = false;
@ -35,18 +40,30 @@
const customTypeValue = "__custom_type__";
const customTagValue = "__custom_tag__";
function buildFragmentContent(): { title: string; resolvedType: string; body: string; content: string; tags: string[] } | null {
function buildFragmentContent(): {
title: string;
resolvedType: string;
body: string;
content: string;
tags: string[];
} | null {
const title = fragmentTitle.trim();
if (!title) return null;
const resolvedType = fragmentType === customTypeValue ? customFragmentType.trim() : fragmentType;
const resolvedType =
fragmentType === customTypeValue
? customFragmentType.trim()
: fragmentType;
if (!resolvedType) return null;
const selectedTags = fragmentTag && fragmentTag !== customTagValue ? [fragmentTag] : [];
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) => {
const uniqueTagList = [
...new Set(tagList.map((tag) => tag.toLowerCase())),
].map((lower) => {
return tagList.find((tag) => tag.toLowerCase() === lower) ?? lower;
});
const body = fragmentBody.trim() || "Add details for this fragment.";
@ -54,7 +71,7 @@
title,
type: resolvedType,
tags: uniqueTagList,
body
body,
});
return { title, resolvedType, body, content, tags: uniqueTagList };
}
@ -73,7 +90,7 @@
title: payload.title,
type: payload.resolvedType,
tags: payload.tags,
body: payload.body
body: payload.body,
});
if (!updated) return;
@ -92,7 +109,7 @@
title: payload.title,
type: payload.resolvedType,
tags: payload.tags,
body: payload.body
body: payload.body,
});
onOpenDocument(item);
fragmentMode = "view";
@ -141,7 +158,10 @@
function loadFragmentFormFromDocument() {
const content = openDocumentContent ?? "";
const isDraftFragment = openDocumentId.startsWith("fragments/new-");
const parsed = parseFragmentContent(content, openDocumentName || "Untitled Fragment");
const parsed = parseFragmentContent(
content,
openDocumentName || "Untitled Fragment",
);
fragmentTitle = parsed.title;
const parsedType = parsed.type;
if (!parsedType) {
@ -175,22 +195,35 @@
fragmentMode = isDraftFragment ? "create" : "view";
}
$: fragmentTypeOptions = $settingsFragmentTypes.length ? $settingsFragmentTypes : ["General"];
$: fragmentTypeOptions = $settingsFragmentTypes.length
? $settingsFragmentTypes
: ["General"];
$: tagOptions = $settingsTags;
$: if (openDocumentId !== lastFragmentDocumentId) {
loadFragmentFormFromDocument();
lastFragmentDocumentId = openDocumentId;
}
$: if (!fragmentType || (!fragmentTypeOptions.includes(fragmentType) && fragmentType !== customTypeValue)) {
$: if (
!fragmentType ||
(!fragmentTypeOptions.includes(fragmentType) &&
fragmentType !== customTypeValue)
) {
fragmentType = fragmentTypeOptions[0];
}
$: if (!fragmentTag || (!tagOptions.includes(fragmentTag) && fragmentTag !== customTagValue)) {
$: if (
!fragmentTag ||
(!tagOptions.includes(fragmentTag) && fragmentTag !== customTagValue)
) {
fragmentTag = tagOptions[0] ?? customTagValue;
}
$: if (!externalEditRequested) {
suppressExternalEditRequest = false;
}
$: if (externalEditRequested && !suppressExternalEditRequest && fragmentMode === "view") {
$: if (
externalEditRequested &&
!suppressExternalEditRequest &&
fragmentMode === "view"
) {
fragmentMode = "edit";
}
</script>
@ -203,10 +236,17 @@
{:else}
<form
class="fragment-form"
on:submit|preventDefault={fragmentMode === "create" ? createNewFragment : saveFragmentEdits}
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" />
<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}
@ -222,7 +262,12 @@
aria-label="Custom fragment type"
/>
{:else}
<input type="text" value={fragmentType} disabled aria-label="Selected fragment type" />
<input
type="text"
value={fragmentType}
disabled
aria-label="Selected fragment type"
/>
{/if}
</div>
<div class="fragment-form-row">
@ -252,8 +297,16 @@
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>
<button type="submit" class="fragment-submit"
>{fragmentMode === "create"
? "Create Fragment"
: "Save Fragment"}</button
>
<button
type="button"
class="fragment-secondary"
on:click={cancelFragmentEdit}>Cancel</button
>
</div>
</form>
{/if}
@ -292,7 +345,9 @@
color: var(--text-primary);
font-size: 0.92rem;
line-height: 1.65;
font-family: "Segoe UI Variable", "Segoe UI", "Segoe UI Emoji", "Apple Color Emoji", "Noto Color Emoji", Inter, Roboto, Helvetica, Arial, sans-serif;
font-family:
"Segoe UI Variable", "Segoe UI", "Segoe UI Emoji", "Apple Color Emoji",
"Noto Color Emoji", Inter, Roboto, Helvetica, Arial, sans-serif;
}
.fragment-view :global(h1),
@ -334,11 +389,17 @@
width: 100%;
border: 1px solid var(--border-soft);
border-radius: 8px;
background-color: color-mix(in srgb, var(--surface-1) 88%, var(--bg-editor) 12%);
background-color: color-mix(
in srgb,
var(--surface-1) 88%,
var(--bg-editor) 12%
);
color: var(--text-primary);
padding: 10px 11px;
font-size: 0.88rem;
font-family: "Segoe UI Variable", "Segoe UI", "Segoe UI Emoji", "Apple Color Emoji", "Noto Color Emoji", Inter, Roboto, Helvetica, Arial, sans-serif;
font-family:
"Segoe UI Variable", "Segoe UI", "Segoe UI Emoji", "Apple Color Emoji",
"Noto Color Emoji", Inter, Roboto, Helvetica, Arial, sans-serif;
}
.fragment-form textarea {

View File

@ -1,3 +1,4 @@
<!-- @format -->
<script lang="ts">
export let openDocumentId = "";
export let openDocumentName = "";
@ -134,14 +135,30 @@
}}
/>
<div class="list-actions">
<button type="button" class="list-btn save" on:click={saveEditItem}>Save</button>
<button type="button" class="list-btn ghost" on:click={cancelEditItem}>Cancel</button>
<button
type="button"
class="list-btn save"
on:click={saveEditItem}>Save</button
>
<button
type="button"
class="list-btn ghost"
on:click={cancelEditItem}>Cancel</button
>
</div>
{:else}
<span class="list-text">{item.text}</span>
<div class="list-actions">
<button type="button" class="list-btn ghost" on:click={() => startEditItem(item.id)}>Edit</button>
<button type="button" class="list-btn danger" on:click={() => removeItem(item.id)}>Remove</button>
<button
type="button"
class="list-btn ghost"
on:click={() => startEditItem(item.id)}>Edit</button
>
<button
type="button"
class="list-btn danger"
on:click={() => removeItem(item.id)}>Remove</button
>
</div>
{/if}
</li>
@ -266,6 +283,14 @@
padding: 18px 16px;
}
.list-create {
flex-wrap: wrap;
}
.list-add-btn {
width: 100%;
}
.list-item {
grid-template-columns: minmax(0, 1fr);
row-gap: 8px;

View File

@ -1,10 +1,19 @@
<!-- @format -->
<script lang="ts">
import { listEntryTemplates, loadEntryTemplate, type EntryTemplateItemDto } from "$lib/backend/templates";
import {
listEntryTemplates,
loadEntryTemplate,
type EntryTemplateItemDto,
} from "$lib/backend/templates";
import MarkdownToolbar from "$lib/components/editor/MarkdownToolbar.svelte";
import { entriesStore } from "$lib/stores/entries";
import { fragmentsStore } from "$lib/stores/fragments";
import { listsStore } from "$lib/stores/lists";
import { serializeTodoList, todoListsStore, todosStore } from "$lib/stores/todos";
import {
serializeTodoList,
todoListsStore,
todosStore,
} from "$lib/stores/todos";
import { renderMarkdown, extractEditorTitle } from "$lib/utils/markdown";
import { onMount } from "svelte";
import { get } from "svelte/store";
@ -95,14 +104,20 @@
applyWrap("[", "](https://example.com)");
}
function lineMatchesListMode(line: string, mode: Exclude<ListMode, null>): boolean {
function lineMatchesListMode(
line: string,
mode: Exclude<ListMode, null>,
): boolean {
if (mode === "ul") {
return /^\s*[-*+]\s/.test(line);
}
return /^\s*\d+\.\s/.test(line);
}
function isMarkerOnlyLine(line: string, mode: Exclude<ListMode, null>): boolean {
function isMarkerOnlyLine(
line: string,
mode: Exclude<ListMode, null>,
): boolean {
if (mode === "ul") {
return /^\s*[-*+]\s$/.test(line);
}
@ -178,7 +193,10 @@
return value.replace(/]/g, "\\]");
}
function appendToAttachmentsSection(lineToAppend: string, attachmentId: string) {
function appendToAttachmentsSection(
lineToAppend: string,
attachmentId: string,
) {
const current = markdownText;
const normalized = current.replace(/\r\n/g, "\n");
if (normalized.includes(`(journal:${attachmentId})`)) {
@ -196,18 +214,25 @@
const headerStart = headerMatch.index;
const headerEnd = headerStart + headerMatch[0].length;
const bodyStart = normalized.indexOf("\n", headerEnd);
const sectionBodyStart = bodyStart === -1 ? normalized.length : bodyStart + 1;
const sectionBodyStart =
bodyStart === -1 ? normalized.length : bodyStart + 1;
const nextHeaderMatch = /^##\s+/m.exec(normalized.slice(sectionBodyStart));
const sectionEnd = nextHeaderMatch ? sectionBodyStart + nextHeaderMatch.index : normalized.length;
const sectionEnd = nextHeaderMatch
? sectionBodyStart + nextHeaderMatch.index
: normalized.length;
const sectionBody = normalized.slice(sectionBodyStart, sectionEnd);
const bodyPrefix = sectionBody.length > 0 && !sectionBody.endsWith("\n") ? "\n" : "";
const bodyPrefix =
sectionBody.length > 0 && !sectionBody.endsWith("\n") ? "\n" : "";
const insertion = `${bodyPrefix}${lineToAppend}\n`;
const next = `${normalized.slice(0, sectionEnd)}${insertion}${normalized.slice(sectionEnd)}`;
updateDraft(next);
}
function attachReference(kind: "Fragment" | "List" | "To-Do", option: AttachmentOption) {
function attachReference(
kind: "Fragment" | "List" | "To-Do",
option: AttachmentOption,
) {
const label = escapeMarkdownLinkText(option.label.trim() || `${kind} Item`);
const line = `- ${kind}: [${label}](journal:${option.id})`;
appendToAttachmentsSection(line, option.id);
@ -221,7 +246,10 @@
attachmentModalOpen = false;
}
function attachFromModal(kind: "Fragment" | "List" | "To-Do", option: AttachmentOption) {
function attachFromModal(
kind: "Fragment" | "List" | "To-Do",
option: AttachmentOption,
) {
attachReference(kind, option);
attachmentModalOpen = false;
}
@ -243,19 +271,29 @@
});
}
function resolveJournalLinkTarget(targetId: string): { id: string; label: string; initialContent: string } | null {
function resolveJournalLinkTarget(
targetId: string,
): { id: string; label: string; initialContent: string } | null {
if (!targetId) return null;
if (targetId.startsWith("fragments/")) {
const fragment = get(fragmentsStore).find((item) => item.id === targetId);
if (!fragment) return null;
return { id: fragment.id, label: fragment.label, initialContent: fragment.initialContent };
return {
id: fragment.id,
label: fragment.label,
initialContent: fragment.initialContent,
};
}
if (targetId.startsWith("lists/")) {
const list = get(listsStore).find((item) => item.id === targetId);
if (!list) return null;
return { id: list.id, label: list.label, initialContent: list.initialContent };
return {
id: list.id,
label: list.label,
initialContent: list.initialContent,
};
}
if (targetId.startsWith("todos/")) {
@ -265,14 +303,18 @@
return {
id: todoList.id,
label: todoList.label,
initialContent: serializeTodoList(todoList.label, todoItems)
initialContent: serializeTodoList(todoList.label, todoItems),
};
}
if (targetId.startsWith("entries/")) {
const entry = get(entriesStore).find((item) => item.id === targetId);
if (!entry) return null;
return { id: entry.id, label: entry.label, initialContent: entry.initialContent };
return {
id: entry.id,
label: entry.label,
initialContent: entry.initialContent,
};
}
return null;
@ -295,8 +337,8 @@
id: openDocumentId,
label: openDocumentName,
initialContent: openDocumentContent,
section: "entries"
}
section: "entries",
},
});
}
@ -307,7 +349,7 @@
return {
destroy() {
node.removeEventListener("click", onClick);
}
},
};
}
@ -427,7 +469,9 @@
lastOpenDocumentId = openDocumentId;
listMode = null;
}
$: isEntryDocument = openDocumentId.startsWith("entries/file/") || openDocumentId.startsWith("entries/draft-");
$: isEntryDocument =
openDocumentId.startsWith("entries/file/") ||
openDocumentId.startsWith("entries/draft-");
$: fragmentAttachmentOptions = $fragmentsStore
.filter((item) => item.id && item.label)
.map((item) => ({ id: item.id, label: item.label }));
@ -452,7 +496,10 @@
{templatesBusy}
{templateOptions}
{listMode}
attachmentsDisabled={fragmentAttachmentOptions.length + listAttachmentOptions.length + todoAttachmentOptions.length === 0}
attachmentsDisabled={fragmentAttachmentOptions.length +
listAttachmentOptions.length +
todoAttachmentOptions.length ===
0}
onApplyHeading={applyHeading}
onApplyTemplate={(filePath) => void applyTemplateByPath(filePath)}
onOpenAttachments={openAttachmentModal}
@ -477,16 +524,30 @@
{#if attachmentModalOpen}
<div class="attachment-modal-backdrop" role="presentation">
<div class="attachment-modal" role="dialog" aria-modal="true" aria-label="Attach item">
<div
class="attachment-modal"
role="dialog"
aria-modal="true"
aria-label="Attach item"
>
<header class="attachment-modal-header">
<h2>Attach Item</h2>
<button type="button" class="attachment-modal-close" on:click={closeAttachmentModal} aria-label="Close attach dialog">
<span class="material-symbols-outlined" aria-hidden="true">close</span>
<button
type="button"
class="attachment-modal-close"
on:click={closeAttachmentModal}
aria-label="Close attach dialog"
>
<span class="material-symbols-outlined" aria-hidden="true"
>close</span
>
</button>
</header>
{#if fragmentAttachmentOptions.length + listAttachmentOptions.length + todoAttachmentOptions.length === 0}
<p class="attachment-empty">No fragments, lists, or to-do lists are available to attach.</p>
<p class="attachment-empty">
No fragments, lists, or to-do lists are available to attach.
</p>
{:else}
{#if fragmentAttachmentOptions.length > 0}
<div class="attachment-group">
@ -496,7 +557,9 @@
aria-label="Attach fragment"
on:change={(event) => {
const target = event.currentTarget as HTMLSelectElement;
const selected = fragmentAttachmentOptions.find((option) => option.id === target.value);
const selected = fragmentAttachmentOptions.find(
(option) => option.id === target.value,
);
if (selected) attachFromModal("Fragment", selected);
target.value = "";
}}
@ -517,7 +580,9 @@
aria-label="Attach list"
on:change={(event) => {
const target = event.currentTarget as HTMLSelectElement;
const selected = listAttachmentOptions.find((option) => option.id === target.value);
const selected = listAttachmentOptions.find(
(option) => option.id === target.value,
);
if (selected) attachFromModal("List", selected);
target.value = "";
}}
@ -538,7 +603,9 @@
aria-label="Attach to-do list"
on:change={(event) => {
const target = event.currentTarget as HTMLSelectElement;
const selected = todoAttachmentOptions.find((option) => option.id === target.value);
const selected = todoAttachmentOptions.find(
(option) => option.id === target.value,
);
if (selected) attachFromModal("To-Do", selected);
target.value = "";
}}
@ -557,7 +624,11 @@
<div class="editor-workspace">
{#if previewOnly}
<article class="markdown-preview" aria-label="Markdown preview" use:interceptJournalLinks>
<article
class="markdown-preview"
aria-label="Markdown preview"
use:interceptJournalLinks
>
{@html renderedHtml}
</article>
{:else}
@ -679,7 +750,11 @@
width: 100%;
border: 1px solid var(--border-soft);
border-radius: 8px;
background-color: color-mix(in srgb, var(--surface-2) 90%, var(--bg-editor) 10%);
background-color: color-mix(
in srgb,
var(--surface-2) 90%,
var(--bg-editor) 10%
);
color: var(--text-primary);
padding: 8px 34px 8px 10px;
font-size: 0.82rem;
@ -720,7 +795,9 @@
font-size: 0.92rem;
line-height: 1.65;
overflow: visible;
font-family: "Segoe UI Variable", "Segoe UI", "Segoe UI Emoji", "Apple Color Emoji", "Noto Color Emoji", Inter, Roboto, Helvetica, Arial, sans-serif;
font-family:
"Segoe UI Variable", "Segoe UI", "Segoe UI Emoji", "Apple Color Emoji",
"Noto Color Emoji", Inter, Roboto, Helvetica, Arial, sans-serif;
}
.markdown-input {

View File

@ -1,3 +1,4 @@
<!-- @format -->
<script lang="ts">
import type { EntryTemplateItemDto } from "$lib/backend/templates";
type AttachmentOption = { id: string; label: string };
@ -61,7 +62,11 @@
<div class="editor-toolbar">
<div class="toolbar-group">
<div class="toolbar-select-wrap heading-wrap" bind:this={headingMenuEl} on:focusout={handleHeadingFocusOut}>
<div
class="toolbar-select-wrap heading-wrap"
bind:this={headingMenuEl}
on:focusout={handleHeadingFocusOut}
>
<span class="material-symbols-outlined" aria-hidden="true">title</span>
<button
type="button"
@ -72,22 +77,54 @@
aria-expanded={headingMenuOpen}
on:click={toggleHeadingMenu}
>
<span class="material-symbols-outlined" aria-hidden="true">expand_more</span>
<span class="material-symbols-outlined" aria-hidden="true"
>expand_more</span
>
</button>
{#if headingMenuOpen}
<div class="heading-menu" role="listbox" aria-label="Header size">
<button type="button" class="heading-option" on:click={() => selectHeading(1)}>H1</button>
<button type="button" class="heading-option" on:click={() => selectHeading(2)}>H2</button>
<button type="button" class="heading-option" on:click={() => selectHeading(3)}>H3</button>
<button type="button" class="heading-option" on:click={() => selectHeading(4)}>H4</button>
<button type="button" class="heading-option" on:click={() => selectHeading(5)}>H5</button>
<button type="button" class="heading-option" on:click={() => selectHeading(6)}>H6</button>
<button
type="button"
class="heading-option"
on:click={() => selectHeading(1)}>H1</button
>
<button
type="button"
class="heading-option"
on:click={() => selectHeading(2)}>H2</button
>
<button
type="button"
class="heading-option"
on:click={() => selectHeading(3)}>H3</button
>
<button
type="button"
class="heading-option"
on:click={() => selectHeading(4)}>H4</button
>
<button
type="button"
class="heading-option"
on:click={() => selectHeading(5)}>H5</button
>
<button
type="button"
class="heading-option"
on:click={() => selectHeading(6)}>H6</button
>
</div>
{/if}
</div>
{#if isEntryDocument}
<div class="toolbar-select-wrap template-wrap" bind:this={templateMenuEl} on:focusout={handleTemplateFocusOut}>
<span class="material-symbols-outlined" aria-hidden="true">description</span>
<div
class="toolbar-select-wrap template-wrap"
bind:this={templateMenuEl}
on:focusout={handleTemplateFocusOut}
>
<span class="material-symbols-outlined" aria-hidden="true"
>description</span
>
<button
type="button"
class="template-trigger"
@ -98,8 +135,12 @@
disabled={templatesBusy}
on:click={toggleTemplateMenu}
>
<span class="template-trigger-text">{templatesBusy ? "Loading..." : "Template"}</span>
<span class="material-symbols-outlined" aria-hidden="true">expand_more</span>
<span class="template-trigger-text"
>{templatesBusy ? "Loading..." : "Template"}</span
>
<span class="material-symbols-outlined" aria-hidden="true"
>expand_more</span
>
</button>
{#if templateMenuOpen}
<div class="template-menu" role="listbox" aria-label="Template">
@ -134,19 +175,55 @@
</div>
<div class="toolbar-divider" aria-hidden="true"></div>
<div class="toolbar-group">
<button type="button" class="toolbar-btn toolbar-icon-btn" on:click={onBold} aria-label="Bold" title="Bold">
<span class="material-symbols-outlined" aria-hidden="true">format_bold</span>
<button
type="button"
class="toolbar-btn toolbar-icon-btn"
on:click={onBold}
aria-label="Bold"
title="Bold"
>
<span class="material-symbols-outlined" aria-hidden="true"
>format_bold</span
>
</button>
<button type="button" class="toolbar-btn toolbar-icon-btn" on:click={onItalic} aria-label="Italic" title="Italic">
<span class="material-symbols-outlined" aria-hidden="true">format_italic</span>
<button
type="button"
class="toolbar-btn toolbar-icon-btn"
on:click={onItalic}
aria-label="Italic"
title="Italic"
>
<span class="material-symbols-outlined" aria-hidden="true"
>format_italic</span
>
</button>
<button type="button" class="toolbar-btn toolbar-icon-btn" on:click={onUnderline} aria-label="Underline" title="Underline">
<span class="material-symbols-outlined" aria-hidden="true">format_underlined</span>
<button
type="button"
class="toolbar-btn toolbar-icon-btn"
on:click={onUnderline}
aria-label="Underline"
title="Underline"
>
<span class="material-symbols-outlined" aria-hidden="true"
>format_underlined</span
>
</button>
<button type="button" class="toolbar-btn toolbar-icon-btn" on:click={onTag} aria-label="Tag" title="Tag [[...]]">
<button
type="button"
class="toolbar-btn toolbar-icon-btn"
on:click={onTag}
aria-label="Tag"
title="Tag [[...]]"
>
<span class="material-symbols-outlined" aria-hidden="true">sell</span>
</button>
<button type="button" class="toolbar-btn toolbar-icon-btn" on:click={onLink} aria-label="Link" title="Link">
<button
type="button"
class="toolbar-btn toolbar-icon-btn"
on:click={onLink}
aria-label="Link"
title="Link"
>
<span class="material-symbols-outlined" aria-hidden="true">link</span>
</button>
<button
@ -157,7 +234,9 @@
aria-label="Bulleted list"
title="Bulleted list"
>
<span class="material-symbols-outlined" aria-hidden="true">format_list_bulleted</span>
<span class="material-symbols-outlined" aria-hidden="true"
>format_list_bulleted</span
>
</button>
<button
type="button"
@ -167,9 +246,17 @@
aria-label="Numbered list"
title="Numbered list"
>
<span class="material-symbols-outlined" aria-hidden="true">format_list_numbered</span>
<span class="material-symbols-outlined" aria-hidden="true"
>format_list_numbered</span
>
</button>
<button type="button" class="toolbar-btn toolbar-icon-btn" on:click={onCode} aria-label="Inline code" title="Inline code">
<button
type="button"
class="toolbar-btn toolbar-icon-btn"
on:click={onCode}
aria-label="Inline code"
title="Inline code"
>
<span class="material-symbols-outlined" aria-hidden="true">code</span>
</button>
</div>
@ -245,7 +332,11 @@
letter-spacing: 0.01em;
font-weight: 600;
cursor: pointer;
transition: background-color 120ms ease, color 120ms ease, border-color 120ms ease, transform 120ms ease;
transition:
background-color 120ms ease,
color 120ms ease,
border-color 120ms ease,
transform 120ms ease;
}
.toolbar-icon-btn {

View File

@ -1,3 +1,4 @@
<!-- @format -->
<script lang="ts">
import {
addTodoItem,
@ -12,7 +13,7 @@
todosStore,
type TodoItem,
updateTodoItemText,
updateTodoItemTextBackend
updateTodoItemTextBackend,
} from "$lib/stores/todos";
import { get } from "svelte/store";
@ -45,7 +46,9 @@
if (!ok) {
todoItems = toggleTodoItem(todoItems, id);
} else {
todoItems = todoItems.map((t) => (t.id === id ? { ...t, done: !t.done } : t));
todoItems = todoItems.map((t) =>
t.id === id ? { ...t, done: !t.done } : t,
);
}
persistTodosForCurrentList();
}
@ -68,7 +71,9 @@
if (!ok) {
todoItems = updateTodoItemText(todoItems, id, text);
} else {
todoItems = todoItems.map((t) => (t.id === id ? { ...t, text: text.trim() } : t));
todoItems = todoItems.map((t) =>
t.id === id ? { ...t, text: text.trim() } : t,
);
}
persistTodosForCurrentList();
}
@ -138,7 +143,11 @@
{#each todoItems as todo}
<li class="todo-item">
<label class="todo-check">
<input type="checkbox" checked={todo.done} on:change={() => toggleTodoDone(todo.id)} />
<input
type="checkbox"
checked={todo.done}
on:change={() => toggleTodoDone(todo.id)}
/>
</label>
{#if editingTodoId === todo.id}
@ -152,14 +161,30 @@
}}
/>
<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>
<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>
<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>
@ -293,6 +318,14 @@
padding: 18px 16px;
}
.todo-create {
flex-wrap: wrap;
}
.todo-add-btn {
width: 100%;
}
.todo-item {
grid-template-columns: auto minmax(0, 1fr);
row-gap: 8px;

View File

@ -32,7 +32,10 @@ export function isTauriRuntime(): boolean {
return false;
}
return Object.prototype.hasOwnProperty.call(window as WindowWithTauri, "__TAURI_INTERNALS__");
return Object.prototype.hasOwnProperty.call(
window as WindowWithTauri,
"__TAURI_INTERNALS__",
);
}
function readUiSettingsFromLocalStorage(): UiSettingsPayload {
@ -49,8 +52,13 @@ function readUiSettingsFromLocalStorage(): UiSettingsPayload {
const parsed = JSON.parse(raw) as UiSettingsPayload;
return {
tags: Array.isArray(parsed.tags) ? parsed.tags : undefined,
fragmentTypes: Array.isArray(parsed.fragmentTypes) ? parsed.fragmentTypes : undefined,
defaultStartupView: typeof parsed.defaultStartupView === "string" ? parsed.defaultStartupView : undefined
fragmentTypes: Array.isArray(parsed.fragmentTypes)
? parsed.fragmentTypes
: undefined,
defaultStartupView:
typeof parsed.defaultStartupView === "string"
? parsed.defaultStartupView
: undefined,
};
} catch {
return {};
@ -64,21 +72,30 @@ function writeUiSettingsToLocalStorage(payload: UiSettingsPayload): void {
const safePayload: UiSettingsPayload = {
tags: Array.isArray(payload.tags) ? payload.tags : undefined,
fragmentTypes: Array.isArray(payload.fragmentTypes) ? payload.fragmentTypes : undefined,
defaultStartupView: typeof payload.defaultStartupView === "string" ? payload.defaultStartupView : undefined
fragmentTypes: Array.isArray(payload.fragmentTypes)
? payload.fragmentTypes
: undefined,
defaultStartupView:
typeof payload.defaultStartupView === "string"
? payload.defaultStartupView
: undefined,
};
window.localStorage.setItem(UI_SETTINGS_KEY, JSON.stringify(safePayload));
}
async function fetchJson<T>(path: string, init: RequestInit = {}, options: FetchJsonOptions = {}): Promise<T> {
async function fetchJson<T>(
path: string,
init: RequestInit = {},
options: FetchJsonOptions = {},
): Promise<T> {
const response = await fetch(`${normalizedApiBase()}${path}`, {
...init,
keepalive: options.keepalive === true,
headers: {
"Content-Type": "application/json",
...(init.headers ?? {})
}
...(init.headers ?? {}),
},
});
if (!response.ok) {
@ -93,7 +110,10 @@ async function fetchJson<T>(path: string, init: RequestInit = {}, options: Fetch
return (await response.json()) as T;
}
export async function invoke<T>(command: string, args?: InvokeArgs): Promise<T> {
export async function invoke<T>(
command: string,
args?: InvokeArgs,
): Promise<T> {
if (isTauriRuntime()) {
const tauriCore = await import("@tauri-apps/api/core");
return tauriCore.invoke<T>(command, args);
@ -112,9 +132,9 @@ export async function invoke<T>(command: string, args?: InvokeArgs): Promise<T>
"/command",
{
method: "POST",
body: JSON.stringify(envelope as BackendCommand)
body: JSON.stringify(envelope as BackendCommand),
},
{ keepalive }
{ keepalive },
);
}
case "get_sidecar_root":
@ -123,23 +143,32 @@ export async function invoke<T>(command: string, args?: InvokeArgs): Promise<T>
const path = typeof args?.path === "string" ? args.path : "";
return fetchJson<T>("/sidecar/root", {
method: "POST",
body: JSON.stringify({ path })
body: JSON.stringify({ path }),
});
}
case "get_ui_settings":
return readUiSettingsFromLocalStorage() as T;
case "set_ui_settings": {
const tags = Array.isArray(args?.tags) ? (args?.tags as string[]) : undefined;
const fragmentTypes =
Array.isArray(args?.fragmentTypes) ? (args?.fragmentTypes as string[]) :
Array.isArray(args?.fragment_types) ? (args?.fragment_types as string[]) :
undefined;
const tags = Array.isArray(args?.tags)
? (args?.tags as string[])
: undefined;
const fragmentTypes = Array.isArray(args?.fragmentTypes)
? (args?.fragmentTypes as string[])
: Array.isArray(args?.fragment_types)
? (args?.fragment_types as string[])
: undefined;
const defaultStartupView =
typeof args?.defaultStartupView === "string" ? args.defaultStartupView :
typeof args?.default_startup_view === "string" ? args.default_startup_view :
undefined;
typeof args?.defaultStartupView === "string"
? args.defaultStartupView
: typeof args?.default_startup_view === "string"
? args.default_startup_view
: undefined;
writeUiSettingsToLocalStorage({ tags, fragmentTypes, defaultStartupView });
writeUiSettingsToLocalStorage({
tags,
fragmentTypes,
defaultStartupView,
});
return undefined as T;
}
case "shutdown":

View File

@ -6,7 +6,7 @@ import {
saveEntry as saveEntryCommand,
searchEntries as searchEntriesCommand,
type EntryListItemDto,
type EntrySearchRequestDto
type EntrySearchRequestDto,
} from "$lib/backend/entries";
export type EntryItem = {
@ -59,17 +59,19 @@ function fromListDto(dto: EntryListItemDto): EntryItem {
id: toStoreId(dto.filePath),
label: toLabel(dto.fileName),
initialContent: "",
filePath: dto.filePath
filePath: dto.filePath,
};
}
function fromLoadResult(result: Awaited<ReturnType<typeof loadEntryCommand>>): EntryItem {
function fromLoadResult(
result: Awaited<ReturnType<typeof loadEntryCommand>>,
): EntryItem {
return {
id: toStoreId(result.filePath),
label: toLabel(result.fileName),
initialContent: result.entry.rawContent,
filePath: result.filePath,
date: result.entry.date
date: result.entry.date,
};
}
@ -82,7 +84,7 @@ export function createEntryDraft(): EntryItem {
return {
id,
label: "Untitled Entry",
initialContent: "# Untitled Entry\n\nStart writing..."
initialContent: "# Untitled Entry\n\nStart writing...",
};
}
@ -100,7 +102,9 @@ export async function hydrateEntries(dataDirectory?: string): Promise<void> {
}
}
export async function loadEntryByStoreId(storeId: string): Promise<EntryItem | null> {
export async function loadEntryByStoreId(
storeId: string,
): Promise<EntryItem | null> {
const filePath = toBackendPath(storeId);
if (!filePath) return null;
@ -115,12 +119,21 @@ export async function loadEntryByStoreId(storeId: string): Promise<EntryItem | n
}
}
export async function saveEntryFromStore(storeId: string, content: string, mode?: string): Promise<EntryItem | null> {
export async function saveEntryFromStore(
storeId: string,
content: string,
mode?: string,
): Promise<EntryItem | null> {
const trimmed = content?.trim();
if (!trimmed) return null;
const existingPath = toBackendPath(storeId);
let payload: { content: string; filePath?: string; mode?: string; fileName?: string };
let payload: {
content: string;
filePath?: string;
mode?: string;
fileName?: string;
};
if (existingPath) {
payload = { content: trimmed, filePath: existingPath, mode };
@ -133,7 +146,9 @@ export async function saveEntryFromStore(storeId: string, content: string, mode?
const loaded = await loadEntryCommand(saved.filePath);
const item = fromLoadResult(loaded);
entriesStore.update((items) => {
const filtered = existingPath ? items : items.filter((i) => i.id !== storeId);
const filtered = existingPath
? items
: items.filter((i) => i.id !== storeId);
return upsertById(filtered, item);
});
return item;
@ -143,7 +158,9 @@ export async function saveEntryFromStore(storeId: string, content: string, mode?
}
}
export async function searchEntriesAsItems(payload: EntrySearchRequestDto): Promise<EntryItem[]> {
export async function searchEntriesAsItems(
payload: EntrySearchRequestDto,
): Promise<EntryItem[]> {
const results = await searchEntriesCommand(payload);
const dataDirectory = payload.dataDirectory?.trim() ?? "";
const separator = dataDirectory.includes("\\") ? "\\" : "/";
@ -154,8 +171,10 @@ export async function searchEntriesAsItems(payload: EntrySearchRequestDto): Prom
: `entries/search/${encodeURIComponent(result.fileName)}`,
label: toLabel(result.fileName),
initialContent: result.entry.rawContent,
filePath: basePath ? `${basePath}${separator}${result.fileName}` : undefined,
date: result.entry.date
filePath: basePath
? `${basePath}${separator}${result.fileName}`
: undefined,
date: result.entry.date,
}));
return mapped;
}

View File

@ -4,7 +4,7 @@ import {
deleteFragment as deleteFragmentCommand,
listFragments,
updateFragment as updateFragmentCommand,
type FragmentDto
type FragmentDto,
} from "$lib/backend/fragments";
export type FragmentItem = {
@ -36,7 +36,10 @@ function toBackendId(id: string): string | null {
return backendId || null;
}
function splitDescription(description: string): { title: string; body: string } {
function splitDescription(description: string): {
title: string;
body: string;
} {
const normalized = description.trim();
if (!normalized) {
return { title: "Untitled Fragment", body: "" };
@ -67,8 +70,8 @@ function dtoToItem(dto: FragmentDto): FragmentItem {
title: parsed.title,
type: dto.type,
tags: dto.tags ?? [],
body: parsed.body
})
body: parsed.body,
}),
};
}
@ -95,12 +98,17 @@ export function createFragmentId(title: string): string {
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 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 {
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);
@ -119,7 +127,7 @@ export function parseFragmentContent(content: string, fallbackTitle = "Untitled
title: headingMatch?.[1]?.trim() || fallbackTitle,
type: typeMatch?.[1]?.trim() || "",
tags,
body: bodyMatch?.[1]?.trim() || ""
body: bodyMatch?.[1]?.trim() || "",
};
}
@ -128,31 +136,49 @@ export function createFragmentDraft(): FragmentItem {
return {
id,
label: "New Fragment",
initialContent: "# New Fragment\n\nType: \nTags: (none)\n\n"
initialContent: "# New Fragment\n\nType: \nTags: (none)\n\n",
};
}
export function createFragmentItem(title: string, content: string): FragmentItem {
export function createFragmentItem(
title: string,
content: string,
): FragmentItem {
return {
id: createFragmentId(title),
label: title.trim() || "Untitled Fragment",
initialContent: content
initialContent: content,
};
}
export function updateFragmentItem(items: FragmentItem[], id: string, title: string, content: string): FragmentItem[] {
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
? {
...item,
label: title.trim() || "Untitled Fragment",
initialContent: content,
}
: item,
);
}
export function prependFragmentItem(items: FragmentItem[], item: FragmentItem): FragmentItem[] {
export function prependFragmentItem(
items: FragmentItem[],
item: FragmentItem,
): FragmentItem[] {
return [item, ...items];
}
export function removeFragmentItem(items: FragmentItem[], id: string): FragmentItem[] {
export function removeFragmentItem(
items: FragmentItem[],
id: string,
): FragmentItem[] {
return items.filter((item) => item.id !== id);
}
@ -169,38 +195,45 @@ export async function hydrateFragments(): Promise<void> {
}
}
export async function createFragmentFromParsed(payload: ParsedFragment): Promise<FragmentItem> {
export async function createFragmentFromParsed(
payload: ParsedFragment,
): Promise<FragmentItem> {
const created = await createFragmentCommand({
type: payload.type.trim(),
description: composeDescription(payload.title, payload.body),
tags: payload.tags
tags: payload.tags,
});
const item = dtoToItem(created);
fragmentsStore.update((items) => prependFragmentItem(items, item));
return item;
}
export async function updateFragmentFromParsed(storeId: string, payload: ParsedFragment): Promise<FragmentItem | null> {
export async function updateFragmentFromParsed(
storeId: string,
payload: ParsedFragment,
): Promise<FragmentItem | null> {
const backendId = toBackendId(storeId);
if (!backendId) return null;
const ok = await updateFragmentCommand(backendId, {
type: payload.type.trim(),
description: composeDescription(payload.title, payload.body),
tags: payload.tags
tags: payload.tags,
});
if (!ok) return null;
const item: FragmentItem = {
id: storeId,
label: payload.title.trim() || "Untitled Fragment",
initialContent: serializeFragment(payload)
initialContent: serializeFragment(payload),
};
fragmentsStore.update((items) => upsertById(items, item));
return item;
}
export async function deleteFragmentByStoreId(storeId: string): Promise<boolean> {
export async function deleteFragmentByStoreId(
storeId: string,
): Promise<boolean> {
const backendId = toBackendId(storeId);
if (!backendId) return false;

View File

@ -4,7 +4,7 @@ import {
deleteList as deleteListCommand,
listLists,
updateList as updateListCommand,
type ListDocumentDto
type ListDocumentDto,
} from "$lib/backend/lists";
export type ListItem = {
@ -31,7 +31,7 @@ function dtoToItem(dto: ListDocumentDto): ListItem {
return {
id: toStoreId(dto.id),
label: dto.label,
initialContent: dto.content || `# ${dto.label}\n\n`
initialContent: dto.content || `# ${dto.label}\n\n`,
};
}
@ -48,7 +48,7 @@ export function createListDraft(): ListItem {
return {
id,
label: "Untitled List",
initialContent: "# Untitled List\n\n- Item 1"
initialContent: "# Untitled List\n\n- Item 1",
};
}
@ -65,10 +65,16 @@ export async function hydrateLists(): Promise<void> {
}
}
export async function createListFromLabel(label: string, content = ""): Promise<ListItem> {
export async function createListFromLabel(
label: string,
content = "",
): Promise<ListItem> {
const resolvedLabel = label.trim() || "Untitled List";
const resolvedContent = content || `# ${resolvedLabel}\n\n`;
const created = await createListCommand({ label: resolvedLabel, content: resolvedContent });
const created = await createListCommand({
label: resolvedLabel,
content: resolvedContent,
});
const item = dtoToItem(created);
listsStore.update((items) => [item, ...items]);
return item;
@ -77,7 +83,7 @@ export async function createListFromLabel(label: string, content = ""): Promise<
export async function updateListByStoreId(
storeId: string,
label?: string,
content?: string
content?: string,
): Promise<boolean> {
const backendId = toBackendId(storeId);
if (!backendId) return false;
@ -95,10 +101,10 @@ export async function updateListByStoreId(
? {
...item,
label: label ?? item.label,
initialContent: content ?? item.initialContent
initialContent: content ?? item.initialContent,
}
: item
)
: item,
),
);
return true;
}

View File

@ -4,13 +4,22 @@ import { invoke } from "$lib/runtime/invoke";
const defaultTags = ["Personal", "Work", "Ideas", "Journal"];
const defaultFragmentTypes = ["Quote", "Snippet", "Reference"];
const startupViews = ["entries", "calendar", "fragments", "todos", "lists"] as const;
const startupViews = [
"entries",
"calendar",
"fragments",
"todos",
"lists",
] as const;
const defaultStartupView = "entries";
export type StartupView = typeof startupViews[number];
export type StartupView = (typeof startupViews)[number];
export const settingsTags = writable<string[]>([...defaultTags]);
export const settingsFragmentTypes = writable<string[]>([...defaultFragmentTypes]);
export const settingsDefaultStartupView = writable<StartupView>(defaultStartupView);
export const settingsFragmentTypes = writable<string[]>([
...defaultFragmentTypes,
]);
export const settingsDefaultStartupView =
writable<StartupView>(defaultStartupView);
let hydrationComplete = false;
let hydrating = false;
@ -25,9 +34,15 @@ function normalize(value: string): string {
return value.trim().toLowerCase();
}
function hasDuplicate(values: string[], candidate: string, excludeIndex?: number): boolean {
function hasDuplicate(
values: string[],
candidate: string,
excludeIndex?: number,
): boolean {
const normalized = normalize(candidate);
return values.some((value, index) => index !== excludeIndex && normalize(value) === normalized);
return values.some(
(value, index) => index !== excludeIndex && normalize(value) === normalized,
);
}
function normalizeValues(values: string[], fallback: string[]): string[] {
@ -58,8 +73,12 @@ export async function hydrateUiSettings(): Promise<void> {
try {
const payload = await invoke<UiSettingsPayload>("get_ui_settings");
settingsTags.set(normalizeValues(payload.tags ?? [], defaultTags));
settingsFragmentTypes.set(normalizeValues(payload.fragmentTypes ?? [], defaultFragmentTypes));
settingsDefaultStartupView.set(normalizeStartupView(payload.defaultStartupView));
settingsFragmentTypes.set(
normalizeValues(payload.fragmentTypes ?? [], defaultFragmentTypes),
);
settingsDefaultStartupView.set(
normalizeStartupView(payload.defaultStartupView),
);
} catch (error) {
console.error("[settings] hydrate failed", error);
} finally {
@ -70,7 +89,10 @@ export async function hydrateUiSettings(): Promise<void> {
export async function persistUiSettings(): Promise<void> {
const tags = normalizeValues(get(settingsTags), defaultTags);
const fragmentTypes = normalizeValues(get(settingsFragmentTypes), defaultFragmentTypes);
const fragmentTypes = normalizeValues(
get(settingsFragmentTypes),
defaultFragmentTypes,
);
const startupView = normalizeStartupView(get(settingsDefaultStartupView));
settingsTags.set(tags);
settingsFragmentTypes.set(fragmentTypes);
@ -81,7 +103,7 @@ export async function persistUiSettings(): Promise<void> {
fragmentTypes,
fragment_types: fragmentTypes,
defaultStartupView: startupView,
default_startup_view: startupView
default_startup_view: startupView,
});
}
@ -137,7 +159,9 @@ export function updateFragmentType(index: number, value: string): boolean {
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)));
settingsFragmentTypes.set(
types.map((type, idx) => (idx === index ? next : type)),
);
queuePersist();
return true;
}
@ -157,4 +181,3 @@ export function setDefaultStartupView(value: string): boolean {
queuePersist();
return true;
}

View File

@ -7,12 +7,17 @@ import {
listTodoLists,
updateTodoItem as updateTodoItemCommand,
updateTodoList as updateTodoListCommand,
type TodoListDto
type TodoListDto,
} from "$lib/backend/todos";
// TodoItem keeps a numeric `id` for local array operations (used by EditorPanel)
// plus a `backendId` (guid string) for backend persistence.
export type TodoItem = { id: number; text: string; done: boolean; backendId?: string };
export type TodoItem = {
id: number;
text: string;
done: boolean;
backendId?: string;
};
export type TodoListMeta = { id: string; label: string; backendId?: string };
export const todoListsStore = writable<TodoListMeta[]>([]);
@ -42,7 +47,7 @@ function dtoToMeta(dto: TodoListDto): TodoListMeta {
return {
id: toStoreId(dto.id),
label: dto.label,
backendId: dto.id
backendId: dto.id,
};
}
@ -51,7 +56,7 @@ function dtoToItems(dto: TodoListDto): TodoItem[] {
id: createTodoId() + index,
text: item.text,
done: item.done,
backendId: item.id
backendId: item.id,
}));
}
@ -81,7 +86,7 @@ export async function hydrateTodos(): Promise<void> {
// ── List CRUD ────────────────────────────────────────────────────
export async function createTodoListFromLabel(
label: string
label: string,
): Promise<{ meta: TodoListMeta; items: TodoItem[] }> {
const resolvedLabel = label.trim() || "New List";
const created = await createTodoListCommand({ label: resolvedLabel });
@ -92,7 +97,9 @@ export async function createTodoListFromLabel(
return { meta, items: [] };
}
export async function deleteTodoListByStoreId(storeId: string): Promise<boolean> {
export async function deleteTodoListByStoreId(
storeId: string,
): Promise<boolean> {
const backendId = toBackendId(storeId);
if (!backendId) return false;
@ -111,7 +118,7 @@ export async function deleteTodoListByStoreId(storeId: string): Promise<boolean>
export async function addTodoItemBackend(
storeId: string,
text: string
text: string,
): Promise<TodoItem | null> {
const backendListId = toBackendId(storeId);
if (!backendListId || !text.trim()) return null;
@ -122,26 +129,26 @@ export async function addTodoItemBackend(
const created = await createTodoItemCommand({
listId: backendListId,
text: text.trim(),
sortOrder
sortOrder,
});
const item: TodoItem = {
id: createTodoId(),
text: created.text,
done: created.done,
backendId: created.id
backendId: created.id,
};
todosStore.update((lists) => ({
...lists,
[storeId]: [item, ...(lists[storeId] ?? [])]
[storeId]: [item, ...(lists[storeId] ?? [])],
}));
return item;
}
export async function toggleTodoItemBackend(
storeId: string,
localId: number
localId: number,
): Promise<boolean> {
const items = get(todosStore)[storeId];
const todo = items?.find((t) => t.id === localId);
@ -153,8 +160,8 @@ export async function toggleTodoItemBackend(
todosStore.update((lists) => ({
...lists,
[storeId]: (lists[storeId] ?? []).map((t) =>
t.id === localId ? { ...t, done: !t.done } : t
)
t.id === localId ? { ...t, done: !t.done } : t,
),
}));
return true;
}
@ -162,7 +169,7 @@ export async function toggleTodoItemBackend(
export async function updateTodoItemTextBackend(
storeId: string,
localId: number,
text: string
text: string,
): Promise<boolean> {
const items = get(todosStore)[storeId];
const todo = items?.find((t) => t.id === localId);
@ -174,15 +181,15 @@ export async function updateTodoItemTextBackend(
todosStore.update((lists) => ({
...lists,
[storeId]: (lists[storeId] ?? []).map((t) =>
t.id === localId ? { ...t, text: text.trim() } : t
)
t.id === localId ? { ...t, text: text.trim() } : t,
),
}));
return true;
}
export async function removeTodoItemBackend(
storeId: string,
localId: number
localId: number,
): Promise<boolean> {
const items = get(todosStore)[storeId];
const todo = items?.find((t) => t.id === localId);
@ -193,7 +200,7 @@ export async function removeTodoItemBackend(
todosStore.update((lists) => ({
...lists,
[storeId]: (lists[storeId] ?? []).filter((t) => t.id !== localId)
[storeId]: (lists[storeId] ?? []).filter((t) => t.id !== localId),
}));
return true;
}
@ -202,7 +209,9 @@ export async function removeTodoItemBackend(
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}`);
const lines = todos.map(
(todo) => `- [${todo.done ? "x" : " "}] ${todo.text}`,
);
return `${heading}\n\n${lines.join("\n")}`;
}
@ -215,7 +224,7 @@ export function parseTodoList(content: string): TodoItem[] {
parsed.push({
id: createTodoId(),
text: match[2].trim(),
done: match[1].toLowerCase() === "x"
done: match[1].toLowerCase() === "x",
});
}
return parsed;
@ -224,7 +233,7 @@ export function parseTodoList(content: string): TodoItem[] {
export function getOrCreateTodoList(
lists: Record<string, TodoItem[]>,
documentId: string,
fallbackContent: string
fallbackContent: string,
): { lists: Record<string, TodoItem[]>; todos: TodoItem[] } {
const existing = lists[documentId];
if (existing) {
@ -237,7 +246,7 @@ export function getOrCreateTodoList(
export function setTodoList(
lists: Record<string, TodoItem[]>,
documentId: string,
todos: TodoItem[]
todos: TodoItem[],
): Record<string, TodoItem[]> {
return { ...lists, [documentId]: todos };
}
@ -247,21 +256,32 @@ export function addTodoItem(todos: TodoItem[], text: string): TodoItem[] {
}
export function toggleTodoItem(todos: TodoItem[], id: number): TodoItem[] {
return todos.map((todo) => (todo.id === id ? { ...todo, done: !todo.done } : todo));
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 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[] } {
export function createTodoListDraft(): {
meta: TodoListMeta;
items: TodoItem[];
} {
const id = `todos/draft-${Date.now()}`;
return {
meta: { id, label: "New List" },
items: []
items: [],
};
}

View File

@ -15,11 +15,11 @@ export function parseInline(input: string): string {
value = value.replace(/\*([^*]+)\*/g, "<em>$1</em>");
value = value.replace(
/\[([^\]]+)\]\((journal:[^\s)]+)\)/g,
'<a href="$2">$1</a>'
'<a href="$2">$1</a>',
);
value = value.replace(
/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g,
'<a href="$2" target="_blank" rel="noreferrer">$1</a>'
'<a href="$2" target="_blank" rel="noreferrer">$1</a>',
);
return value;
}
@ -37,7 +37,9 @@ export function renderMarkdown(markdown: string): string {
if (trimmed.startsWith("```")) {
if (inCode) {
output.push(`<pre><code>${escapeHtml(codeLines.join("\n"))}</code></pre>`);
output.push(
`<pre><code>${escapeHtml(codeLines.join("\n"))}</code></pre>`,
);
codeLines = [];
inCode = false;
} else {
@ -69,7 +71,9 @@ export function renderMarkdown(markdown: string): string {
if (/^[-*+]\s+/.test(trimmed)) {
const items: string[] = [];
while (i < lines.length && /^[-*+]\s+/.test(lines[i].trim())) {
items.push(`<li>${parseInline(lines[i].trim().replace(/^[-*+]\s+/, ""))}</li>`);
items.push(
`<li>${parseInline(lines[i].trim().replace(/^[-*+]\s+/, ""))}</li>`,
);
i += 1;
}
output.push(`<ul>${items.join("")}</ul>`);
@ -79,7 +83,9 @@ export function renderMarkdown(markdown: string): string {
if (/^\d+\.\s+/.test(trimmed)) {
const items: string[] = [];
while (i < lines.length && /^\d+\.\s+/.test(lines[i].trim())) {
items.push(`<li>${parseInline(lines[i].trim().replace(/^\d+\.\s+/, ""))}</li>`);
items.push(
`<li>${parseInline(lines[i].trim().replace(/^\d+\.\s+/, ""))}</li>`,
);
i += 1;
}
output.push(`<ol>${items.join("")}</ol>`);
@ -87,7 +93,9 @@ export function renderMarkdown(markdown: string): string {
}
if (/^>\s+/.test(trimmed)) {
output.push(`<blockquote>${parseInline(trimmed.replace(/^>\s+/, ""))}</blockquote>`);
output.push(
`<blockquote>${parseInline(trimmed.replace(/^>\s+/, ""))}</blockquote>`,
);
i += 1;
continue;
}
@ -114,7 +122,8 @@ export function renderMarkdown(markdown: string): string {
}
export function extractEditorTitle(markdown: string, fallback: string): string {
const firstLine = markdown.replace(/\r\n/g, "\n").split("\n")[0]?.trim() ?? "";
const firstLine =
markdown.replace(/\r\n/g, "\n").split("\n")[0]?.trim() ?? "";
const headingMatch = firstLine.match(/^#\s+(.+)$/);
return headingMatch ? headingMatch[1] : fallback;
}

View File

@ -12,7 +12,10 @@ function normalizeTags(tags: string[]): string[] {
return result;
}
function splitFrontmatter(content: string): { frontmatter: string | null; body: string } {
function splitFrontmatter(content: string): {
frontmatter: string | null;
body: string;
} {
const normalized = (content ?? "").replace(/\r\n/g, "\n");
if (!normalized.startsWith("---\n")) {
return { frontmatter: null, body: normalized };
@ -38,14 +41,12 @@ function parseTagsValue(rawValue: string): string[] {
value
.slice(1, -1)
.split(",")
.map((token) => token.trim().replace(/^["']|["']$/g, ""))
.map((token) => token.trim().replace(/^["']|["']$/g, "")),
);
}
return normalizeTags(
value
.split(",")
.map((token) => token.trim().replace(/^["']|["']$/g, ""))
value.split(",").map((token) => token.trim().replace(/^["']|["']$/g, "")),
);
}
@ -59,7 +60,9 @@ export function parseTagsFromMarkdown(content: string): string[] {
const { frontmatter } = splitFrontmatter(content);
if (!frontmatter) return [];
const line = frontmatter.split("\n").find((entry) => /^\s*tags\s*:/i.test(entry));
const line = frontmatter
.split("\n")
.find((entry) => /^\s*tags\s*:/i.test(entry));
if (!line) return [];
const value = line.replace(/^\s*tags\s*:/i, "");
return parseTagsValue(value);
@ -72,7 +75,8 @@ export function stripFrontmatter(content: string): string {
export function setTagsInMarkdown(content: string, tags: string[]): string {
const normalizedBody = (content ?? "").replace(/\r\n/g, "\n");
const normalizedTags = normalizeTags(tags);
const tagsLine = normalizedTags.length > 0 ? `tags: ${formatTagsValue(normalizedTags)}` : "";
const tagsLine =
normalizedTags.length > 0 ? `tags: ${formatTagsValue(normalizedTags)}` : "";
const { frontmatter, body } = splitFrontmatter(normalizedBody);
if (!frontmatter) {
@ -108,7 +112,7 @@ export function extractBracketTags(content: string): string[] {
...raw
.split(",")
.map((token) => token.trim())
.filter(Boolean)
.filter(Boolean),
);
}
return normalizeTags(tokens);
@ -137,7 +141,7 @@ export function extractTagsFromTagsSection(content: string): string[] {
...cleaned
.split(",")
.map((token) => token.trim())
.filter(Boolean)
.filter(Boolean),
);
}
@ -148,6 +152,6 @@ export function extractEntryTags(content: string): string[] {
return normalizeTags([
...parseTagsFromMarkdown(content),
...extractBracketTags(content),
...extractTagsFromTagsSection(content)
...extractTagsFromTagsSection(content),
]);
}

View File

@ -1,6 +1,11 @@
<!-- @format -->
<script lang="ts">
import { onMount } from "svelte";
import { getSessionPassword, clearVaultSession, flushBeforeClose } from "$lib/stores/session";
import {
getSessionPassword,
clearVaultSession,
flushBeforeClose,
} from "$lib/stores/session";
import { persistAndClearVault } from "$lib/backend/auth";
import { hydrateUiSettings } from "$lib/stores/settings";
import { invoke, isTauriRuntime } from "$lib/runtime/invoke";

View File

@ -1,13 +1,40 @@
<!-- @format -->
<script lang="ts">
import { goto } from "$app/navigation";
import { unlockVaultWorkspace } from "$lib/backend/auth";
import AppModal from "$lib/components/AppModal.svelte";
import { deleteEntryTemplate, loadEntryTemplate, saveEntryTemplate } from "$lib/backend/templates";
import { deleteEntryByStoreId, entriesStore, getDefaultEntry, hydrateEntries, loadEntryByStoreId, saveEntryFromStore } from "$lib/stores/entries";
import { deleteFragmentByStoreId, hydrateFragments } from "$lib/stores/fragments";
import { deleteListByStoreId, hydrateLists, updateListByStoreId } from "$lib/stores/lists";
import { hydrateUiSettings, settingsDefaultStartupView, type StartupView } from "$lib/stores/settings";
import { isVaultReady, setFlushCallback, setVaultSession } from "$lib/stores/session";
import {
deleteEntryTemplate,
loadEntryTemplate,
saveEntryTemplate,
} from "$lib/backend/templates";
import {
deleteEntryByStoreId,
entriesStore,
getDefaultEntry,
hydrateEntries,
loadEntryByStoreId,
saveEntryFromStore,
} from "$lib/stores/entries";
import {
deleteFragmentByStoreId,
hydrateFragments,
} from "$lib/stores/fragments";
import {
deleteListByStoreId,
hydrateLists,
updateListByStoreId,
} from "$lib/stores/lists";
import {
hydrateUiSettings,
settingsDefaultStartupView,
type StartupView,
} from "$lib/stores/settings";
import {
isVaultReady,
setFlushCallback,
setVaultSession,
} from "$lib/stores/session";
import { deleteTodoListByStoreId, hydrateTodos } from "$lib/stores/todos";
import Navbar from "$lib/components/Navbar.svelte";
import SidePanel from "$lib/components/SidePanel.svelte";
@ -41,7 +68,10 @@
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..." };
: {
"entries/daily-notes":
"# Daily Notes\n\nStart writing today's entry...",
};
let modalOpen = false;
let modalTitle = "";
let modalMessage = "";
@ -49,7 +79,12 @@
let modalCancelText = "Cancel";
let modalShowCancel = false;
let modalTone: "default" | "danger" = "default";
let modalAction: "logout-confirm" | "logout-info" | "unlock-vault" | "delete-confirm" | null = null;
let modalAction:
| "logout-confirm"
| "logout-info"
| "unlock-vault"
| "delete-confirm"
| null = null;
let modalInputEnabled = false;
let modalInputType = "text";
let modalInputPlaceholder = "";
@ -63,7 +98,7 @@
let calendarPanelState: CalendarPanelState = {
items: [],
busy: false,
error: ""
error: "",
};
function resolveStartupSection(value: string): StartupView {
@ -130,7 +165,11 @@
}
function showModal(options: {
action: "logout-confirm" | "logout-info" | "unlock-vault" | "delete-confirm";
action:
| "logout-confirm"
| "logout-info"
| "unlock-vault"
| "delete-confirm";
title: string;
message: string;
confirmText?: string;
@ -175,7 +214,7 @@
action: "logout-info",
title: "Logout Requested",
message: "You have been logged out.",
confirmText: "Close"
confirmText: "Close",
});
return;
}
@ -226,7 +265,7 @@
inputType: "password",
inputPlaceholder: "Vault password",
inputAriaLabel: "Vault password",
inputValue: ""
inputValue: "",
});
});
}
@ -234,7 +273,10 @@
function isLockedError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
const normalized = message.toLowerCase();
return normalized.includes("database is locked") || normalized.includes("incorrect vault password");
return (
normalized.includes("database is locked") ||
normalized.includes("incorrect vault password")
);
}
async function bootstrapFragmentsWithUnlock(maxAttempts = 3) {
@ -284,7 +326,6 @@
attempts += 1;
}
}
} finally {
fragmentBootstrapInFlight = false;
}
@ -296,39 +337,62 @@
if (!content?.trim()) return;
try {
if (selectedSection === "entries" && activeDocumentId.startsWith("entries/template-draft-")) {
const draft = get(entriesStore).find((item) => item.id === activeDocumentId);
if (
selectedSection === "entries" &&
activeDocumentId.startsWith("entries/template-draft-")
) {
const draft = get(entriesStore).find(
(item) => item.id === activeDocumentId,
);
const draftLabel = (draft?.label ?? activeDocumentLabel ?? "").trim();
const templateName = draftLabel.replace(/_template$/i, "").trim() || draftLabel;
const templateName =
draftLabel.replace(/_template$/i, "").trim() || draftLabel;
await saveEntryTemplate({
name: templateName,
content
content,
});
templateRefreshToken += 1;
entriesStore.update((items) => items.filter((item) => item.id !== activeDocumentId));
entriesStore.update((items) =>
items.filter((item) => item.id !== activeDocumentId),
);
const { [activeDocumentId]: _, ...rest } = openDocuments;
openDocuments = rest;
activeDocumentId = "";
activeDocumentLabel = "";
editMode = false;
} else if (selectedSection === "entries" && activeDocumentId.startsWith("entries/template-file/")) {
} else if (
selectedSection === "entries" &&
activeDocumentId.startsWith("entries/template-file/")
) {
const filePath = toTemplatePath(activeDocumentId);
if (!filePath) return;
await saveEntryTemplate({
name: templateNameFromPath(filePath),
content,
filePath
filePath,
});
templateRefreshToken += 1;
} else if (selectedSection === "entries" && (activeDocumentId.startsWith("entries/file/") || activeDocumentId.startsWith("entries/draft-"))) {
const saved = await saveEntryFromStore(activeDocumentId, content, "Overwrite");
} else if (
selectedSection === "entries" &&
(activeDocumentId.startsWith("entries/file/") ||
activeDocumentId.startsWith("entries/draft-"))
) {
const saved = await saveEntryFromStore(
activeDocumentId,
content,
"Overwrite",
);
if (saved && saved.id !== activeDocumentId) {
const { [activeDocumentId]: _, ...rest } = openDocuments;
openDocuments = { ...rest, [saved.id]: saved.initialContent };
activeDocumentId = saved.id;
activeDocumentLabel = saved.label;
}
} else if (selectedSection === "lists" && activeDocumentId.startsWith("lists/") && !activeDocumentId.startsWith("lists/draft-")) {
} else if (
selectedSection === "lists" &&
activeDocumentId.startsWith("lists/") &&
!activeDocumentId.startsWith("lists/draft-")
) {
await updateListByStoreId(activeDocumentId, undefined, content);
}
} catch {
@ -355,7 +419,7 @@
confirmText: "Log Out",
cancelText: "Cancel",
showCancel: true,
tone: "danger"
tone: "danger",
});
return;
}
@ -393,7 +457,11 @@
}
let resolvedDoc = doc;
if (effectiveSection === "entries" && doc.id.startsWith("entries/file/") && !doc.initialContent) {
if (
effectiveSection === "entries" &&
doc.id.startsWith("entries/file/") &&
!doc.initialContent
) {
try {
const loaded = await loadEntryByStoreId(doc.id);
if (loaded) {
@ -402,7 +470,11 @@
} catch {
// entry content will use initialContent fallback
}
} else if (effectiveSection === "entries" && doc.id.startsWith("entries/template-file/") && !doc.initialContent) {
} else if (
effectiveSection === "entries" &&
doc.id.startsWith("entries/template-file/") &&
!doc.initialContent
) {
try {
const filePath = toTemplatePath(doc.id);
if (filePath) {
@ -410,7 +482,7 @@
resolvedDoc = {
id: doc.id,
label: loaded.fileName.replace(/\.template\.md$/i, ""),
initialContent: loaded.content
initialContent: loaded.content,
};
}
} catch {
@ -419,7 +491,10 @@
}
if (!(resolvedDoc.id in openDocuments)) {
openDocuments = { ...openDocuments, [resolvedDoc.id]: resolvedDoc.initialContent };
openDocuments = {
...openDocuments,
[resolvedDoc.id]: resolvedDoc.initialContent,
};
}
activeDocumentId = resolvedDoc.id;
activeDocumentLabel = resolvedDoc.label;
@ -433,7 +508,7 @@
await handleOpenDocument({
id: target.id,
label: target.label,
initialContent: target.initialContent
initialContent: target.initialContent,
});
}
@ -459,7 +534,9 @@
ok = await deleteEntryTemplate(templatePath);
if (ok) templateRefreshToken += 1;
} else if (id.startsWith("entries/template-draft-")) {
entriesStore.update((items) => items.filter((item) => item.id !== id));
entriesStore.update((items) =>
items.filter((item) => item.id !== id),
);
ok = true;
} else {
ok = await deleteEntryByStoreId(id);
@ -500,7 +577,7 @@
confirmText: "Delete",
cancelText: "Cancel",
showCancel: true,
tone: "danger"
tone: "danger",
});
}
@ -508,8 +585,12 @@
setFlushCallback(saveCurrentDocument);
void (async () => {
await hydrateUiSettings();
const startupSection = resolveStartupSection(get(settingsDefaultStartupView));
const sectionFromQuery = parseSectionQuery(new URLSearchParams(window.location.search).get("section"));
const startupSection = resolveStartupSection(
get(settingsDefaultStartupView),
);
const sectionFromQuery = parseSectionQuery(
new URLSearchParams(window.location.search).get("section"),
);
applyStartupSection(sectionFromQuery ?? startupSection);
await bootstrapFragmentsWithUnlock();
})();
@ -569,6 +650,7 @@
<style>
.app-shell {
height: 100vh;
height: 100dvh;
overflow: hidden;
}

View File

@ -1,3 +1,4 @@
<!-- @format -->
<script lang="ts">
import { goto } from "$app/navigation";
import AppModal from "$lib/components/AppModal.svelte";
@ -12,7 +13,7 @@
settingsFragmentTypes,
settingsTags,
updateFragmentType,
updateSettingsTag
updateSettingsTag,
} from "$lib/stores/settings";
import { invoke, isTauriRuntime } from "$lib/runtime/invoke";
import { onMount } from "svelte";
@ -40,8 +41,16 @@
let returnSection = "entries";
onMount(async () => {
const queryReturn = new URLSearchParams(window.location.search).get("return")?.trim().toLowerCase() ?? "";
if (["entries", "calendar", "fragments", "todos", "lists"].includes(queryReturn)) {
const queryReturn =
new URLSearchParams(window.location.search)
.get("return")
?.trim()
.toLowerCase() ?? "";
if (
["entries", "calendar", "fragments", "todos", "lists"].includes(
queryReturn,
)
) {
returnSection = queryReturn;
}
@ -57,7 +66,9 @@
async function saveSidecarRoot() {
sidecarRootError = "";
try {
const result: any = await invoke("set_sidecar_root", { path: sidecarRoot });
const result: any = await invoke("set_sidecar_root", {
path: sidecarRoot,
});
sidecarRoot = result.root;
sidecarRootIsCustom = result.isCustom;
} catch (e) {
@ -88,7 +99,7 @@
const picked = await open({
directory: true,
multiple: false,
title: "Select Sidecar Root Directory"
title: "Select Sidecar Root Directory",
});
if (typeof picked === "string" && picked.trim()) {
sidecarRoot = picked;
@ -131,7 +142,7 @@
action: "logout-info",
title: "Logout Requested",
message: "You have been logged out.",
confirmText: "Close"
confirmText: "Close",
});
return;
}
@ -148,7 +159,7 @@
confirmText: "Log Out",
cancelText: "Cancel",
showCancel: true,
tone: "danger"
tone: "danger",
});
return;
}
@ -213,7 +224,9 @@
function saveEditFragmentType() {
if (editingFragmentTypeIndex === null) return;
if (updateFragmentType(editingFragmentTypeIndex, editingFragmentTypeValue)) {
if (
updateFragmentType(editingFragmentTypeIndex, editingFragmentTypeValue)
) {
editingFragmentTypeIndex = null;
editingFragmentTypeValue = "";
}
@ -234,7 +247,6 @@
function updateDefaultStartupView(value: string) {
setDefaultStartupView(value);
}
</script>
<div class="app-shell panel-closed">
@ -246,156 +258,225 @@
<h1>Settings</h1>
<p>Configure app behavior and interface options.</p>
</div>
<button type="button" class="header-close-btn" on:click={closeSettings} aria-label="Close settings">
<button
type="button"
class="header-close-btn"
on:click={closeSettings}
aria-label="Close settings"
>
<span class="material-symbols-outlined" aria-hidden="true">close</span>
</button>
</header>
<div class="settings-grid">
<section class="route-card">
<div class="card-head">
<h2 class="card-title">
<span class="material-symbols-outlined" aria-hidden="true">rocket_launch</span>
Startup
</h2>
</div>
<label>
Default startup view
<select
value={$settingsDefaultStartupView}
on:change={(event) => updateDefaultStartupView((event.currentTarget as HTMLSelectElement).value)}
>
<option value="entries">Entries</option>
<option value="calendar">Calendar</option>
<option value="fragments">Fragments</option>
<option value="todos">To-Do List</option>
<option value="lists">Lists</option>
</select>
</label>
</section>
<section class="route-card">
<div class="card-head">
<h2 class="card-title">
<span class="material-symbols-outlined" aria-hidden="true"
>rocket_launch</span
>
Startup
</h2>
</div>
<label>
Default startup view
<select
value={$settingsDefaultStartupView}
on:change={(event) =>
updateDefaultStartupView(
(event.currentTarget as HTMLSelectElement).value,
)}
>
<option value="entries">Entries</option>
<option value="calendar">Calendar</option>
<option value="fragments">Fragments</option>
<option value="todos">To-Do List</option>
<option value="lists">Lists</option>
</select>
</label>
</section>
<section class="route-card">
<div class="card-head">
<h2 class="card-title">
<span class="material-symbols-outlined" aria-hidden="true">label</span>
Tags
</h2>
<p class="section-copy">Add and manage tags used for notes and entries.</p>
</div>
<section class="route-card list-card">
<div class="card-head">
<h2 class="card-title">
<span class="material-symbols-outlined" aria-hidden="true"
>label</span
>
Tags
</h2>
<p class="section-copy">
Add and manage tags used for notes and entries.
</p>
</div>
<div class="create-row">
<input
type="text"
placeholder="Add tag (example: Research)"
bind:value={newTag}
on:keydown={(event) => event.key === "Enter" && addTag()}
/>
<button type="button" class="secondary-btn" on:click={addTag}>Add</button>
</div>
<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>
<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">
<div class="card-head">
<h2 class="card-title">
<span class="material-symbols-outlined" aria-hidden="true">category</span>
Fragment Types
</h2>
<p class="section-copy">Configure custom fragment types for the Fragments section.</p>
</div>
<section class="route-card list-card">
<div class="card-head">
<h2 class="card-title">
<span class="material-symbols-outlined" aria-hidden="true"
>category</span
>
Fragment Types
</h2>
<p class="section-copy">
Configure custom fragment types for the Fragments section.
</p>
</div>
<div class="create-row">
<input
type="text"
placeholder="Add fragment type (example: Observation)"
bind:value={newFragmentType}
on:keydown={(event) => event.key === "Enter" && addFragmentTypeLocal()}
/>
<button type="button" class="secondary-btn" on:click={addFragmentTypeLocal}>Add</button>
</div>
<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>
<ul class="item-list">
{#each $settingsFragmentTypes as type, index}
<li class="item-row">
{#if editingFragmentTypeIndex === index}
<input
type="text"
bind:value={editingFragmentTypeValue}
on:keydown={(event) => {
if (event.key === "Enter") saveEditFragmentType();
if (event.key === "Escape") cancelEditFragmentType();
}}
/>
<div class="row-actions">
<button
type="button"
class="secondary-btn"
on:click={saveEditFragmentType}>Save</button
>
<button
type="button"
class="ghost-btn"
on:click={cancelEditFragmentType}>Cancel</button
>
</div>
{:else}
<span>{type}</span>
<div class="row-actions">
<button
type="button"
class="ghost-btn"
on:click={() => startEditFragmentType(index, type)}
>Edit</button
>
<button
type="button"
class="danger-btn"
on:click={() => removeFragmentTypeLocal(index)}
>Remove</button
>
</div>
{/if}
</li>
{/each}
</ul>
</section>
<section class="route-card">
<div class="card-head">
<h2 class="card-title">
<span class="material-symbols-outlined" aria-hidden="true">hub</span>
Sidecar
</h2>
<p class="section-copy">Root directory containing the Journal.Sidecar project.</p>
</div>
<section class="route-card">
<div class="card-head">
<h2 class="card-title">
<span class="material-symbols-outlined" aria-hidden="true">hub</span
>
Sidecar
</h2>
<p class="section-copy">
Root directory containing the Journal.Sidecar project.
</p>
</div>
<div class="create-row">
<input
type="text"
placeholder="Auto-detected from working directory"
bind:value={sidecarRoot}
on:keydown={(event) => event.key === "Enter" && saveSidecarRoot()}
/>
<button type="button" class="ghost-btn" on:click={browseSidecarRoot} disabled={sidecarBrowseBusy}>
{sidecarBrowseBusy ? "Browsing..." : "Browse"}
</button>
{#if sidecarRootIsCustom}
<button type="button" class="ghost-btn" on:click={resetSidecarRoot}>Reset</button>
<div class="create-row">
<input
type="text"
placeholder="Auto-detected from working directory"
bind:value={sidecarRoot}
on:keydown={(event) => event.key === "Enter" && saveSidecarRoot()}
/>
<button
type="button"
class="ghost-btn"
on:click={browseSidecarRoot}
disabled={sidecarBrowseBusy}
>
{sidecarBrowseBusy ? "Browsing..." : "Browse"}
</button>
{#if sidecarRootIsCustom}
<button type="button" class="ghost-btn" on:click={resetSidecarRoot}
>Reset</button
>
{/if}
</div>
{#if sidecarRootError}
<p class="error-text">{sidecarRootError}</p>
{/if}
</div>
{#if sidecarRootError}
<p class="error-text">{sidecarRootError}</p>
{/if}
</section>
</section>
</div>
</main>
</div>
@ -414,11 +495,14 @@
<style>
.route-view {
min-height: 100vh;
height: 100vh;
height: 100dvh;
min-height: 0;
padding: 20px;
display: flex;
flex-direction: column;
gap: 18px;
overflow: hidden;
background: var(--bg-editor);
color: var(--text-primary);
}
@ -459,7 +543,11 @@
display: grid;
place-items: center;
cursor: pointer;
transition: background-color 120ms ease, color 120ms ease, border-color 120ms ease, transform 120ms ease;
transition:
background-color 120ms ease,
color 120ms ease,
border-color 120ms ease,
transform 120ms ease;
}
.header-close-btn .material-symbols-outlined {
@ -474,8 +562,11 @@
}
.settings-grid {
flex: 1;
min-height: 0;
columns: 2;
column-gap: 14px;
overflow: hidden;
}
.route-card {
@ -487,12 +578,19 @@
display: flex;
flex-direction: column;
gap: 12px;
min-height: 0;
overflow: hidden;
box-shadow:
inset 0 1px 0 color-mix(in srgb, var(--zinc-300) 8%, transparent 92%),
0 8px 24px color-mix(in srgb, var(--bg-app) 32%, transparent 68%);
margin-bottom: 14px;
}
.list-card {
overflow: hidden;
max-height: clamp(280px, 46vh, 520px);
}
.card-head {
display: flex;
flex-direction: column;
@ -527,7 +625,11 @@
.route-card select {
border: 1px solid var(--border-soft);
border-radius: 8px;
background-color: color-mix(in srgb, var(--surface-2) 88%, var(--bg-editor) 12%);
background-color: color-mix(
in srgb,
var(--surface-2) 88%,
var(--bg-editor) 12%
);
color: var(--text-primary);
padding: 9px 34px 9px 10px;
font-size: 0.84rem;
@ -555,6 +657,10 @@
display: flex;
flex-direction: column;
gap: 7px;
flex: 1;
min-height: 0;
overflow: auto;
padding-right: 2px;
}
.item-row {
@ -598,7 +704,11 @@
letter-spacing: 0.01em;
cursor: pointer;
color: var(--text-primary);
transition: background-color 120ms ease, color 120ms ease, border-color 120ms ease, transform 120ms ease;
transition:
background-color 120ms ease,
color 120ms ease,
border-color 120ms ease,
transform 120ms ease;
}
.secondary-btn {
@ -633,6 +743,12 @@
@media (max-width: 1100px) {
.settings-grid {
columns: 1;
overflow: auto;
padding-right: 2px;
}
.list-card {
max-height: min(42dvh, 460px);
}
}
@ -660,16 +776,24 @@
flex: 1 1 auto;
}
.list-card {
max-height: min(48dvh, 360px);
}
.item-row {
align-items: flex-start;
flex-direction: column;
gap: 8px;
}
.item-row input,
.route-card input {
min-width: 0;
}
.row-actions {
width: 100%;
justify-content: flex-end;
}
}
</style>

View File

@ -1,5 +1,6 @@
:root {
font-family: "Segoe UI Variable", "Segoe UI", Inter, Roboto, Helvetica, Arial, sans-serif;
font-family:
"Segoe UI Variable", "Segoe UI", Inter, Roboto, Helvetica, Arial, sans-serif;
font-size: 15px;
line-height: 1.45;
font-weight: 400;
@ -51,7 +52,11 @@ body {
}
body {
background: radial-gradient(circle at 15% -10%, var(--zinc-800) 0%, var(--bg-app) 42%);
background: radial-gradient(
circle at 15% -10%,
var(--zinc-800) 0%,
var(--bg-app) 42%
);
color: var(--text-primary);
}
@ -84,6 +89,7 @@ select {
.app-shell {
min-height: 100vh;
min-height: 100dvh;
display: grid;
grid-template-columns: 72px 300px minmax(0, 1fr);
}