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

View File

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

View File

@ -9,7 +9,9 @@
"preview": "vite preview", "preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"tauri": "tauri" "tauri": "tauri",
"format": "prettier --write .",
"format:check": "prettier --check ."
}, },
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -22,6 +24,8 @@
"@sveltejs/kit": "^2.9.0", "@sveltejs/kit": "^2.9.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0", "@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tauri-apps/cli": "^2", "@tauri-apps/cli": "^2",
"prettier": "^3.8.1",
"prettier-plugin-svelte": "^3.5.0",
"svelte": "^5.0.0", "svelte": "^5.0.0",
"svelte-check": "^4.0.0", "svelte-check": "^4.0.0",
"typescript": "~5.6.2", "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", "identifier": "default",
"description": "Capability for the main window", "description": "Capability for the main window",
"windows": ["main"], "windows": ["main"],
"permissions": [ "permissions": ["core:default", "dialog:default", "opener:default"]
"core:default",
"dialog:default",
"opener:default"
]
} }

View File

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

View File

@ -3,12 +3,16 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" /> <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
<link rel="stylesheet" href="style.css"> 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" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Journal</title> <title>Journal</title>
%sveltekit.head% %sveltekit.head%
</head> </head>
<body data-sveltekit-preload-data="hover"> <body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div> <div style="display: contents">%sveltekit.body%</div>
</body> </body>

View File

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

View File

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

View File

@ -1,5 +1,9 @@
import { sendCommand } from "./client"; import { sendCommand } from "./client";
import { normalizeFragment, type FragmentDto, type FragmentDtoRaw } from "./fragments"; import {
normalizeFragment,
type FragmentDto,
type FragmentDtoRaw,
} from "./fragments";
import { pickCase } from "./normalize"; import { pickCase } from "./normalize";
export type ParsedSectionDto = { export type ParsedSectionDto = {
@ -107,80 +111,126 @@ function normalizeSection(raw: ParsedSectionDtoRaw): ParsedSectionDto {
return { return {
title: pickCase(raw, "title", "Title", ""), title: pickCase(raw, "title", "Title", ""),
content: pickCase(raw, "content", "Content", [] as string[]), 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 { function normalizeJournalEntry(
const fragments = pickCase(raw, "fragments", "Fragments", [] as FragmentDtoRaw[]); raw: JournalEntryDtoRaw | undefined,
const sections = pickCase(raw, "sections", "Sections", {} as Record<string, ParsedSectionDtoRaw>); ): JournalEntryDto {
const fragments = pickCase(
raw,
"fragments",
"Fragments",
[] as FragmentDtoRaw[],
);
const sections = pickCase(
raw,
"sections",
"Sections",
{} as Record<string, ParsedSectionDtoRaw>,
);
return { return {
date: pickCase(raw, "date", "Date", ""), date: pickCase(raw, "date", "Date", ""),
fragments: fragments.map(normalizeFragment), fragments: fragments.map(normalizeFragment),
rawContent: pickCase(raw, "rawContent", "RawContent", ""), rawContent: pickCase(raw, "rawContent", "RawContent", ""),
sections: Object.fromEntries( 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 { function normalizeEntryListItem(raw: EntryListItemDtoRaw): EntryListItemDto {
return { return {
fileName: pickCase(raw, "fileName", "FileName", ""), fileName: pickCase(raw, "fileName", "FileName", ""),
filePath: pickCase(raw, "filePath", "FilePath", "") filePath: pickCase(raw, "filePath", "FilePath", ""),
}; };
} }
function normalizeEntryLoadResult(raw: EntryLoadResultDtoRaw): EntryLoadResultDto { function normalizeEntryLoadResult(
const nestedEntry = pickCase(raw, "entry", "Entry", undefined as JournalEntryDtoRaw | undefined); raw: EntryLoadResultDtoRaw,
const entry = ): EntryLoadResultDto {
nestedEntry const nestedEntry = pickCase(
? normalizeJournalEntry(nestedEntry) raw,
: normalizeJournalEntry({ "entry",
date: pickCase(raw, "date", "Date", undefined as string | undefined), "Entry",
rawContent: pickCase(raw, "rawContent", "RawContent", undefined as string | undefined), undefined as JournalEntryDtoRaw | undefined,
fragments: [], );
sections: {} 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 { return {
fileName: pickCase(raw, "fileName", "FileName", ""), fileName: pickCase(raw, "fileName", "FileName", ""),
filePath: pickCase(raw, "filePath", "FilePath", ""), filePath: pickCase(raw, "filePath", "FilePath", ""),
entry entry,
}; };
} }
function normalizeEntrySearchResult(raw: EntrySearchResultDtoRaw): EntrySearchResultDto { function normalizeEntrySearchResult(
const nestedEntry = pickCase(raw, "entry", "Entry", undefined as JournalEntryDtoRaw | undefined); raw: EntrySearchResultDtoRaw,
const entry = ): EntrySearchResultDto {
nestedEntry const nestedEntry = pickCase(
? normalizeJournalEntry(nestedEntry) raw,
: normalizeJournalEntry({ "entry",
date: pickCase(raw, "date", "Date", undefined as string | undefined), "Entry",
rawContent: pickCase(raw, "rawContent", "RawContent", undefined as string | undefined), undefined as JournalEntryDtoRaw | undefined,
fragments: [], );
sections: {} 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 { return {
fileName: pickCase(raw, "fileName", "FileName", ""), 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[]>({ const data = await sendCommand<EntryListItemDtoRaw[]>({
action: "entries.list", 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> { export async function loadEntry(filePath: string): Promise<EntryLoadResultDto> {
const data = await sendCommand<EntryLoadResultDtoRaw>({ const data = await sendCommand<EntryLoadResultDtoRaw>({
action: "entries.load", action: "entries.load",
payload: { filePath } payload: { filePath },
}); });
return normalizeEntryLoadResult(data); return normalizeEntryLoadResult(data);
@ -194,26 +244,30 @@ export async function saveEntry(payload: {
}): Promise<EntrySaveResultDto> { }): Promise<EntrySaveResultDto> {
const data = await sendCommand<EntrySaveResultDtoRaw>({ const data = await sendCommand<EntrySaveResultDtoRaw>({
action: "entries.save", action: "entries.save",
payload payload,
}); });
return { return {
filePath: pickCase(data, "filePath", "FilePath", "") filePath: pickCase(data, "filePath", "FilePath", ""),
}; };
} }
export async function deleteEntry(filePath: string): Promise<boolean> { export async function deleteEntry(filePath: string): Promise<boolean> {
return sendCommand<boolean>({ return sendCommand<boolean>({
action: "entries.delete", 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[]>({ const data = await sendCommand<EntrySearchResultDtoRaw[]>({
action: "search.entries", 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", ""), type: pickCase(raw, "type", "Type", ""),
description: pickCase(raw, "description", "Description", ""), description: pickCase(raw, "description", "Description", ""),
time: pickCase(raw, "time", "Time", ""), 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[]> { export async function listFragments(): Promise<FragmentDto[]> {
const data = await sendCommand<FragmentDtoRaw[]>({ const data = await sendCommand<FragmentDtoRaw[]>({
action: "fragments.list" action: "fragments.list",
}); });
return data.map(normalizeFragment).filter((item) => Boolean(item.id)); 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> { export async function getFragment(id: string): Promise<FragmentDto | null> {
const data = await sendCommand<FragmentDtoRaw | null>({ const data = await sendCommand<FragmentDtoRaw | null>({
action: "fragments.get", action: "fragments.get",
id id,
}); });
if (!data) return null; if (!data) return null;
const normalized = normalizeFragment(data); const normalized = normalizeFragment(data);
return normalized.id ? normalized : null; 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>({ const data = await sendCommand<FragmentDtoRaw>({
action: "fragments.create", action: "fragments.create",
payload payload,
}); });
return normalizeFragment(data); return normalizeFragment(data);
} }
export function updateFragment(id: string, payload: UpdateFragmentPayload): Promise<boolean> { export function updateFragment(
id: string,
payload: UpdateFragmentPayload,
): Promise<boolean> {
return sendCommand<boolean>({ return sendCommand<boolean>({
action: "fragments.update", action: "fragments.update",
id, id,
payload payload,
}); });
} }
export function deleteFragment(id: string): Promise<boolean> { export function deleteFragment(id: string): Promise<boolean> {
return sendCommand<boolean>({ return sendCommand<boolean>({
action: "fragments.delete", action: "fragments.delete",
id id,
}); });
} }

View File

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

View File

@ -1,14 +1,16 @@
type UnknownObject = Record<string, unknown>; type UnknownObject = Record<string, unknown>;
function asObject(value: unknown): UnknownObject | undefined { 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>( export function pickCase<T>(
source: unknown, source: unknown,
camelKey: string, camelKey: string,
pascalKey: string, pascalKey: string,
fallback: T fallback: T,
): T { ): T {
const obj = asObject(source); const obj = asObject(source);
if (!obj) return fallback; if (!obj) return fallback;

View File

@ -37,32 +37,40 @@ type EntryTemplateSaveResultDtoRaw = {
FilePath?: string; FilePath?: string;
}; };
function normalizeTemplateItem(raw: EntryTemplateItemDtoRaw): EntryTemplateItemDto { function normalizeTemplateItem(
raw: EntryTemplateItemDtoRaw,
): EntryTemplateItemDto {
return { return {
fileName: pickCase(raw, "fileName", "FileName", ""), 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[]>({ const data = await sendCommand<EntryTemplateItemDtoRaw[]>({
action: "templates.list", 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>({ const data = await sendCommand<EntryTemplateLoadResultDtoRaw>({
action: "templates.load", action: "templates.load",
payload: { filePath } payload: { filePath },
}); });
return { return {
fileName: pickCase(data, "fileName", "FileName", ""), fileName: pickCase(data, "fileName", "FileName", ""),
filePath: pickCase(data, "filePath", "FilePath", ""), 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> { }): Promise<EntryTemplateSaveResultDto> {
const data = await sendCommand<EntryTemplateSaveResultDtoRaw>({ const data = await sendCommand<EntryTemplateSaveResultDtoRaw>({
action: "templates.save", action: "templates.save",
payload payload,
}); });
return { return {
filePath: pickCase(data, "filePath", "FilePath", "") filePath: pickCase(data, "filePath", "FilePath", ""),
}; };
} }
export async function deleteEntryTemplate(filePath: string): Promise<boolean> { export async function deleteEntryTemplate(filePath: string): Promise<boolean> {
return sendCommand<boolean>({ return sendCommand<boolean>({
action: "templates.delete", action: "templates.delete",
payload: { filePath } payload: { filePath },
}); });
} }

View File

@ -66,7 +66,7 @@ function normalizeItem(raw: TodoItemDtoRaw): TodoItemDto {
listId: pickCase(raw, "listId", "ListId", ""), listId: pickCase(raw, "listId", "ListId", ""),
text: pickCase(raw, "text", "Text", ""), text: pickCase(raw, "text", "Text", ""),
done: pickCase(raw, "done", "Done", false), 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", ""), id: pickCase(raw, "id", "Id", ""),
label: pickCase(raw, "label", "Label", ""), label: pickCase(raw, "label", "Label", ""),
createdAt: pickCase(raw, "createdAt", "CreatedAt", ""), createdAt: pickCase(raw, "createdAt", "CreatedAt", ""),
items: rawItems.map(normalizeItem) items: rawItems.map(normalizeItem),
}; };
} }
export async function listTodoLists(): Promise<TodoListDto[]> { export async function listTodoLists(): Promise<TodoListDto[]> {
const data = await sendCommand<TodoListDtoRaw[]>({ const data = await sendCommand<TodoListDtoRaw[]>({
action: "todos.list" action: "todos.list",
}); });
return data.map(normalizeList).filter((item) => Boolean(item.id)); 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> { export async function getTodoList(id: string): Promise<TodoListDto | null> {
const data = await sendCommand<TodoListDtoRaw | null>({ const data = await sendCommand<TodoListDtoRaw | null>({
action: "todos.get", action: "todos.get",
id id,
}); });
if (!data) return null; if (!data) return null;
const normalized = normalizeList(data); const normalized = normalizeList(data);
return normalized.id ? normalized : null; 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>({ const data = await sendCommand<TodoListDtoRaw>({
action: "todos.create", action: "todos.create",
payload payload,
}); });
return normalizeList(data); return normalizeList(data);
} }
export function updateTodoList(id: string, payload: UpdateTodoListPayload): Promise<boolean> { export function updateTodoList(
id: string,
payload: UpdateTodoListPayload,
): Promise<boolean> {
return sendCommand<boolean>({ return sendCommand<boolean>({
action: "todos.update", action: "todos.update",
id, id,
payload payload,
}); });
} }
export function deleteTodoList(id: string): Promise<boolean> { export function deleteTodoList(id: string): Promise<boolean> {
return sendCommand<boolean>({ return sendCommand<boolean>({
action: "todos.delete", 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>({ const data = await sendCommand<TodoItemDtoRaw>({
action: "todos.items.create", action: "todos.items.create",
payload payload,
}); });
return normalizeItem(data); return normalizeItem(data);
} }
export function updateTodoItem(id: string, payload: UpdateTodoItemPayload): Promise<boolean> { export function updateTodoItem(
id: string,
payload: UpdateTodoItemPayload,
): Promise<boolean> {
return sendCommand<boolean>({ return sendCommand<boolean>({
action: "todos.items.update", action: "todos.items.update",
id, id,
payload payload,
}); });
} }
export function deleteTodoItem(id: string): Promise<boolean> { export function deleteTodoItem(id: string): Promise<boolean> {
return sendCommand<boolean>({ return sendCommand<boolean>({
action: "todos.items.delete", 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 BackendOk<T> = { ok: true; data: T };
export type BackendErr = { ok: false; error: string }; export type BackendErr = { ok: false; error: string };
export type BackendResponse<T> = BackendOk<T> | BackendErr; export type BackendResponse<T> = BackendOk<T> | BackendErr;

View File

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

View File

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

View File

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

View File

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

View File

@ -1,23 +1,60 @@
<!-- @format -->
<script lang="ts"> <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 { listFragments, type FragmentDto } from "$lib/backend/fragments";
import { listLists, type ListDocumentDto } from "$lib/backend/lists"; import { listLists, type ListDocumentDto } from "$lib/backend/lists";
import { listTodoLists, type TodoListDto } from "$lib/backend/todos"; import { listTodoLists, type TodoListDto } from "$lib/backend/todos";
import { sendCommand } from "$lib/backend/client"; import { sendCommand } from "$lib/backend/client";
import CalendarWidget from "$lib/components/CalendarWidget.svelte"; import CalendarWidget from "$lib/components/CalendarWidget.svelte";
import { entriesBusyStore, entriesStore, searchEntriesAsItems } from "$lib/stores/entries"; import {
import { createFragmentDraft, fragmentsStore, serializeFragment } from "$lib/stores/fragments"; entriesBusyStore,
import { createListDraft, createListFromLabel, listsStore } from "$lib/stores/lists"; entriesStore,
import { createTodoListDraft, createTodoListFromLabel, serializeTodoList, todoListsStore, todosStore } from "$lib/stores/todos"; 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"; import { extractEntryTags } from "$lib/utils/metadata";
export let activeSection = "entries"; export let activeSection = "entries";
export let activeDocumentId = ""; export let activeDocumentId = "";
export let templateRefreshToken = 0; export let templateRefreshToken = 0;
export let onOpenDocument: (doc: { id: string; label: string; initialContent: string }) => void = () => {}; export let onOpenDocument: (doc: {
export let onEditItem: (doc: { id: string; label: string; initialContent: string }) => void = () => {}; id: string;
export let onDeleteItem: (doc: { id: string; label: string }) => void = () => {}; label: string;
export let onCalendarStateChange: (state: { items: SidePanelItem[]; busy: boolean; error: string }) => void = () => {}; 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 showNewItemInput = false;
let newItemName = ""; let newItemName = "";
@ -56,14 +93,18 @@
calendar: "Calendar", calendar: "Calendar",
fragments: "Fragments", fragments: "Fragments",
todos: "To-Do List", todos: "To-Do List",
lists: "Lists" lists: "Lists",
}; };
const today = new Date(); const today = new Date();
let calendarYear = today.getFullYear(); let calendarYear = today.getFullYear();
let calendarMonth = today.getMonth(); let calendarMonth = today.getMonth();
let calendarMonthLabel = new Date(calendarYear, calendarMonth, 1).toLocaleString(undefined, { let calendarMonthLabel = new Date(
month: "long" calendarYear,
calendarMonth,
1,
).toLocaleString(undefined, {
month: "long",
}); });
let calendarViewMode: CalendarViewMode = "month"; let calendarViewMode: CalendarViewMode = "month";
let calendarSortMode: CalendarSortMode = "desc"; let calendarSortMode: CalendarSortMode = "desc";
@ -86,14 +127,23 @@
let calendarDateExplicitlySelected = false; let calendarDateExplicitlySelected = false;
const CALENDAR_SAVED_VIEWS_KEY = "journal.calendar.savedViews"; 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(), year: today.getFullYear(),
month: today.getMonth(), month: today.getMonth(),
day: today.getDate(), 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; calendarYear = payload.year;
calendarMonth = payload.month; calendarMonth = payload.month;
calendarMonthLabel = payload.label; calendarMonthLabel = payload.label;
@ -111,7 +161,10 @@
} }
function parseDateLabel(value: string): Date | null { 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; if (!/^\d{4}-\d{2}-\d{2}$/.test(token)) return null;
const date = new Date(`${token}T00:00:00`); const date = new Date(`${token}T00:00:00`);
return Number.isNaN(date.getTime()) ? null : date; 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")}`; 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 start = parseDateLabel(startDate);
const end = parseDateLabel(endDate); const end = parseDateLabel(endDate);
if (!start || !end) return true; if (!start || !end) return true;
@ -149,7 +206,7 @@
const selected = selectedCalendarDate ?? { const selected = selectedCalendarDate ?? {
year: calendarYear, year: calendarYear,
month: calendarMonth, month: calendarMonth,
day: 1 day: 1,
}; };
const selectedDate = new Date(selected.year, selected.month, selected.day); const selectedDate = new Date(selected.year, selected.month, selected.day);
@ -173,8 +230,11 @@
} }
async function getDataDirectory(): Promise<string> { async function getDataDirectory(): Promise<string> {
const config = await sendCommand<{ dataDirectory?: string; DataDirectory?: string }>({ const config = await sendCommand<{
action: "config.get" dataDirectory?: string;
DataDirectory?: string;
}>({
action: "config.get",
}); });
return (config.dataDirectory ?? config.DataDirectory ?? "").trim(); return (config.dataDirectory ?? config.DataDirectory ?? "").trim();
} }
@ -182,7 +242,8 @@
function toFragmentTimelineItem(fragment: FragmentDto): SidePanelItem { function toFragmentTimelineItem(fragment: FragmentDto): SidePanelItem {
const split = fragment.description.split(/\n{2,}/); const split = fragment.description.split(/\n{2,}/);
const title = (split[0] ?? "").trim() || "Untitled Fragment"; 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 date = parseIsoDate(fragment.time) ?? new Date();
const dateKey = toIsoDate(date); const dateKey = toIsoDate(date);
return { return {
@ -192,20 +253,23 @@
title, title,
type: fragment.type, type: fragment.type,
tags: fragment.tags ?? [], tags: fragment.tags ?? [],
body body,
}), }),
sortDate: date.toISOString() sortDate: date.toISOString(),
}; };
} }
function toListTimelineItem(list: ListDocumentDto): SidePanelItem { 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); const dateKey = toIsoDate(created);
return { return {
id: `lists/${list.id}`, id: `lists/${list.id}`,
label: `${dateKey} | List | ${list.label}`, label: `${dateKey} | List | ${list.label}`,
initialContent: list.content || `# ${list.label}\n\n`, initialContent: list.content || `# ${list.label}\n\n`,
sortDate: created.toISOString() sortDate: created.toISOString(),
}; };
} }
@ -215,13 +279,13 @@
const items = list.items.map((item, index) => ({ const items = list.items.map((item, index) => ({
id: Date.now() + index, id: Date.now() + index,
text: item.text, text: item.text,
done: item.done done: item.done,
})); }));
return { return {
id: `todos/${list.id}`, id: `todos/${list.id}`,
label: `${dateKey} | To-Do | ${list.label}`, label: `${dateKey} | To-Do | ${list.label}`,
initialContent: serializeTodoList(list.label, items), initialContent: serializeTodoList(list.label, items),
sortDate: created.toISOString() sortDate: created.toISOString(),
}; };
} }
@ -235,9 +299,14 @@
return tokens.some((token) => lower.includes(token)); 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; 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)); return tagTokens.some((token) => normalized.includes(token));
} }
@ -254,13 +323,13 @@
const { startDate, endDate } = getActiveDateRange(); const { startDate, endDate } = getActiveDateRange();
const entryItems = await searchEntriesAsItems({ const entryItems = await searchEntriesAsItems({
dataDirectory, dataDirectory,
query: calendarQuery.trim() || undefined query: calendarQuery.trim() || undefined,
}); });
const [fragmentDtos, listDtos, todoDtos] = await Promise.all([ const [fragmentDtos, listDtos, todoDtos] = await Promise.all([
listFragments(), listFragments(),
listLists(), listLists(),
listTodoLists() listTodoLists(),
]); ]);
const query = calendarQuery.trim(); const query = calendarQuery.trim();
@ -274,32 +343,40 @@
if (!date || !isWithinRange(date, startDate, endDate)) return false; if (!date || !isWithinRange(date, startDate, endDate)) return false;
if (!matchesTextQuery(fragment.description, query)) return false; if (!matchesTextQuery(fragment.description, query)) return false;
if (!matchesTags(fragment.tags, tagTokens)) 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; return true;
}) })
.map(toFragmentTimelineItem); .map(toFragmentTimelineItem);
const listItems = listDtos const listItems = listDtos.map(toListTimelineItem).filter((item) => {
.map(toListTimelineItem) if (hasTypeFilter) return false;
.filter((item) => { if (tagTokens.length > 0) return false;
if (hasTypeFilter) return false; const date = item.sortDate ? parseIsoDate(item.sortDate) : null;
if (tagTokens.length > 0) return false; if (!date || !isWithinRange(date, startDate, endDate)) return false;
const date = item.sortDate ? parseIsoDate(item.sortDate) : null; if (
if (!date || !isWithinRange(date, startDate, endDate)) return false; !matchesTextQuery(item.initialContent, query) &&
if (!matchesTextQuery(item.initialContent, query) && !matchesTextQuery(item.label, query)) return false; !matchesTextQuery(item.label, query)
return true; )
}); return false;
return true;
});
const todoItems = todoDtos const todoItems = todoDtos.map(toTodoTimelineItem).filter((item) => {
.map(toTodoTimelineItem) if (hasTypeFilter) return false;
.filter((item) => { if (tagTokens.length > 0) return false;
if (hasTypeFilter) return false; const date = item.sortDate ? parseIsoDate(item.sortDate) : null;
if (tagTokens.length > 0) return false; if (!date || !isWithinRange(date, startDate, endDate)) return false;
const date = item.sortDate ? parseIsoDate(item.sortDate) : null; if (
if (!date || !isWithinRange(date, startDate, endDate)) return false; !matchesTextQuery(item.initialContent, query) &&
if (!matchesTextQuery(item.initialContent, query) && !matchesTextQuery(item.label, query)) return false; !matchesTextQuery(item.label, query)
return true; )
}); return false;
return true;
});
const entriesWithKind: SidePanelItem[] = entryItems const entriesWithKind: SidePanelItem[] = entryItems
.map((item) => { .map((item) => {
@ -309,29 +386,43 @@
id: item.id, id: item.id,
label: `${dateKey} | Entry | ${item.label}`, label: `${dateKey} | Entry | ${item.label}`,
initialContent: item.initialContent, initialContent: item.initialContent,
sortDate: date ? date.toISOString() : undefined sortDate: date ? date.toISOString() : undefined,
}; };
}) })
.filter((item) => { .filter((item) => {
if (hasTypeFilter) return false; if (hasTypeFilter) return false;
if (!matchesTags(extractEntryTags(item.initialContent), tagTokens)) return false; if (!matchesTags(extractEntryTags(item.initialContent), tagTokens))
if (!matchesTextQuery(item.initialContent, query) && !matchesTextQuery(item.label, query)) return false; return false;
if (
!matchesTextQuery(item.initialContent, query) &&
!matchesTextQuery(item.label, query)
)
return false;
const date = item.sortDate ? parseIsoDate(item.sortDate) : null; const date = item.sortDate ? parseIsoDate(item.sortDate) : null;
if (date && !isWithinRange(date, startDate, endDate)) return false; if (date && !isWithinRange(date, startDate, endDate)) return false;
return true; return true;
}); });
const merged = [...entriesWithKind, ...fragmentItems, ...listItems, ...todoItems]; const merged = [
...entriesWithKind,
...fragmentItems,
...listItems,
...todoItems,
];
const sorted = merged.sort((a, b) => { const sorted = merged.sort((a, b) => {
const aDate = a.sortDate ? parseIsoDate(a.sortDate)?.getTime() ?? 0 : parseDateLabel(a.label)?.getTime() ?? 0; const aDate = a.sortDate
const bDate = b.sortDate ? parseIsoDate(b.sortDate)?.getTime() ?? 0 : parseDateLabel(b.label)?.getTime() ?? 0; ? (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; return calendarSortMode === "asc" ? aDate - bDate : bDate - aDate;
}); });
calendarTimelineItems = sorted; calendarTimelineItems = sorted;
calendarLastRefreshedAt = new Date().toLocaleTimeString(undefined, { calendarLastRefreshedAt = new Date().toLocaleTimeString(undefined, {
hour: "2-digit", hour: "2-digit",
minute: "2-digit", minute: "2-digit",
second: "2-digit" second: "2-digit",
}); });
} catch (error) { } catch (error) {
calendarError = String(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 (activeSection !== "calendar") return;
if (calendarBusy && !options?.allowWhileBusy) return; if (calendarBusy && !options?.allowWhileBusy) return;
if (calendarTimelineDebounce) { if (calendarTimelineDebounce) {
@ -369,7 +462,10 @@
function persistSavedViews() { function persistSavedViews() {
if (typeof window === "undefined") return; 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) { function applySavedView(view: SavedCalendarView) {
@ -399,7 +495,7 @@
tags: calendarTags, tags: calendarTags,
types: calendarTypes, types: calendarTypes,
startDate: calendarStartDate, startDate: calendarStartDate,
endDate: calendarEndDate endDate: calendarEndDate,
}; };
calendarSavedViews = [view, ...calendarSavedViews]; calendarSavedViews = [view, ...calendarSavedViews];
persistSavedViews(); persistSavedViews();
@ -422,7 +518,7 @@
tags: "", tags: "",
types: "", types: "",
startDate: "", startDate: "",
endDate: "" endDate: "",
}, },
{ {
id: "builtin-trigger", id: "builtin-trigger",
@ -433,13 +529,16 @@
tags: "stress, trigger", tags: "stress, trigger",
types: "!TRIGGER", types: "!TRIGGER",
startDate: "", startDate: "",
endDate: "" endDate: "",
}, },
]; ];
function openOrCreateDailyEntry(dateKey: string) { 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) { if (existing) {
onOpenDocument(existing); onOpenDocument(existing);
return; return;
@ -448,17 +547,27 @@
const draft: SidePanelItem = { const draft: SidePanelItem = {
id: `entries/draft-${Date.now()}`, id: `entries/draft-${Date.now()}`,
label: dateKey, label: dateKey,
initialContent: `# ${dateKey}\n\n` initialContent: `# ${dateKey}\n\n`,
}; };
entriesStore.update((items) => [draft, ...items]); entriesStore.update((items) => [draft, ...items]);
onEditItem(draft); 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; 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; selectedCalendarDate = payload;
if (activeSection !== "calendar") return; if (activeSection !== "calendar") return;
calendarDateExplicitlySelected = true; calendarDateExplicitlySelected = true;
@ -478,7 +587,7 @@
return { return {
id: toTemplateStoreId(item.filePath), id: toTemplateStoreId(item.filePath),
label: toTemplateLabel(item.fileName), label: toTemplateLabel(item.fileName),
initialContent: "" initialContent: "",
}; };
} }
@ -498,7 +607,11 @@
} }
function handleAddItem() { function handleAddItem() {
if (activeSection === "entries" || activeSection === "todos" || activeSection === "lists") { if (
activeSection === "entries" ||
activeSection === "todos" ||
activeSection === "lists"
) {
if (activeSection === "entries") { if (activeSection === "entries") {
createTemplateMode = false; createTemplateMode = false;
} }
@ -519,7 +632,7 @@
year: calendarYear, year: calendarYear,
month: calendarMonth, month: calendarMonth,
day: 1, day: 1,
key: toDateKey(calendarYear, calendarMonth, 1) key: toDateKey(calendarYear, calendarMonth, 1),
}; };
openOrCreateDailyEntry(selected.key); openOrCreateDailyEntry(selected.key);
} else { } else {
@ -553,7 +666,10 @@
newItemName = ""; newItemName = "";
const label = toDailyNoteLabel(new Date()); 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) { if (existing) {
onEditItem(existing); onEditItem(existing);
return; return;
@ -581,8 +697,14 @@
? label ? label
: `${label}_template` : `${label}_template`
: label; : label;
const id = isTemplate ? `entries/template-draft-${Date.now()}` : `entries/draft-${Date.now()}`; const id = isTemplate
const item = { id, label: displayLabel, initialContent: `# ${displayLabel}\n\n` }; ? `entries/template-draft-${Date.now()}`
: `entries/draft-${Date.now()}`;
const item = {
id,
label: displayLabel,
initialContent: `# ${displayLabel}\n\n`,
};
entriesStore.update((items) => [item, ...items]); entriesStore.update((items) => [item, ...items]);
onEditItem(item); onEditItem(item);
createTemplateMode = false; createTemplateMode = false;
@ -597,17 +719,20 @@
onOpenDocument({ onOpenDocument({
id: meta.id, id: meta.id,
label: meta.label, label: meta.label,
initialContent: serializeTodoList(meta.label, todoItems) initialContent: serializeTodoList(meta.label, todoItems),
}); });
} catch (error) { } catch (error) {
const draft = createTodoListDraft(); const draft = createTodoListDraft();
draft.meta.label = label; draft.meta.label = label;
todoListsStore.update((lists) => [draft.meta, ...lists]); 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({ onOpenDocument({
id: draft.meta.id, id: draft.meta.id,
label: draft.meta.label, label: draft.meta.label,
initialContent: serializeTodoList(draft.meta.label, draft.items) initialContent: serializeTodoList(draft.meta.label, draft.items),
}); });
} }
} else if (activeSection === "lists") { } else if (activeSection === "lists") {
@ -643,28 +768,42 @@
$: todoDocuments = $todoListsStore.map(({ id, label }) => ({ $: todoDocuments = $todoListsStore.map(({ id, label }) => ({
id, id,
label, label,
initialContent: serializeTodoList(label, $todosStore[id] ?? []) initialContent: serializeTodoList(label, $todosStore[id] ?? []),
})); }));
$: items = activeSection === "entries" $: items =
? $entriesStore activeSection === "entries"
: activeSection === "todos" ? $entriesStore
? todoDocuments : activeSection === "todos"
: activeSection === "fragments" ? todoDocuments
? $fragmentsStore : activeSection === "fragments"
: activeSection === "lists" ? $fragmentsStore
? $listsStore : activeSection === "lists"
: []; ? $listsStore
: [];
$: isCalendarSection = activeSection === "calendar"; $: isCalendarSection = activeSection === "calendar";
$: showItemActions = activeSection === "entries" || activeSection === "todos" || activeSection === "lists" || activeSection === "fragments"; $: showItemActions =
$: entryItems = activeSection === "entries" activeSection === "entries" ||
? $entriesStore.filter((item) => !item.id.startsWith("entries/template-draft-")) activeSection === "todos" ||
: []; activeSection === "lists" ||
$: templateDraftItems = activeSection === "entries" activeSection === "fragments";
? $entriesStore.filter((item) => item.id.startsWith("entries/template-draft-")) $: 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]; $: allTemplateItems = [...templateDraftItems, ...templateItems];
$: if (activeSection === "entries" && (!wasEntriesSection || templateRefreshToken !== lastTemplateRefreshToken)) { $: if (
activeSection === "entries" &&
(!wasEntriesSection || templateRefreshToken !== lastTemplateRefreshToken)
) {
wasEntriesSection = true; wasEntriesSection = true;
lastTemplateRefreshToken = templateRefreshToken; lastTemplateRefreshToken = templateRefreshToken;
void refreshTemplates(); void refreshTemplates();
@ -691,8 +830,12 @@
calendarTypes, calendarTypes,
calendarStartDate, calendarStartDate,
calendarEndDate, calendarEndDate,
entriesSig: $entriesStore.map((item) => `${item.id}:${item.label}`).join("|"), entriesSig: $entriesStore
fragmentsSig: $fragmentsStore.map((item) => `${item.id}:${item.label}`).join("|"), .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("|"), listsSig: $listsStore.map((item) => `${item.id}:${item.label}`).join("|"),
todosSig: $todoListsStore todosSig: $todoListsStore
.map((item) => { .map((item) => {
@ -701,9 +844,12 @@
.join("~"); .join("~");
return `${item.id}:${item.label}:${todos}`; return `${item.id}:${item.label}:${todos}`;
}) })
.join("|") .join("|"),
}); });
$: if (activeSection === "calendar" && calendarTimelineRefreshKey !== lastCalendarTimelineKey) { $: if (
activeSection === "calendar" &&
calendarTimelineRefreshKey !== lastCalendarTimelineKey
) {
lastCalendarTimelineKey = calendarTimelineRefreshKey; lastCalendarTimelineKey = calendarTimelineRefreshKey;
if (calendarTimelineDebounce) { if (calendarTimelineDebounce) {
clearTimeout(calendarTimelineDebounce); clearTimeout(calendarTimelineDebounce);
@ -726,7 +872,7 @@
onCalendarStateChange({ onCalendarStateChange({
items: calendarTimelineItems, items: calendarTimelineItems,
busy: calendarBusy, busy: calendarBusy,
error: calendarError error: calendarError,
}); });
} }
</script> </script>
@ -736,19 +882,43 @@
<h2>{panelTitle}</h2> <h2>{panelTitle}</h2>
<div class="panel-header-actions"> <div class="panel-header-actions">
{#if activeSection === "calendar"} {#if activeSection === "calendar"}
<button type="button" class="panel-action" aria-label="Refresh calendar" title="Refresh calendar" on:click={handleRefreshClick}> <button
<span class="material-symbols-outlined">refresh</span> type="button"
</button> class="panel-action"
aria-label="Refresh calendar"
title="Refresh calendar"
on:click={handleRefreshClick}
>
<span class="material-symbols-outlined">refresh</span>
</button>
{/if} {/if}
{#if activeSection === "entries"} {#if activeSection === "entries"}
<button type="button" class="panel-action" aria-label="Add template" title="Add template" on:click={handleAddTemplate}> <button
<span class="material-symbols-outlined">palette</span> type="button"
</button> class="panel-action"
<button type="button" class="panel-action" aria-label="Add daily note" title="Add daily note" on:click={handleAddDailyNote}> aria-label="Add template"
<span class="material-symbols-outlined">calendar_month</span> title="Add template"
</button> 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} {/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> <span class="material-symbols-outlined">add</span>
</button> </button>
</div> </div>
@ -799,12 +969,24 @@
</div> </div>
<div class="calendar-control-row"> <div class="calendar-control-row">
<input type="text" bind:value={calendarQuery} placeholder="Text query" /> <input
<input type="text" bind:value={calendarTags} placeholder="Tags (comma)" /> type="text"
bind:value={calendarQuery}
placeholder="Text query"
/>
<input
type="text"
bind:value={calendarTags}
placeholder="Tags (comma)"
/>
</div> </div>
<div class="calendar-control-row"> <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> <span></span>
</div> </div>
@ -812,9 +994,17 @@
<h3>Saved Views</h3> <h3>Saved Views</h3>
<div class="saved-view-actions"> <div class="saved-view-actions">
{#each builtInViews as view} {#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} {/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> </div>
{#if showSaveViewInput} {#if showSaveViewInput}
<div class="saved-view-input"> <div class="saved-view-input">
@ -837,8 +1027,17 @@
<ul class="saved-view-list"> <ul class="saved-view-list">
{#each calendarSavedViews as view} {#each calendarSavedViews as view}
<li> <li>
<button type="button" class="saved-view-btn" on:click={() => applySavedView(view)}>{view.name}</button> <button
<button type="button" class="saved-view-delete" on:click={() => deleteSavedView(view.id)} aria-label="Delete saved view"> 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> <span class="material-symbols-outlined">delete</span>
</button> </button>
</li> </li>
@ -856,12 +1055,17 @@
<div class="calendar-entries"> <div class="calendar-entries">
<h3>{calendarMonthLabel} {calendarYear} Timeline</h3> <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> </div>
{:else} {:else}
<div class="panel-search"> <div class="panel-search">
<span class="material-symbols-outlined">search</span> <span class="material-symbols-outlined">search</span>
<input type="text" placeholder={`Search ${panelTitle.toLowerCase()}...`} /> <input
type="text"
placeholder={`Search ${panelTitle.toLowerCase()}...`}
/>
</div> </div>
{#if showNewItemInput} {#if showNewItemInput}
@ -900,10 +1104,20 @@
{item.label} {item.label}
</button> </button>
<div class="item-actions"> <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> <span class="material-symbols-outlined">edit</span>
</button> </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> <span class="material-symbols-outlined">delete</span>
</button> </button>
</div> </div>
@ -932,10 +1146,20 @@
{item.label} {item.label}
</button> </button>
<div class="item-actions"> <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> <span class="material-symbols-outlined">edit</span>
</button> </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> <span class="material-symbols-outlined">delete</span>
</button> </button>
</div> </div>
@ -963,10 +1187,20 @@
</button> </button>
{#if showItemActions} {#if showItemActions}
<div class="item-actions"> <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> <span class="material-symbols-outlined">edit</span>
</button> </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> <span class="material-symbols-outlined">delete</span>
</button> </button>
</div> </div>
@ -980,7 +1214,11 @@
<style> <style>
.side-panel { .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); border-right: 1px solid var(--border-soft);
padding: 16px 14px; padding: 16px 14px;
display: flex; 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"> <script lang="ts">
import { import {
createFragmentFromParsed, createFragmentFromParsed,
@ -7,7 +8,7 @@
parseFragmentContent, parseFragmentContent,
serializeFragment, serializeFragment,
updateFragmentFromParsed, updateFragmentFromParsed,
type FragmentItem type FragmentItem,
} from "$lib/stores/fragments"; } from "$lib/stores/fragments";
import { settingsFragmentTypes, settingsTags } from "$lib/stores/settings"; import { settingsFragmentTypes, settingsTags } from "$lib/stores/settings";
import { renderMarkdown } from "$lib/utils/markdown"; import { renderMarkdown } from "$lib/utils/markdown";
@ -17,7 +18,11 @@
export let openDocumentName = ""; export let openDocumentName = "";
export let openDocumentContent = ""; export let openDocumentContent = "";
export let onDocumentContentChange: (content: string) => void = () => {}; export let onDocumentContentChange: (content: string) => void = () => {};
export let onOpenDocument: (doc: { id: string; label: string; initialContent: string }) => void = () => {}; export let onOpenDocument: (doc: {
id: string;
label: string;
initialContent: string;
}) => void = () => {};
export let onDeleteDocument: (id: string) => void = () => {}; export let onDeleteDocument: (id: string) => void = () => {};
export let externalEditRequested = false; export let externalEditRequested = false;
@ -35,18 +40,30 @@
const customTypeValue = "__custom_type__"; const customTypeValue = "__custom_type__";
const customTagValue = "__custom_tag__"; 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(); const title = fragmentTitle.trim();
if (!title) return null; if (!title) return null;
const resolvedType = fragmentType === customTypeValue ? customFragmentType.trim() : fragmentType; const resolvedType =
fragmentType === customTypeValue
? customFragmentType.trim()
: fragmentType;
if (!resolvedType) return null; if (!resolvedType) return null;
const selectedTags = fragmentTag && fragmentTag !== customTagValue ? [fragmentTag] : []; const selectedTags =
fragmentTag && fragmentTag !== customTagValue ? [fragmentTag] : [];
const customTags = customFragmentTags const customTags = customFragmentTags
.split(",") .split(",")
.map((tag) => tag.trim()) .map((tag) => tag.trim())
.filter(Boolean); .filter(Boolean);
const tagList = [...selectedTags, ...customTags]; 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; return tagList.find((tag) => tag.toLowerCase() === lower) ?? lower;
}); });
const body = fragmentBody.trim() || "Add details for this fragment."; const body = fragmentBody.trim() || "Add details for this fragment.";
@ -54,7 +71,7 @@
title, title,
type: resolvedType, type: resolvedType,
tags: uniqueTagList, tags: uniqueTagList,
body body,
}); });
return { title, resolvedType, body, content, tags: uniqueTagList }; return { title, resolvedType, body, content, tags: uniqueTagList };
} }
@ -73,7 +90,7 @@
title: payload.title, title: payload.title,
type: payload.resolvedType, type: payload.resolvedType,
tags: payload.tags, tags: payload.tags,
body: payload.body body: payload.body,
}); });
if (!updated) return; if (!updated) return;
@ -92,7 +109,7 @@
title: payload.title, title: payload.title,
type: payload.resolvedType, type: payload.resolvedType,
tags: payload.tags, tags: payload.tags,
body: payload.body body: payload.body,
}); });
onOpenDocument(item); onOpenDocument(item);
fragmentMode = "view"; fragmentMode = "view";
@ -141,7 +158,10 @@
function loadFragmentFormFromDocument() { function loadFragmentFormFromDocument() {
const content = openDocumentContent ?? ""; const content = openDocumentContent ?? "";
const isDraftFragment = openDocumentId.startsWith("fragments/new-"); const isDraftFragment = openDocumentId.startsWith("fragments/new-");
const parsed = parseFragmentContent(content, openDocumentName || "Untitled Fragment"); const parsed = parseFragmentContent(
content,
openDocumentName || "Untitled Fragment",
);
fragmentTitle = parsed.title; fragmentTitle = parsed.title;
const parsedType = parsed.type; const parsedType = parsed.type;
if (!parsedType) { if (!parsedType) {
@ -175,22 +195,35 @@
fragmentMode = isDraftFragment ? "create" : "view"; fragmentMode = isDraftFragment ? "create" : "view";
} }
$: fragmentTypeOptions = $settingsFragmentTypes.length ? $settingsFragmentTypes : ["General"]; $: fragmentTypeOptions = $settingsFragmentTypes.length
? $settingsFragmentTypes
: ["General"];
$: tagOptions = $settingsTags; $: tagOptions = $settingsTags;
$: if (openDocumentId !== lastFragmentDocumentId) { $: if (openDocumentId !== lastFragmentDocumentId) {
loadFragmentFormFromDocument(); loadFragmentFormFromDocument();
lastFragmentDocumentId = openDocumentId; lastFragmentDocumentId = openDocumentId;
} }
$: if (!fragmentType || (!fragmentTypeOptions.includes(fragmentType) && fragmentType !== customTypeValue)) { $: if (
!fragmentType ||
(!fragmentTypeOptions.includes(fragmentType) &&
fragmentType !== customTypeValue)
) {
fragmentType = fragmentTypeOptions[0]; fragmentType = fragmentTypeOptions[0];
} }
$: if (!fragmentTag || (!tagOptions.includes(fragmentTag) && fragmentTag !== customTagValue)) { $: if (
!fragmentTag ||
(!tagOptions.includes(fragmentTag) && fragmentTag !== customTagValue)
) {
fragmentTag = tagOptions[0] ?? customTagValue; fragmentTag = tagOptions[0] ?? customTagValue;
} }
$: if (!externalEditRequested) { $: if (!externalEditRequested) {
suppressExternalEditRequest = false; suppressExternalEditRequest = false;
} }
$: if (externalEditRequested && !suppressExternalEditRequest && fragmentMode === "view") { $: if (
externalEditRequested &&
!suppressExternalEditRequest &&
fragmentMode === "view"
) {
fragmentMode = "edit"; fragmentMode = "edit";
} }
</script> </script>
@ -203,10 +236,17 @@
{:else} {:else}
<form <form
class="fragment-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> <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"> <div class="fragment-form-row">
<select bind:value={fragmentType} aria-label="Fragment type"> <select bind:value={fragmentType} aria-label="Fragment type">
{#each fragmentTypeOptions as type} {#each fragmentTypeOptions as type}
@ -222,7 +262,12 @@
aria-label="Custom fragment type" aria-label="Custom fragment type"
/> />
{:else} {:else}
<input type="text" value={fragmentType} disabled aria-label="Selected fragment type" /> <input
type="text"
value={fragmentType}
disabled
aria-label="Selected fragment type"
/>
{/if} {/if}
</div> </div>
<div class="fragment-form-row"> <div class="fragment-form-row">
@ -252,8 +297,16 @@
aria-label="Fragment body" aria-label="Fragment body"
></textarea> ></textarea>
<div class="fragment-actions"> <div class="fragment-actions">
<button type="submit" class="fragment-submit">{fragmentMode === "create" ? "Create Fragment" : "Save Fragment"}</button> <button type="submit" class="fragment-submit"
<button type="button" class="fragment-secondary" on:click={cancelFragmentEdit}>Cancel</button> >{fragmentMode === "create"
? "Create Fragment"
: "Save Fragment"}</button
>
<button
type="button"
class="fragment-secondary"
on:click={cancelFragmentEdit}>Cancel</button
>
</div> </div>
</form> </form>
{/if} {/if}
@ -292,7 +345,9 @@
color: var(--text-primary); color: var(--text-primary);
font-size: 0.92rem; font-size: 0.92rem;
line-height: 1.65; 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), .fragment-view :global(h1),
@ -334,11 +389,17 @@
width: 100%; width: 100%;
border: 1px solid var(--border-soft); border: 1px solid var(--border-soft);
border-radius: 8px; 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); color: var(--text-primary);
padding: 10px 11px; padding: 10px 11px;
font-size: 0.88rem; 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 { .fragment-form textarea {

View File

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

View File

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

View File

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

View File

@ -1,3 +1,4 @@
<!-- @format -->
<script lang="ts"> <script lang="ts">
import { import {
addTodoItem, addTodoItem,
@ -12,7 +13,7 @@
todosStore, todosStore,
type TodoItem, type TodoItem,
updateTodoItemText, updateTodoItemText,
updateTodoItemTextBackend updateTodoItemTextBackend,
} from "$lib/stores/todos"; } from "$lib/stores/todos";
import { get } from "svelte/store"; import { get } from "svelte/store";
@ -45,7 +46,9 @@
if (!ok) { if (!ok) {
todoItems = toggleTodoItem(todoItems, id); todoItems = toggleTodoItem(todoItems, id);
} else { } 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(); persistTodosForCurrentList();
} }
@ -68,7 +71,9 @@
if (!ok) { if (!ok) {
todoItems = updateTodoItemText(todoItems, id, text); todoItems = updateTodoItemText(todoItems, id, text);
} else { } 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(); persistTodosForCurrentList();
} }
@ -138,7 +143,11 @@
{#each todoItems as todo} {#each todoItems as todo}
<li class="todo-item"> <li class="todo-item">
<label class="todo-check"> <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> </label>
{#if editingTodoId === todo.id} {#if editingTodoId === todo.id}
@ -152,14 +161,30 @@
}} }}
/> />
<div class="todo-actions"> <div class="todo-actions">
<button type="button" class="todo-btn save" on:click={saveEditTodo}>Save</button> <button
<button type="button" class="todo-btn ghost" on:click={cancelEditTodo}>Cancel</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> </div>
{:else} {:else}
<span class="todo-text" class:is-done={todo.done}>{todo.text}</span> <span class="todo-text" class:is-done={todo.done}>{todo.text}</span>
<div class="todo-actions"> <div class="todo-actions">
<button type="button" class="todo-btn ghost" on:click={() => startEditTodo(todo.id)}>Edit</button> <button
<button type="button" class="todo-btn danger" on:click={() => removeTodo(todo.id)}>Remove</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> </div>
{/if} {/if}
</li> </li>
@ -293,6 +318,14 @@
padding: 18px 16px; padding: 18px 16px;
} }
.todo-create {
flex-wrap: wrap;
}
.todo-add-btn {
width: 100%;
}
.todo-item { .todo-item {
grid-template-columns: auto minmax(0, 1fr); grid-template-columns: auto minmax(0, 1fr);
row-gap: 8px; row-gap: 8px;

View File

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

View File

@ -6,7 +6,7 @@ import {
saveEntry as saveEntryCommand, saveEntry as saveEntryCommand,
searchEntries as searchEntriesCommand, searchEntries as searchEntriesCommand,
type EntryListItemDto, type EntryListItemDto,
type EntrySearchRequestDto type EntrySearchRequestDto,
} from "$lib/backend/entries"; } from "$lib/backend/entries";
export type EntryItem = { export type EntryItem = {
@ -59,17 +59,19 @@ function fromListDto(dto: EntryListItemDto): EntryItem {
id: toStoreId(dto.filePath), id: toStoreId(dto.filePath),
label: toLabel(dto.fileName), label: toLabel(dto.fileName),
initialContent: "", initialContent: "",
filePath: dto.filePath filePath: dto.filePath,
}; };
} }
function fromLoadResult(result: Awaited<ReturnType<typeof loadEntryCommand>>): EntryItem { function fromLoadResult(
result: Awaited<ReturnType<typeof loadEntryCommand>>,
): EntryItem {
return { return {
id: toStoreId(result.filePath), id: toStoreId(result.filePath),
label: toLabel(result.fileName), label: toLabel(result.fileName),
initialContent: result.entry.rawContent, initialContent: result.entry.rawContent,
filePath: result.filePath, filePath: result.filePath,
date: result.entry.date date: result.entry.date,
}; };
} }
@ -82,7 +84,7 @@ export function createEntryDraft(): EntryItem {
return { return {
id, id,
label: "Untitled Entry", 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); const filePath = toBackendPath(storeId);
if (!filePath) return null; 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(); const trimmed = content?.trim();
if (!trimmed) return null; if (!trimmed) return null;
const existingPath = toBackendPath(storeId); 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) { if (existingPath) {
payload = { content: trimmed, filePath: existingPath, mode }; 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 loaded = await loadEntryCommand(saved.filePath);
const item = fromLoadResult(loaded); const item = fromLoadResult(loaded);
entriesStore.update((items) => { 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 upsertById(filtered, item);
}); });
return 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 results = await searchEntriesCommand(payload);
const dataDirectory = payload.dataDirectory?.trim() ?? ""; const dataDirectory = payload.dataDirectory?.trim() ?? "";
const separator = dataDirectory.includes("\\") ? "\\" : "/"; const separator = dataDirectory.includes("\\") ? "\\" : "/";
@ -154,8 +171,10 @@ export async function searchEntriesAsItems(payload: EntrySearchRequestDto): Prom
: `entries/search/${encodeURIComponent(result.fileName)}`, : `entries/search/${encodeURIComponent(result.fileName)}`,
label: toLabel(result.fileName), label: toLabel(result.fileName),
initialContent: result.entry.rawContent, initialContent: result.entry.rawContent,
filePath: basePath ? `${basePath}${separator}${result.fileName}` : undefined, filePath: basePath
date: result.entry.date ? `${basePath}${separator}${result.fileName}`
: undefined,
date: result.entry.date,
})); }));
return mapped; return mapped;
} }

View File

@ -4,7 +4,7 @@ import {
deleteFragment as deleteFragmentCommand, deleteFragment as deleteFragmentCommand,
listFragments, listFragments,
updateFragment as updateFragmentCommand, updateFragment as updateFragmentCommand,
type FragmentDto type FragmentDto,
} from "$lib/backend/fragments"; } from "$lib/backend/fragments";
export type FragmentItem = { export type FragmentItem = {
@ -36,7 +36,10 @@ function toBackendId(id: string): string | null {
return backendId || null; return backendId || null;
} }
function splitDescription(description: string): { title: string; body: string } { function splitDescription(description: string): {
title: string;
body: string;
} {
const normalized = description.trim(); const normalized = description.trim();
if (!normalized) { if (!normalized) {
return { title: "Untitled Fragment", body: "" }; return { title: "Untitled Fragment", body: "" };
@ -67,8 +70,8 @@ function dtoToItem(dto: FragmentDto): FragmentItem {
title: parsed.title, title: parsed.title,
type: dto.type, type: dto.type,
tags: dto.tags ?? [], 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 { export function serializeFragment(payload: ParsedFragment): string {
const title = payload.title.trim() || "Untitled Fragment"; const title = payload.title.trim() || "Untitled Fragment";
const type = payload.type.trim(); 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."; const body = payload.body.trim() || "Add details for this fragment.";
return `# ${title}\n\nType: ${type}\nTags: ${tagsLine}\n\n${body}`; 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 headingMatch = content.match(/^#\s+(.+)$/m);
const typeMatch = content.match(/^Type:\s*(.+)$/m); const typeMatch = content.match(/^Type:\s*(.+)$/m);
const tagsMatch = content.match(/^Tags:\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, title: headingMatch?.[1]?.trim() || fallbackTitle,
type: typeMatch?.[1]?.trim() || "", type: typeMatch?.[1]?.trim() || "",
tags, tags,
body: bodyMatch?.[1]?.trim() || "" body: bodyMatch?.[1]?.trim() || "",
}; };
} }
@ -128,31 +136,49 @@ export function createFragmentDraft(): FragmentItem {
return { return {
id, id,
label: "New Fragment", 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 { return {
id: createFragmentId(title), id: createFragmentId(title),
label: title.trim() || "Untitled Fragment", 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) => return items.map((item) =>
item.id === id 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]; 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); 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({ const created = await createFragmentCommand({
type: payload.type.trim(), type: payload.type.trim(),
description: composeDescription(payload.title, payload.body), description: composeDescription(payload.title, payload.body),
tags: payload.tags tags: payload.tags,
}); });
const item = dtoToItem(created); const item = dtoToItem(created);
fragmentsStore.update((items) => prependFragmentItem(items, item)); fragmentsStore.update((items) => prependFragmentItem(items, item));
return 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); const backendId = toBackendId(storeId);
if (!backendId) return null; if (!backendId) return null;
const ok = await updateFragmentCommand(backendId, { const ok = await updateFragmentCommand(backendId, {
type: payload.type.trim(), type: payload.type.trim(),
description: composeDescription(payload.title, payload.body), description: composeDescription(payload.title, payload.body),
tags: payload.tags tags: payload.tags,
}); });
if (!ok) return null; if (!ok) return null;
const item: FragmentItem = { const item: FragmentItem = {
id: storeId, id: storeId,
label: payload.title.trim() || "Untitled Fragment", label: payload.title.trim() || "Untitled Fragment",
initialContent: serializeFragment(payload) initialContent: serializeFragment(payload),
}; };
fragmentsStore.update((items) => upsertById(items, item)); fragmentsStore.update((items) => upsertById(items, item));
return item; return item;
} }
export async function deleteFragmentByStoreId(storeId: string): Promise<boolean> { export async function deleteFragmentByStoreId(
storeId: string,
): Promise<boolean> {
const backendId = toBackendId(storeId); const backendId = toBackendId(storeId);
if (!backendId) return false; if (!backendId) return false;

View File

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

View File

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

View File

@ -7,12 +7,17 @@ import {
listTodoLists, listTodoLists,
updateTodoItem as updateTodoItemCommand, updateTodoItem as updateTodoItemCommand,
updateTodoList as updateTodoListCommand, updateTodoList as updateTodoListCommand,
type TodoListDto type TodoListDto,
} from "$lib/backend/todos"; } from "$lib/backend/todos";
// TodoItem keeps a numeric `id` for local array operations (used by EditorPanel) // TodoItem keeps a numeric `id` for local array operations (used by EditorPanel)
// plus a `backendId` (guid string) for backend persistence. // 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 type TodoListMeta = { id: string; label: string; backendId?: string };
export const todoListsStore = writable<TodoListMeta[]>([]); export const todoListsStore = writable<TodoListMeta[]>([]);
@ -42,7 +47,7 @@ function dtoToMeta(dto: TodoListDto): TodoListMeta {
return { return {
id: toStoreId(dto.id), id: toStoreId(dto.id),
label: dto.label, label: dto.label,
backendId: dto.id backendId: dto.id,
}; };
} }
@ -51,7 +56,7 @@ function dtoToItems(dto: TodoListDto): TodoItem[] {
id: createTodoId() + index, id: createTodoId() + index,
text: item.text, text: item.text,
done: item.done, done: item.done,
backendId: item.id backendId: item.id,
})); }));
} }
@ -81,7 +86,7 @@ export async function hydrateTodos(): Promise<void> {
// ── List CRUD ──────────────────────────────────────────────────── // ── List CRUD ────────────────────────────────────────────────────
export async function createTodoListFromLabel( export async function createTodoListFromLabel(
label: string label: string,
): Promise<{ meta: TodoListMeta; items: TodoItem[] }> { ): Promise<{ meta: TodoListMeta; items: TodoItem[] }> {
const resolvedLabel = label.trim() || "New List"; const resolvedLabel = label.trim() || "New List";
const created = await createTodoListCommand({ label: resolvedLabel }); const created = await createTodoListCommand({ label: resolvedLabel });
@ -92,7 +97,9 @@ export async function createTodoListFromLabel(
return { meta, items: [] }; return { meta, items: [] };
} }
export async function deleteTodoListByStoreId(storeId: string): Promise<boolean> { export async function deleteTodoListByStoreId(
storeId: string,
): Promise<boolean> {
const backendId = toBackendId(storeId); const backendId = toBackendId(storeId);
if (!backendId) return false; if (!backendId) return false;
@ -111,7 +118,7 @@ export async function deleteTodoListByStoreId(storeId: string): Promise<boolean>
export async function addTodoItemBackend( export async function addTodoItemBackend(
storeId: string, storeId: string,
text: string text: string,
): Promise<TodoItem | null> { ): Promise<TodoItem | null> {
const backendListId = toBackendId(storeId); const backendListId = toBackendId(storeId);
if (!backendListId || !text.trim()) return null; if (!backendListId || !text.trim()) return null;
@ -122,26 +129,26 @@ export async function addTodoItemBackend(
const created = await createTodoItemCommand({ const created = await createTodoItemCommand({
listId: backendListId, listId: backendListId,
text: text.trim(), text: text.trim(),
sortOrder sortOrder,
}); });
const item: TodoItem = { const item: TodoItem = {
id: createTodoId(), id: createTodoId(),
text: created.text, text: created.text,
done: created.done, done: created.done,
backendId: created.id backendId: created.id,
}; };
todosStore.update((lists) => ({ todosStore.update((lists) => ({
...lists, ...lists,
[storeId]: [item, ...(lists[storeId] ?? [])] [storeId]: [item, ...(lists[storeId] ?? [])],
})); }));
return item; return item;
} }
export async function toggleTodoItemBackend( export async function toggleTodoItemBackend(
storeId: string, storeId: string,
localId: number localId: number,
): Promise<boolean> { ): Promise<boolean> {
const items = get(todosStore)[storeId]; const items = get(todosStore)[storeId];
const todo = items?.find((t) => t.id === localId); const todo = items?.find((t) => t.id === localId);
@ -153,8 +160,8 @@ export async function toggleTodoItemBackend(
todosStore.update((lists) => ({ todosStore.update((lists) => ({
...lists, ...lists,
[storeId]: (lists[storeId] ?? []).map((t) => [storeId]: (lists[storeId] ?? []).map((t) =>
t.id === localId ? { ...t, done: !t.done } : t t.id === localId ? { ...t, done: !t.done } : t,
) ),
})); }));
return true; return true;
} }
@ -162,7 +169,7 @@ export async function toggleTodoItemBackend(
export async function updateTodoItemTextBackend( export async function updateTodoItemTextBackend(
storeId: string, storeId: string,
localId: number, localId: number,
text: string text: string,
): Promise<boolean> { ): Promise<boolean> {
const items = get(todosStore)[storeId]; const items = get(todosStore)[storeId];
const todo = items?.find((t) => t.id === localId); const todo = items?.find((t) => t.id === localId);
@ -174,15 +181,15 @@ export async function updateTodoItemTextBackend(
todosStore.update((lists) => ({ todosStore.update((lists) => ({
...lists, ...lists,
[storeId]: (lists[storeId] ?? []).map((t) => [storeId]: (lists[storeId] ?? []).map((t) =>
t.id === localId ? { ...t, text: text.trim() } : t t.id === localId ? { ...t, text: text.trim() } : t,
) ),
})); }));
return true; return true;
} }
export async function removeTodoItemBackend( export async function removeTodoItemBackend(
storeId: string, storeId: string,
localId: number localId: number,
): Promise<boolean> { ): Promise<boolean> {
const items = get(todosStore)[storeId]; const items = get(todosStore)[storeId];
const todo = items?.find((t) => t.id === localId); const todo = items?.find((t) => t.id === localId);
@ -193,7 +200,7 @@ export async function removeTodoItemBackend(
todosStore.update((lists) => ({ todosStore.update((lists) => ({
...lists, ...lists,
[storeId]: (lists[storeId] ?? []).filter((t) => t.id !== localId) [storeId]: (lists[storeId] ?? []).filter((t) => t.id !== localId),
})); }));
return true; return true;
} }
@ -202,7 +209,9 @@ export async function removeTodoItemBackend(
export function serializeTodoList(title: string, todos: TodoItem[]): string { export function serializeTodoList(title: string, todos: TodoItem[]): string {
const heading = title?.trim() ? `# ${title}` : "# To-Do List"; 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")}`; return `${heading}\n\n${lines.join("\n")}`;
} }
@ -215,7 +224,7 @@ export function parseTodoList(content: string): TodoItem[] {
parsed.push({ parsed.push({
id: createTodoId(), id: createTodoId(),
text: match[2].trim(), text: match[2].trim(),
done: match[1].toLowerCase() === "x" done: match[1].toLowerCase() === "x",
}); });
} }
return parsed; return parsed;
@ -224,7 +233,7 @@ export function parseTodoList(content: string): TodoItem[] {
export function getOrCreateTodoList( export function getOrCreateTodoList(
lists: Record<string, TodoItem[]>, lists: Record<string, TodoItem[]>,
documentId: string, documentId: string,
fallbackContent: string fallbackContent: string,
): { lists: Record<string, TodoItem[]>; todos: TodoItem[] } { ): { lists: Record<string, TodoItem[]>; todos: TodoItem[] } {
const existing = lists[documentId]; const existing = lists[documentId];
if (existing) { if (existing) {
@ -237,7 +246,7 @@ export function getOrCreateTodoList(
export function setTodoList( export function setTodoList(
lists: Record<string, TodoItem[]>, lists: Record<string, TodoItem[]>,
documentId: string, documentId: string,
todos: TodoItem[] todos: TodoItem[],
): Record<string, TodoItem[]> { ): Record<string, TodoItem[]> {
return { ...lists, [documentId]: todos }; return { ...lists, [documentId]: todos };
} }
@ -247,21 +256,32 @@ export function addTodoItem(todos: TodoItem[], text: string): TodoItem[] {
} }
export function toggleTodoItem(todos: TodoItem[], id: number): 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[] { export function updateTodoItemText(
return todos.map((todo) => (todo.id === id ? { ...todo, text: text.trim() } : todo)); 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[] { export function removeTodoItem(todos: TodoItem[], id: number): TodoItem[] {
return todos.filter((todo) => todo.id !== id); 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()}`; const id = `todos/draft-${Date.now()}`;
return { return {
meta: { id, label: "New List" }, 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(/\*([^*]+)\*/g, "<em>$1</em>");
value = value.replace( value = value.replace(
/\[([^\]]+)\]\((journal:[^\s)]+)\)/g, /\[([^\]]+)\]\((journal:[^\s)]+)\)/g,
'<a href="$2">$1</a>' '<a href="$2">$1</a>',
); );
value = value.replace( value = value.replace(
/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g,
'<a href="$2" target="_blank" rel="noreferrer">$1</a>' '<a href="$2" target="_blank" rel="noreferrer">$1</a>',
); );
return value; return value;
} }
@ -37,7 +37,9 @@ export function renderMarkdown(markdown: string): string {
if (trimmed.startsWith("```")) { if (trimmed.startsWith("```")) {
if (inCode) { if (inCode) {
output.push(`<pre><code>${escapeHtml(codeLines.join("\n"))}</code></pre>`); output.push(
`<pre><code>${escapeHtml(codeLines.join("\n"))}</code></pre>`,
);
codeLines = []; codeLines = [];
inCode = false; inCode = false;
} else { } else {
@ -69,7 +71,9 @@ export function renderMarkdown(markdown: string): string {
if (/^[-*+]\s+/.test(trimmed)) { if (/^[-*+]\s+/.test(trimmed)) {
const items: string[] = []; const items: string[] = [];
while (i < lines.length && /^[-*+]\s+/.test(lines[i].trim())) { 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; i += 1;
} }
output.push(`<ul>${items.join("")}</ul>`); output.push(`<ul>${items.join("")}</ul>`);
@ -79,7 +83,9 @@ export function renderMarkdown(markdown: string): string {
if (/^\d+\.\s+/.test(trimmed)) { if (/^\d+\.\s+/.test(trimmed)) {
const items: string[] = []; const items: string[] = [];
while (i < lines.length && /^\d+\.\s+/.test(lines[i].trim())) { 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; i += 1;
} }
output.push(`<ol>${items.join("")}</ol>`); output.push(`<ol>${items.join("")}</ol>`);
@ -87,7 +93,9 @@ export function renderMarkdown(markdown: string): string {
} }
if (/^>\s+/.test(trimmed)) { if (/^>\s+/.test(trimmed)) {
output.push(`<blockquote>${parseInline(trimmed.replace(/^>\s+/, ""))}</blockquote>`); output.push(
`<blockquote>${parseInline(trimmed.replace(/^>\s+/, ""))}</blockquote>`,
);
i += 1; i += 1;
continue; continue;
} }
@ -114,7 +122,8 @@ export function renderMarkdown(markdown: string): string {
} }
export function extractEditorTitle(markdown: string, fallback: 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+(.+)$/); const headingMatch = firstLine.match(/^#\s+(.+)$/);
return headingMatch ? headingMatch[1] : fallback; return headingMatch ? headingMatch[1] : fallback;
} }

View File

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

View File

@ -1,6 +1,11 @@
<!-- @format -->
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; 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 { persistAndClearVault } from "$lib/backend/auth";
import { hydrateUiSettings } from "$lib/stores/settings"; import { hydrateUiSettings } from "$lib/stores/settings";
import { invoke, isTauriRuntime } from "$lib/runtime/invoke"; import { invoke, isTauriRuntime } from "$lib/runtime/invoke";

View File

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

View File

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

View File

@ -1,5 +1,6 @@
:root { :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; font-size: 15px;
line-height: 1.45; line-height: 1.45;
font-weight: 400; font-weight: 400;
@ -51,7 +52,11 @@ body {
} }
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); color: var(--text-primary);
} }
@ -84,6 +89,7 @@ select {
.app-shell { .app-shell {
min-height: 100vh; min-height: 100vh;
min-height: 100dvh;
display: grid; display: grid;
grid-template-columns: 72px 300px minmax(0, 1fr); grid-template-columns: 72px 300px minmax(0, 1fr);
} }