Improve mobile layout and configure Prettier for Svelte
This commit is contained in:
parent
8f67269f44
commit
c2a94ba6f4
8
Journal.App/.prettierignore
Normal file
8
Journal.App/.prettierignore
Normal file
@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
build/
|
||||
.svelte-kit/
|
||||
.vscode/
|
||||
dist/
|
||||
coverage/
|
||||
target/
|
||||
src-tauri/target/
|
||||
@ -18,12 +18,12 @@ npm run tauri dev # Tauri desktop window (connects to dev server)
|
||||
|
||||
## Build Targets
|
||||
|
||||
| Command | Output | Use case |
|
||||
|---------|--------|----------|
|
||||
| `npm run build` | `Journal.App/build/` | Web bundle for `Journal.WebGateway` |
|
||||
| `.\scripts\publish-app.ps1 -Target web` | `Journal.App/build/` | Same, via script |
|
||||
| `.\scripts\publish-app.ps1 -Target tauri -TauriBundles none` | `src-tauri/target/release/journalapp.exe` | Raw desktop exe |
|
||||
| `.\scripts\publish-app.ps1 -Target tauri -TauriBundles nsis` | NSIS installer | Packaged installer |
|
||||
| Command | Output | Use case |
|
||||
| ------------------------------------------------------------ | ----------------------------------------- | ----------------------------------- |
|
||||
| `npm run build` | `Journal.App/build/` | Web bundle for `Journal.WebGateway` |
|
||||
| `.\scripts\publish-app.ps1 -Target web` | `Journal.App/build/` | Same, via script |
|
||||
| `.\scripts\publish-app.ps1 -Target tauri -TauriBundles none` | `src-tauri/target/release/journalapp.exe` | Raw desktop exe |
|
||||
| `.\scripts\publish-app.ps1 -Target tauri -TauriBundles nsis` | NSIS installer | Packaged installer |
|
||||
|
||||
## Frontend State Management
|
||||
|
||||
@ -31,13 +31,13 @@ Svelte stores are the source of truth for all feature state.
|
||||
|
||||
### Current Stores
|
||||
|
||||
| Store file | State exports | Notes |
|
||||
|-----------|---------------|-------|
|
||||
| `src/lib/stores/entries.ts` | `entriesStore` | Entry list, `getDefaultEntry`, `createEntryDraft` |
|
||||
| `src/lib/stores/fragments.ts` | `fragmentsStore` | Fragment CRUD + parse/serialize helpers |
|
||||
| `src/lib/stores/todos.ts` | `todoListsStore`, `todosStore` | Todo list and item CRUD |
|
||||
| `src/lib/stores/lists.ts` | `listsStore` | Generic list CRUD, `createListDraft` |
|
||||
| `src/lib/stores/settings.ts` | `settingsTags`, `settingsFragmentTypes` | Tag/type config |
|
||||
| Store file | State exports | Notes |
|
||||
| ----------------------------- | --------------------------------------- | ------------------------------------------------- |
|
||||
| `src/lib/stores/entries.ts` | `entriesStore` | Entry list, `getDefaultEntry`, `createEntryDraft` |
|
||||
| `src/lib/stores/fragments.ts` | `fragmentsStore` | Fragment CRUD + parse/serialize helpers |
|
||||
| `src/lib/stores/todos.ts` | `todoListsStore`, `todosStore` | Todo list and item CRUD |
|
||||
| `src/lib/stores/lists.ts` | `listsStore` | Generic list CRUD, `createListDraft` |
|
||||
| `src/lib/stores/settings.ts` | `settingsTags`, `settingsFragmentTypes` | Tag/type config |
|
||||
|
||||
### Store-First Rule
|
||||
|
||||
@ -47,14 +47,14 @@ Svelte stores are the source of truth for all feature state.
|
||||
|
||||
## Tauri Commands (Rust → Frontend)
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `sidecar_command` | Forward a `CommandEnvelope` to `Journal.Sidecar` stdin/stdout and return parsed JSON |
|
||||
| `get_sidecar_root` | Get currently resolved sidecar root path |
|
||||
| `set_sidecar_root` | Override root path (saved to `settings.json`, restarts sidecar) |
|
||||
| `get_ui_settings` | Load tag/fragment-type settings |
|
||||
| `set_ui_settings` | Persist tag/fragment-type settings |
|
||||
| `shutdown` | Stop sidecar, exit app |
|
||||
| Command | Description |
|
||||
| ------------------ | ------------------------------------------------------------------------------------ |
|
||||
| `sidecar_command` | Forward a `CommandEnvelope` to `Journal.Sidecar` stdin/stdout and return parsed JSON |
|
||||
| `get_sidecar_root` | Get currently resolved sidecar root path |
|
||||
| `set_sidecar_root` | Override root path (saved to `settings.json`, restarts sidecar) |
|
||||
| `get_ui_settings` | Load tag/fragment-type settings |
|
||||
| `set_ui_settings` | Persist tag/fragment-type settings |
|
||||
| `shutdown` | Stop sidecar, exit app |
|
||||
|
||||
## Sidecar Path Resolution
|
||||
|
||||
|
||||
36
Journal.App/package-lock.json
generated
36
Journal.App/package-lock.json
generated
@ -18,6 +18,8 @@
|
||||
"@sveltejs/kit": "^2.9.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@tauri-apps/cli": "^2",
|
||||
"prettier": "^3.8.1",
|
||||
"prettier-plugin-svelte": "^3.5.0",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"typescript": "~5.6.2",
|
||||
@ -906,7 +908,6 @@
|
||||
"integrity": "sha512-NXsZLvalgI3HrHG6ogoEVzjyV7bSFQNqQeekfU7nNufQFrRyV3EBDfQKEwxx50peu7spZR42JuC1PFhwxuvBrg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
"@sveltejs/acorn-typescript": "^1.0.5",
|
||||
@ -949,7 +950,6 @@
|
||||
"integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@sveltejs/vite-plugin-svelte-inspector": "^4.0.1",
|
||||
"debug": "^4.4.1",
|
||||
@ -1256,7 +1256,6 @@
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@ -1543,7 +1542,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@ -1580,6 +1578,33 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/prettier": {
|
||||
"version": "3.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
|
||||
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/prettier/prettier?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/prettier-plugin-svelte": {
|
||||
"version": "3.5.0",
|
||||
"resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.5.0.tgz",
|
||||
"integrity": "sha512-2lLO/7EupnjO/95t+XZesXs8Bf3nYLIDfCo270h5QWbj/vjLqmrQ1LiRk9LPggxSDsnVYfehamZNf+rgQYApZg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"prettier": "^3.0.0",
|
||||
"svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0"
|
||||
}
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
||||
@ -1690,7 +1715,6 @@
|
||||
"integrity": "sha512-pRUBr6j6uQDgBi208gHnGRMykw0Rf2Yr1HmLyRucsvcaYgIUxswJkT93WZJflsmezu5s8Lq+q78EoyLv2yaFCg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jridgewell/remapping": "^2.3.4",
|
||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||
@ -1770,7 +1794,6 @@
|
||||
"integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@ -1785,7 +1808,6 @@
|
||||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.4",
|
||||
|
||||
@ -9,7 +9,9 @@
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"tauri": "tauri"
|
||||
"tauri": "tauri",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check ."
|
||||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@ -22,6 +24,8 @@
|
||||
"@sveltejs/kit": "^2.9.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@tauri-apps/cli": "^2",
|
||||
"prettier": "^3.8.1",
|
||||
"prettier-plugin-svelte": "^3.5.0",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"typescript": "~5.6.2",
|
||||
|
||||
11
Journal.App/prettier.config.cjs
Normal file
11
Journal.App/prettier.config.cjs
Normal file
@ -0,0 +1,11 @@
|
||||
module.exports = {
|
||||
plugins: ["prettier-plugin-svelte"],
|
||||
overrides: [
|
||||
{
|
||||
files: "*.svelte",
|
||||
options: {
|
||||
parser: "svelte",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@ -3,9 +3,5 @@
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"dialog:default",
|
||||
"opener:default"
|
||||
]
|
||||
"permissions": ["core:default", "dialog:default", "opener:default"]
|
||||
}
|
||||
|
||||
@ -32,4 +32,4 @@
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,12 +3,16 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200" />
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200"
|
||||
/>
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Journal</title>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
|
||||
@ -4,7 +4,7 @@ import { pickCase } from "./normalize";
|
||||
export function hydrateWorkspace(password: string): Promise<unknown> {
|
||||
return sendCommand<unknown>({
|
||||
action: "db.hydrate_workspace",
|
||||
payload: { password }
|
||||
payload: { password },
|
||||
});
|
||||
}
|
||||
|
||||
@ -24,17 +24,19 @@ type PersistOptions = {
|
||||
keepalive?: boolean;
|
||||
};
|
||||
|
||||
async function getRuntimeConfig(options: PersistOptions = {}): Promise<RuntimeConfig> {
|
||||
async function getRuntimeConfig(
|
||||
options: PersistOptions = {},
|
||||
): Promise<RuntimeConfig> {
|
||||
const data = await sendCommand<RuntimeConfigRaw>(
|
||||
{
|
||||
action: "config.get"
|
||||
action: "config.get",
|
||||
},
|
||||
options
|
||||
options,
|
||||
);
|
||||
|
||||
return {
|
||||
dataDirectory: pickCase(data, "dataDirectory", "DataDirectory", ""),
|
||||
vaultDirectory: pickCase(data, "vaultDirectory", "VaultDirectory", "")
|
||||
vaultDirectory: pickCase(data, "vaultDirectory", "VaultDirectory", ""),
|
||||
};
|
||||
}
|
||||
|
||||
@ -45,8 +47,8 @@ export async function unlockVaultWorkspace(password: string): Promise<void> {
|
||||
payload: {
|
||||
password,
|
||||
vaultDirectory: config.vaultDirectory,
|
||||
dataDirectory: config.dataDirectory
|
||||
}
|
||||
dataDirectory: config.dataDirectory,
|
||||
},
|
||||
});
|
||||
|
||||
if (!loaded) {
|
||||
@ -57,12 +59,15 @@ export async function unlockVaultWorkspace(password: string): Promise<void> {
|
||||
action: "db.hydrate_workspace",
|
||||
payload: {
|
||||
password,
|
||||
dataDirectory: config.dataDirectory
|
||||
}
|
||||
dataDirectory: config.dataDirectory,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function persistAndClearVault(password: string, options: PersistOptions = {}): Promise<void> {
|
||||
export async function persistAndClearVault(
|
||||
password: string,
|
||||
options: PersistOptions = {},
|
||||
): Promise<void> {
|
||||
const config = await getRuntimeConfig(options);
|
||||
|
||||
await sendCommand<boolean>(
|
||||
@ -71,19 +76,19 @@ export async function persistAndClearVault(password: string, options: PersistOpt
|
||||
payload: {
|
||||
password,
|
||||
vaultDirectory: config.vaultDirectory,
|
||||
dataDirectory: config.dataDirectory
|
||||
}
|
||||
dataDirectory: config.dataDirectory,
|
||||
},
|
||||
},
|
||||
options
|
||||
options,
|
||||
);
|
||||
|
||||
await sendCommand<boolean>(
|
||||
{
|
||||
action: "vault.clear_data_directory",
|
||||
payload: {
|
||||
dataDirectory: config.dataDirectory
|
||||
}
|
||||
dataDirectory: config.dataDirectory,
|
||||
},
|
||||
},
|
||||
options
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
@ -9,14 +9,17 @@ type SendCommandOptions = {
|
||||
keepalive?: boolean;
|
||||
};
|
||||
|
||||
export async function sendCommand<T>(command: BackendCommand, options: SendCommandOptions = {}): Promise<T> {
|
||||
export async function sendCommand<T>(
|
||||
command: BackendCommand,
|
||||
options: SendCommandOptions = {},
|
||||
): Promise<T> {
|
||||
const envelope: BackendCommand = {
|
||||
...command,
|
||||
correlationId: command.correlationId ?? newCorrelationId()
|
||||
correlationId: command.correlationId ?? newCorrelationId(),
|
||||
};
|
||||
const response = await invoke<BackendResponse<T>>("sidecar_command", {
|
||||
command: envelope,
|
||||
keepalive: options.keepalive === true
|
||||
keepalive: options.keepalive === true,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@ -1,5 +1,9 @@
|
||||
import { sendCommand } from "./client";
|
||||
import { normalizeFragment, type FragmentDto, type FragmentDtoRaw } from "./fragments";
|
||||
import {
|
||||
normalizeFragment,
|
||||
type FragmentDto,
|
||||
type FragmentDtoRaw,
|
||||
} from "./fragments";
|
||||
import { pickCase } from "./normalize";
|
||||
|
||||
export type ParsedSectionDto = {
|
||||
@ -107,80 +111,126 @@ function normalizeSection(raw: ParsedSectionDtoRaw): ParsedSectionDto {
|
||||
return {
|
||||
title: pickCase(raw, "title", "Title", ""),
|
||||
content: pickCase(raw, "content", "Content", [] as string[]),
|
||||
checkboxes: pickCase(raw, "checkboxes", "Checkboxes", {} as Record<string, boolean>)
|
||||
checkboxes: pickCase(
|
||||
raw,
|
||||
"checkboxes",
|
||||
"Checkboxes",
|
||||
{} as Record<string, boolean>,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeJournalEntry(raw: JournalEntryDtoRaw | undefined): JournalEntryDto {
|
||||
const fragments = pickCase(raw, "fragments", "Fragments", [] as FragmentDtoRaw[]);
|
||||
const sections = pickCase(raw, "sections", "Sections", {} as Record<string, ParsedSectionDtoRaw>);
|
||||
function normalizeJournalEntry(
|
||||
raw: JournalEntryDtoRaw | undefined,
|
||||
): JournalEntryDto {
|
||||
const fragments = pickCase(
|
||||
raw,
|
||||
"fragments",
|
||||
"Fragments",
|
||||
[] as FragmentDtoRaw[],
|
||||
);
|
||||
const sections = pickCase(
|
||||
raw,
|
||||
"sections",
|
||||
"Sections",
|
||||
{} as Record<string, ParsedSectionDtoRaw>,
|
||||
);
|
||||
return {
|
||||
date: pickCase(raw, "date", "Date", ""),
|
||||
fragments: fragments.map(normalizeFragment),
|
||||
rawContent: pickCase(raw, "rawContent", "RawContent", ""),
|
||||
sections: Object.fromEntries(
|
||||
Object.entries(sections).map(([key, value]) => [key, normalizeSection(value)])
|
||||
)
|
||||
Object.entries(sections).map(([key, value]) => [
|
||||
key,
|
||||
normalizeSection(value),
|
||||
]),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeEntryListItem(raw: EntryListItemDtoRaw): EntryListItemDto {
|
||||
return {
|
||||
fileName: pickCase(raw, "fileName", "FileName", ""),
|
||||
filePath: pickCase(raw, "filePath", "FilePath", "")
|
||||
filePath: pickCase(raw, "filePath", "FilePath", ""),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeEntryLoadResult(raw: EntryLoadResultDtoRaw): EntryLoadResultDto {
|
||||
const nestedEntry = pickCase(raw, "entry", "Entry", undefined as JournalEntryDtoRaw | undefined);
|
||||
const entry =
|
||||
nestedEntry
|
||||
? normalizeJournalEntry(nestedEntry)
|
||||
: normalizeJournalEntry({
|
||||
date: pickCase(raw, "date", "Date", undefined as string | undefined),
|
||||
rawContent: pickCase(raw, "rawContent", "RawContent", undefined as string | undefined),
|
||||
fragments: [],
|
||||
sections: {}
|
||||
});
|
||||
function normalizeEntryLoadResult(
|
||||
raw: EntryLoadResultDtoRaw,
|
||||
): EntryLoadResultDto {
|
||||
const nestedEntry = pickCase(
|
||||
raw,
|
||||
"entry",
|
||||
"Entry",
|
||||
undefined as JournalEntryDtoRaw | undefined,
|
||||
);
|
||||
const entry = nestedEntry
|
||||
? normalizeJournalEntry(nestedEntry)
|
||||
: normalizeJournalEntry({
|
||||
date: pickCase(raw, "date", "Date", undefined as string | undefined),
|
||||
rawContent: pickCase(
|
||||
raw,
|
||||
"rawContent",
|
||||
"RawContent",
|
||||
undefined as string | undefined,
|
||||
),
|
||||
fragments: [],
|
||||
sections: {},
|
||||
});
|
||||
|
||||
return {
|
||||
fileName: pickCase(raw, "fileName", "FileName", ""),
|
||||
filePath: pickCase(raw, "filePath", "FilePath", ""),
|
||||
entry
|
||||
entry,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeEntrySearchResult(raw: EntrySearchResultDtoRaw): EntrySearchResultDto {
|
||||
const nestedEntry = pickCase(raw, "entry", "Entry", undefined as JournalEntryDtoRaw | undefined);
|
||||
const entry =
|
||||
nestedEntry
|
||||
? normalizeJournalEntry(nestedEntry)
|
||||
: normalizeJournalEntry({
|
||||
date: pickCase(raw, "date", "Date", undefined as string | undefined),
|
||||
rawContent: pickCase(raw, "rawContent", "RawContent", undefined as string | undefined),
|
||||
fragments: [],
|
||||
sections: {}
|
||||
});
|
||||
function normalizeEntrySearchResult(
|
||||
raw: EntrySearchResultDtoRaw,
|
||||
): EntrySearchResultDto {
|
||||
const nestedEntry = pickCase(
|
||||
raw,
|
||||
"entry",
|
||||
"Entry",
|
||||
undefined as JournalEntryDtoRaw | undefined,
|
||||
);
|
||||
const entry = nestedEntry
|
||||
? normalizeJournalEntry(nestedEntry)
|
||||
: normalizeJournalEntry({
|
||||
date: pickCase(raw, "date", "Date", undefined as string | undefined),
|
||||
rawContent: pickCase(
|
||||
raw,
|
||||
"rawContent",
|
||||
"RawContent",
|
||||
undefined as string | undefined,
|
||||
),
|
||||
fragments: [],
|
||||
sections: {},
|
||||
});
|
||||
|
||||
return {
|
||||
fileName: pickCase(raw, "fileName", "FileName", ""),
|
||||
entry
|
||||
entry,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listEntries(dataDirectory?: string): Promise<EntryListItemDto[]> {
|
||||
export async function listEntries(
|
||||
dataDirectory?: string,
|
||||
): Promise<EntryListItemDto[]> {
|
||||
const data = await sendCommand<EntryListItemDtoRaw[]>({
|
||||
action: "entries.list",
|
||||
payload: { dataDirectory }
|
||||
payload: { dataDirectory },
|
||||
});
|
||||
|
||||
return data.map(normalizeEntryListItem).filter((item) => Boolean(item.filePath));
|
||||
return data
|
||||
.map(normalizeEntryListItem)
|
||||
.filter((item) => Boolean(item.filePath));
|
||||
}
|
||||
|
||||
export async function loadEntry(filePath: string): Promise<EntryLoadResultDto> {
|
||||
const data = await sendCommand<EntryLoadResultDtoRaw>({
|
||||
action: "entries.load",
|
||||
payload: { filePath }
|
||||
payload: { filePath },
|
||||
});
|
||||
|
||||
return normalizeEntryLoadResult(data);
|
||||
@ -194,26 +244,30 @@ export async function saveEntry(payload: {
|
||||
}): Promise<EntrySaveResultDto> {
|
||||
const data = await sendCommand<EntrySaveResultDtoRaw>({
|
||||
action: "entries.save",
|
||||
payload
|
||||
payload,
|
||||
});
|
||||
|
||||
return {
|
||||
filePath: pickCase(data, "filePath", "FilePath", "")
|
||||
filePath: pickCase(data, "filePath", "FilePath", ""),
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteEntry(filePath: string): Promise<boolean> {
|
||||
return sendCommand<boolean>({
|
||||
action: "entries.delete",
|
||||
payload: { filePath }
|
||||
payload: { filePath },
|
||||
});
|
||||
}
|
||||
|
||||
export async function searchEntries(payload: EntrySearchRequestDto): Promise<EntrySearchResultDto[]> {
|
||||
export async function searchEntries(
|
||||
payload: EntrySearchRequestDto,
|
||||
): Promise<EntrySearchResultDto[]> {
|
||||
const data = await sendCommand<EntrySearchResultDtoRaw[]>({
|
||||
action: "search.entries",
|
||||
payload
|
||||
payload,
|
||||
});
|
||||
|
||||
return data.map(normalizeEntrySearchResult).filter((item) => Boolean(item.fileName));
|
||||
return data
|
||||
.map(normalizeEntrySearchResult)
|
||||
.filter((item) => Boolean(item.fileName));
|
||||
}
|
||||
|
||||
@ -41,13 +41,13 @@ export function normalizeFragment(raw: FragmentDtoRaw): FragmentDto {
|
||||
type: pickCase(raw, "type", "Type", ""),
|
||||
description: pickCase(raw, "description", "Description", ""),
|
||||
time: pickCase(raw, "time", "Time", ""),
|
||||
tags: pickCase(raw, "tags", "Tags", [] as string[])
|
||||
tags: pickCase(raw, "tags", "Tags", [] as string[]),
|
||||
};
|
||||
}
|
||||
|
||||
export async function listFragments(): Promise<FragmentDto[]> {
|
||||
const data = await sendCommand<FragmentDtoRaw[]>({
|
||||
action: "fragments.list"
|
||||
action: "fragments.list",
|
||||
});
|
||||
return data.map(normalizeFragment).filter((item) => Boolean(item.id));
|
||||
}
|
||||
@ -55,32 +55,37 @@ export async function listFragments(): Promise<FragmentDto[]> {
|
||||
export async function getFragment(id: string): Promise<FragmentDto | null> {
|
||||
const data = await sendCommand<FragmentDtoRaw | null>({
|
||||
action: "fragments.get",
|
||||
id
|
||||
id,
|
||||
});
|
||||
if (!data) return null;
|
||||
const normalized = normalizeFragment(data);
|
||||
return normalized.id ? normalized : null;
|
||||
}
|
||||
|
||||
export async function createFragment(payload: CreateFragmentPayload): Promise<FragmentDto> {
|
||||
export async function createFragment(
|
||||
payload: CreateFragmentPayload,
|
||||
): Promise<FragmentDto> {
|
||||
const data = await sendCommand<FragmentDtoRaw>({
|
||||
action: "fragments.create",
|
||||
payload
|
||||
payload,
|
||||
});
|
||||
return normalizeFragment(data);
|
||||
}
|
||||
|
||||
export function updateFragment(id: string, payload: UpdateFragmentPayload): Promise<boolean> {
|
||||
export function updateFragment(
|
||||
id: string,
|
||||
payload: UpdateFragmentPayload,
|
||||
): Promise<boolean> {
|
||||
return sendCommand<boolean>({
|
||||
action: "fragments.update",
|
||||
id,
|
||||
payload
|
||||
payload,
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteFragment(id: string): Promise<boolean> {
|
||||
return sendCommand<boolean>({
|
||||
action: "fragments.delete",
|
||||
id
|
||||
id,
|
||||
});
|
||||
}
|
||||
|
||||
@ -38,13 +38,13 @@ export function normalizeList(raw: ListDocumentDtoRaw): ListDocumentDto {
|
||||
label: pickCase(raw, "label", "Label", ""),
|
||||
content: pickCase(raw, "content", "Content", ""),
|
||||
createdAt: pickCase(raw, "createdAt", "CreatedAt", ""),
|
||||
updatedAt: pickCase(raw, "updatedAt", "UpdatedAt", "")
|
||||
updatedAt: pickCase(raw, "updatedAt", "UpdatedAt", ""),
|
||||
};
|
||||
}
|
||||
|
||||
export async function listLists(): Promise<ListDocumentDto[]> {
|
||||
const data = await sendCommand<ListDocumentDtoRaw[]>({
|
||||
action: "lists.list"
|
||||
action: "lists.list",
|
||||
});
|
||||
return data.map(normalizeList).filter((item) => Boolean(item.id));
|
||||
}
|
||||
@ -52,32 +52,37 @@ export async function listLists(): Promise<ListDocumentDto[]> {
|
||||
export async function getList(id: string): Promise<ListDocumentDto | null> {
|
||||
const data = await sendCommand<ListDocumentDtoRaw | null>({
|
||||
action: "lists.get",
|
||||
id
|
||||
id,
|
||||
});
|
||||
if (!data) return null;
|
||||
const normalized = normalizeList(data);
|
||||
return normalized.id ? normalized : null;
|
||||
}
|
||||
|
||||
export async function createList(payload: CreateListPayload): Promise<ListDocumentDto> {
|
||||
export async function createList(
|
||||
payload: CreateListPayload,
|
||||
): Promise<ListDocumentDto> {
|
||||
const data = await sendCommand<ListDocumentDtoRaw>({
|
||||
action: "lists.create",
|
||||
payload
|
||||
payload,
|
||||
});
|
||||
return normalizeList(data);
|
||||
}
|
||||
|
||||
export function updateList(id: string, payload: UpdateListPayload): Promise<boolean> {
|
||||
export function updateList(
|
||||
id: string,
|
||||
payload: UpdateListPayload,
|
||||
): Promise<boolean> {
|
||||
return sendCommand<boolean>({
|
||||
action: "lists.update",
|
||||
id,
|
||||
payload
|
||||
payload,
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteList(id: string): Promise<boolean> {
|
||||
return sendCommand<boolean>({
|
||||
action: "lists.delete",
|
||||
id
|
||||
id,
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,14 +1,16 @@
|
||||
type UnknownObject = Record<string, unknown>;
|
||||
|
||||
function asObject(value: unknown): UnknownObject | undefined {
|
||||
return value && typeof value === "object" ? (value as UnknownObject) : undefined;
|
||||
return value && typeof value === "object"
|
||||
? (value as UnknownObject)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function pickCase<T>(
|
||||
source: unknown,
|
||||
camelKey: string,
|
||||
pascalKey: string,
|
||||
fallback: T
|
||||
fallback: T,
|
||||
): T {
|
||||
const obj = asObject(source);
|
||||
if (!obj) return fallback;
|
||||
|
||||
@ -37,32 +37,40 @@ type EntryTemplateSaveResultDtoRaw = {
|
||||
FilePath?: string;
|
||||
};
|
||||
|
||||
function normalizeTemplateItem(raw: EntryTemplateItemDtoRaw): EntryTemplateItemDto {
|
||||
function normalizeTemplateItem(
|
||||
raw: EntryTemplateItemDtoRaw,
|
||||
): EntryTemplateItemDto {
|
||||
return {
|
||||
fileName: pickCase(raw, "fileName", "FileName", ""),
|
||||
filePath: pickCase(raw, "filePath", "FilePath", "")
|
||||
filePath: pickCase(raw, "filePath", "FilePath", ""),
|
||||
};
|
||||
}
|
||||
|
||||
export async function listEntryTemplates(dataDirectory?: string): Promise<EntryTemplateItemDto[]> {
|
||||
export async function listEntryTemplates(
|
||||
dataDirectory?: string,
|
||||
): Promise<EntryTemplateItemDto[]> {
|
||||
const data = await sendCommand<EntryTemplateItemDtoRaw[]>({
|
||||
action: "templates.list",
|
||||
payload: { dataDirectory }
|
||||
payload: { dataDirectory },
|
||||
});
|
||||
|
||||
return data.map(normalizeTemplateItem).filter((item) => Boolean(item.filePath));
|
||||
return data
|
||||
.map(normalizeTemplateItem)
|
||||
.filter((item) => Boolean(item.filePath));
|
||||
}
|
||||
|
||||
export async function loadEntryTemplate(filePath: string): Promise<EntryTemplateLoadResultDto> {
|
||||
export async function loadEntryTemplate(
|
||||
filePath: string,
|
||||
): Promise<EntryTemplateLoadResultDto> {
|
||||
const data = await sendCommand<EntryTemplateLoadResultDtoRaw>({
|
||||
action: "templates.load",
|
||||
payload: { filePath }
|
||||
payload: { filePath },
|
||||
});
|
||||
|
||||
return {
|
||||
fileName: pickCase(data, "fileName", "FileName", ""),
|
||||
filePath: pickCase(data, "filePath", "FilePath", ""),
|
||||
content: pickCase(data, "content", "Content", "")
|
||||
content: pickCase(data, "content", "Content", ""),
|
||||
};
|
||||
}
|
||||
|
||||
@ -74,17 +82,17 @@ export async function saveEntryTemplate(payload: {
|
||||
}): Promise<EntryTemplateSaveResultDto> {
|
||||
const data = await sendCommand<EntryTemplateSaveResultDtoRaw>({
|
||||
action: "templates.save",
|
||||
payload
|
||||
payload,
|
||||
});
|
||||
|
||||
return {
|
||||
filePath: pickCase(data, "filePath", "FilePath", "")
|
||||
filePath: pickCase(data, "filePath", "FilePath", ""),
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteEntryTemplate(filePath: string): Promise<boolean> {
|
||||
return sendCommand<boolean>({
|
||||
action: "templates.delete",
|
||||
payload: { filePath }
|
||||
payload: { filePath },
|
||||
});
|
||||
}
|
||||
|
||||
@ -66,7 +66,7 @@ function normalizeItem(raw: TodoItemDtoRaw): TodoItemDto {
|
||||
listId: pickCase(raw, "listId", "ListId", ""),
|
||||
text: pickCase(raw, "text", "Text", ""),
|
||||
done: pickCase(raw, "done", "Done", false),
|
||||
sortOrder: pickCase(raw, "sortOrder", "SortOrder", 0)
|
||||
sortOrder: pickCase(raw, "sortOrder", "SortOrder", 0),
|
||||
};
|
||||
}
|
||||
|
||||
@ -76,13 +76,13 @@ function normalizeList(raw: TodoListDtoRaw): TodoListDto {
|
||||
id: pickCase(raw, "id", "Id", ""),
|
||||
label: pickCase(raw, "label", "Label", ""),
|
||||
createdAt: pickCase(raw, "createdAt", "CreatedAt", ""),
|
||||
items: rawItems.map(normalizeItem)
|
||||
items: rawItems.map(normalizeItem),
|
||||
};
|
||||
}
|
||||
|
||||
export async function listTodoLists(): Promise<TodoListDto[]> {
|
||||
const data = await sendCommand<TodoListDtoRaw[]>({
|
||||
action: "todos.list"
|
||||
action: "todos.list",
|
||||
});
|
||||
return data.map(normalizeList).filter((item) => Boolean(item.id));
|
||||
}
|
||||
@ -90,55 +90,65 @@ export async function listTodoLists(): Promise<TodoListDto[]> {
|
||||
export async function getTodoList(id: string): Promise<TodoListDto | null> {
|
||||
const data = await sendCommand<TodoListDtoRaw | null>({
|
||||
action: "todos.get",
|
||||
id
|
||||
id,
|
||||
});
|
||||
if (!data) return null;
|
||||
const normalized = normalizeList(data);
|
||||
return normalized.id ? normalized : null;
|
||||
}
|
||||
|
||||
export async function createTodoList(payload: CreateTodoListPayload): Promise<TodoListDto> {
|
||||
export async function createTodoList(
|
||||
payload: CreateTodoListPayload,
|
||||
): Promise<TodoListDto> {
|
||||
const data = await sendCommand<TodoListDtoRaw>({
|
||||
action: "todos.create",
|
||||
payload
|
||||
payload,
|
||||
});
|
||||
return normalizeList(data);
|
||||
}
|
||||
|
||||
export function updateTodoList(id: string, payload: UpdateTodoListPayload): Promise<boolean> {
|
||||
export function updateTodoList(
|
||||
id: string,
|
||||
payload: UpdateTodoListPayload,
|
||||
): Promise<boolean> {
|
||||
return sendCommand<boolean>({
|
||||
action: "todos.update",
|
||||
id,
|
||||
payload
|
||||
payload,
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteTodoList(id: string): Promise<boolean> {
|
||||
return sendCommand<boolean>({
|
||||
action: "todos.delete",
|
||||
id
|
||||
id,
|
||||
});
|
||||
}
|
||||
|
||||
export async function createTodoItem(payload: CreateTodoItemPayload): Promise<TodoItemDto> {
|
||||
export async function createTodoItem(
|
||||
payload: CreateTodoItemPayload,
|
||||
): Promise<TodoItemDto> {
|
||||
const data = await sendCommand<TodoItemDtoRaw>({
|
||||
action: "todos.items.create",
|
||||
payload
|
||||
payload,
|
||||
});
|
||||
return normalizeItem(data);
|
||||
}
|
||||
|
||||
export function updateTodoItem(id: string, payload: UpdateTodoItemPayload): Promise<boolean> {
|
||||
export function updateTodoItem(
|
||||
id: string,
|
||||
payload: UpdateTodoItemPayload,
|
||||
): Promise<boolean> {
|
||||
return sendCommand<boolean>({
|
||||
action: "todos.items.update",
|
||||
id,
|
||||
payload
|
||||
payload,
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteTodoItem(id: string): Promise<boolean> {
|
||||
return sendCommand<boolean>({
|
||||
action: "todos.items.delete",
|
||||
id
|
||||
id,
|
||||
});
|
||||
}
|
||||
|
||||
@ -10,4 +10,3 @@ export type BackendCommand = {
|
||||
export type BackendOk<T> = { ok: true; data: T };
|
||||
export type BackendErr = { ok: false; error: string };
|
||||
export type BackendResponse<T> = BackendOk<T> | BackendErr;
|
||||
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
<!-- @format -->
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from "svelte";
|
||||
|
||||
@ -67,9 +68,15 @@
|
||||
|
||||
<div class="modal-actions">
|
||||
{#if showCancel}
|
||||
<button type="button" class="secondary" on:click={handleCancel}>{cancelText}</button>
|
||||
<button type="button" class="secondary" on:click={handleCancel}
|
||||
>{cancelText}</button
|
||||
>
|
||||
{/if}
|
||||
<button type="button" class:danger={tone === "danger"} on:click={handleConfirm}>
|
||||
<button
|
||||
type="button"
|
||||
class:danger={tone === "danger"}
|
||||
on:click={handleConfirm}
|
||||
>
|
||||
{confirmText}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -1,13 +1,31 @@
|
||||
<!-- @format -->
|
||||
<script lang="ts">
|
||||
export let onVisibleMonthChange: (month: { year: number; month: number; label: string }) => void = () => {};
|
||||
export let onSelectedDateChange: (payload: { year: number; month: number; day: number; key: string }) => void =
|
||||
() => {};
|
||||
export let onDateActivate: (payload: { year: number; month: number; day: number; key: string }) => void = () => {};
|
||||
export let onVisibleMonthChange: (month: {
|
||||
year: number;
|
||||
month: number;
|
||||
label: string;
|
||||
}) => void = () => {};
|
||||
export let onSelectedDateChange: (payload: {
|
||||
year: number;
|
||||
month: number;
|
||||
day: number;
|
||||
key: string;
|
||||
}) => void = () => {};
|
||||
export let onDateActivate: (payload: {
|
||||
year: number;
|
||||
month: number;
|
||||
day: number;
|
||||
key: string;
|
||||
}) => void = () => {};
|
||||
|
||||
const today = new Date();
|
||||
let currentYear = today.getFullYear();
|
||||
let currentMonth = today.getMonth();
|
||||
let selectedDateKey = getDateKey(today.getFullYear(), today.getMonth(), today.getDate());
|
||||
let selectedDateKey = getDateKey(
|
||||
today.getFullYear(),
|
||||
today.getMonth(),
|
||||
today.getDate(),
|
||||
);
|
||||
|
||||
const weekdays = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
||||
|
||||
@ -46,7 +64,7 @@
|
||||
year: cell.year,
|
||||
month: cell.month,
|
||||
day: cell.day,
|
||||
key
|
||||
key,
|
||||
});
|
||||
}
|
||||
|
||||
@ -62,14 +80,20 @@
|
||||
for (let i = 0; i < startOffset; i += 1) {
|
||||
const day = prevMonthLastDay - startOffset + i + 1;
|
||||
const prevMonthDate = new Date(year, month - 1, day);
|
||||
const key = getDateKey(prevMonthDate.getFullYear(), prevMonthDate.getMonth(), day);
|
||||
const key = getDateKey(
|
||||
prevMonthDate.getFullYear(),
|
||||
prevMonthDate.getMonth(),
|
||||
day,
|
||||
);
|
||||
nextCells.push({
|
||||
day,
|
||||
month: prevMonthDate.getMonth(),
|
||||
year: prevMonthDate.getFullYear(),
|
||||
inMonth: false,
|
||||
isToday: key === getDateKey(today.getFullYear(), today.getMonth(), today.getDate()),
|
||||
isSelected: key === selectedDateKey
|
||||
isToday:
|
||||
key ===
|
||||
getDateKey(today.getFullYear(), today.getMonth(), today.getDate()),
|
||||
isSelected: key === selectedDateKey,
|
||||
});
|
||||
}
|
||||
|
||||
@ -80,43 +104,73 @@
|
||||
month,
|
||||
year,
|
||||
inMonth: true,
|
||||
isToday: key === getDateKey(today.getFullYear(), today.getMonth(), today.getDate()),
|
||||
isSelected: key === selectedDateKey
|
||||
isToday:
|
||||
key ===
|
||||
getDateKey(today.getFullYear(), today.getMonth(), today.getDate()),
|
||||
isSelected: key === selectedDateKey,
|
||||
});
|
||||
}
|
||||
|
||||
const trailing = (7 - (nextCells.length % 7)) % 7;
|
||||
for (let day = 1; day <= trailing; day += 1) {
|
||||
const nextMonthDate = new Date(year, month + 1, day);
|
||||
const key = getDateKey(nextMonthDate.getFullYear(), nextMonthDate.getMonth(), day);
|
||||
const key = getDateKey(
|
||||
nextMonthDate.getFullYear(),
|
||||
nextMonthDate.getMonth(),
|
||||
day,
|
||||
);
|
||||
nextCells.push({
|
||||
day,
|
||||
month: nextMonthDate.getMonth(),
|
||||
year: nextMonthDate.getFullYear(),
|
||||
inMonth: false,
|
||||
isToday: key === getDateKey(today.getFullYear(), today.getMonth(), today.getDate()),
|
||||
isSelected: key === selectedDateKey
|
||||
isToday:
|
||||
key ===
|
||||
getDateKey(today.getFullYear(), today.getMonth(), today.getDate()),
|
||||
isSelected: key === selectedDateKey,
|
||||
});
|
||||
}
|
||||
|
||||
return nextCells;
|
||||
}
|
||||
|
||||
$: monthLabel = new Date(currentYear, currentMonth, 1).toLocaleString(undefined, { month: "long" });
|
||||
$: monthLabel = new Date(currentYear, currentMonth, 1).toLocaleString(
|
||||
undefined,
|
||||
{ month: "long" },
|
||||
);
|
||||
$: cells = getCalendarCells(currentYear, currentMonth);
|
||||
$: onVisibleMonthChange({ year: currentYear, month: currentMonth, label: monthLabel });
|
||||
$: onVisibleMonthChange({
|
||||
year: currentYear,
|
||||
month: currentMonth,
|
||||
label: monthLabel,
|
||||
});
|
||||
$: {
|
||||
const parts = selectedDateKey.split("-");
|
||||
const [year, month, day] = parts.map((value) => Number(value));
|
||||
if (parts.length === 3 && !Number.isNaN(year) && !Number.isNaN(month) && !Number.isNaN(day)) {
|
||||
onSelectedDateChange({ year, month: month - 1, day, key: selectedDateKey });
|
||||
if (
|
||||
parts.length === 3 &&
|
||||
!Number.isNaN(year) &&
|
||||
!Number.isNaN(month) &&
|
||||
!Number.isNaN(day)
|
||||
) {
|
||||
onSelectedDateChange({
|
||||
year,
|
||||
month: month - 1,
|
||||
day,
|
||||
key: selectedDateKey,
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="calendar-widget" aria-label="Monthly calendar">
|
||||
<header class="calendar-header">
|
||||
<button type="button" class="nav-icon" aria-label="Previous month" on:click={() => changeMonth(-1)}>
|
||||
<button
|
||||
type="button"
|
||||
class="nav-icon"
|
||||
aria-label="Previous month"
|
||||
on:click={() => changeMonth(-1)}
|
||||
>
|
||||
<span class="material-symbols-outlined">chevron_left</span>
|
||||
</button>
|
||||
|
||||
@ -125,7 +179,12 @@
|
||||
<span>{currentYear}</span>
|
||||
</div>
|
||||
|
||||
<button type="button" class="nav-icon" aria-label="Next month" on:click={() => changeMonth(1)}>
|
||||
<button
|
||||
type="button"
|
||||
class="nav-icon"
|
||||
aria-label="Next month"
|
||||
on:click={() => changeMonth(1)}
|
||||
>
|
||||
<span class="material-symbols-outlined">chevron_right</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
<!-- @format -->
|
||||
<script lang="ts">
|
||||
import FragmentEditor from "$lib/components/editor/FragmentEditor.svelte";
|
||||
import ListEditor from "$lib/components/editor/ListEditor.svelte";
|
||||
@ -23,7 +24,11 @@
|
||||
export let onDeleteDocument: (id: string) => void = () => {};
|
||||
export let showLinkedBackButton = false;
|
||||
export let onLinkedBack: () => void = () => {};
|
||||
export let calendarItems: Array<{ id: string; label: string; initialContent: string }> = [];
|
||||
export let calendarItems: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
initialContent: string;
|
||||
}> = [];
|
||||
export let calendarBusy = false;
|
||||
export let calendarError = "";
|
||||
export let previewOnly = true;
|
||||
@ -69,16 +74,25 @@
|
||||
return label?.trim() || "Untitled Entry";
|
||||
}
|
||||
|
||||
function toCalendarCard(item: { id: string; label: string; initialContent: string }): CalendarCard {
|
||||
function toCalendarCard(item: {
|
||||
id: string;
|
||||
label: string;
|
||||
initialContent: string;
|
||||
}): CalendarCard {
|
||||
const content = item.initialContent ?? "";
|
||||
const lower = content.toLowerCase();
|
||||
return {
|
||||
...item,
|
||||
title: deriveTitle(item.label, content),
|
||||
summary: deriveSummary(content),
|
||||
hasTrigger: lower.includes("!trigger") || lower.includes("#trigger") || lower.includes("#stress"),
|
||||
hasMood: lower.includes("mental / emotional snapshot") || lower.includes("cognitive state"),
|
||||
hasOpenTodos: /-\s*\[\s\]/.test(content)
|
||||
hasTrigger:
|
||||
lower.includes("!trigger") ||
|
||||
lower.includes("#trigger") ||
|
||||
lower.includes("#stress"),
|
||||
hasMood:
|
||||
lower.includes("mental / emotional snapshot") ||
|
||||
lower.includes("cognitive state"),
|
||||
hasOpenTodos: /-\s*\[\s\]/.test(content),
|
||||
};
|
||||
}
|
||||
|
||||
@ -88,8 +102,15 @@
|
||||
<main class="editor-panel" aria-label="Editor area">
|
||||
{#if showLinkedBackButton}
|
||||
<div class="editor-nav">
|
||||
<button type="button" class="back-btn" on:click={onLinkedBack} aria-label="Back to source entry">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">arrow_back</span>
|
||||
<button
|
||||
type="button"
|
||||
class="back-btn"
|
||||
on:click={onLinkedBack}
|
||||
aria-label="Back to source entry"
|
||||
>
|
||||
<span class="material-symbols-outlined" aria-hidden="true"
|
||||
>arrow_back</span
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
@ -109,7 +130,11 @@
|
||||
<ul class="calendar-list">
|
||||
{#each calendarCards as item}
|
||||
<li class:is-active={item.id === openDocumentId}>
|
||||
<button type="button" class="calendar-item-btn" on:click={() => onOpenDocument(item)}>
|
||||
<button
|
||||
type="button"
|
||||
class="calendar-item-btn"
|
||||
on:click={() => onOpenDocument(item)}
|
||||
>
|
||||
<div class="calendar-item-head">
|
||||
<h3>{item.title}</h3>
|
||||
<span class="calendar-date">{item.label}</span>
|
||||
@ -117,8 +142,11 @@
|
||||
<p class="calendar-summary">{item.summary}</p>
|
||||
<div class="calendar-badges">
|
||||
{#if item.hasMood}<span class="badge mood">Mood</span>{/if}
|
||||
{#if item.hasTrigger}<span class="badge trigger">Trigger</span>{/if}
|
||||
{#if item.hasOpenTodos}<span class="badge todo">Open To-Dos</span>{/if}
|
||||
{#if item.hasTrigger}<span class="badge trigger">Trigger</span
|
||||
>{/if}
|
||||
{#if item.hasOpenTodos}<span class="badge todo"
|
||||
>Open To-Dos</span
|
||||
>{/if}
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
<!-- @format -->
|
||||
<script lang="ts">
|
||||
export let activeSection: string | null = "entries";
|
||||
export let onSelect: (id: string) => void = () => {};
|
||||
@ -13,7 +14,7 @@
|
||||
{ id: "calendar", label: "Calendar", icon: "calendar_month" },
|
||||
{ id: "fragments", label: "Fragments", icon: "auto_stories" },
|
||||
{ id: "todos", label: "To-Do List", icon: "checklist" },
|
||||
{ id: "lists", label: "Lists", icon: "lists" }
|
||||
{ id: "lists", label: "Lists", icon: "lists" },
|
||||
];
|
||||
|
||||
function selectItem(id: string) {
|
||||
@ -64,7 +65,11 @@
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 14px 10px;
|
||||
background: linear-gradient(180deg, var(--surface-2) 0%, var(--bg-navbar) 100%);
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
var(--surface-2) 0%,
|
||||
var(--bg-navbar) 100%
|
||||
);
|
||||
border-right: 1px solid var(--border-soft);
|
||||
}
|
||||
|
||||
@ -102,7 +107,10 @@
|
||||
color: var(--text-dim);
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.14s ease, color 0.14s ease, border-color 0.14s ease;
|
||||
transition:
|
||||
background-color 0.14s ease,
|
||||
color 0.14s ease,
|
||||
border-color 0.14s ease;
|
||||
}
|
||||
|
||||
.nav-button .material-symbols-outlined {
|
||||
@ -129,7 +137,9 @@
|
||||
background: var(--surface-1);
|
||||
border: 1px solid var(--border-strong);
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
|
||||
transition: opacity 0.12s ease, transform 0.12s ease;
|
||||
transition:
|
||||
opacity 0.12s ease,
|
||||
transform 0.12s ease;
|
||||
}
|
||||
|
||||
.nav-button:hover,
|
||||
|
||||
@ -1,23 +1,60 @@
|
||||
<!-- @format -->
|
||||
<script lang="ts">
|
||||
import { listEntryTemplates, type EntryTemplateItemDto } from "$lib/backend/templates";
|
||||
import {
|
||||
listEntryTemplates,
|
||||
type EntryTemplateItemDto,
|
||||
} from "$lib/backend/templates";
|
||||
import { listFragments, type FragmentDto } from "$lib/backend/fragments";
|
||||
import { listLists, type ListDocumentDto } from "$lib/backend/lists";
|
||||
import { listTodoLists, type TodoListDto } from "$lib/backend/todos";
|
||||
import { sendCommand } from "$lib/backend/client";
|
||||
import CalendarWidget from "$lib/components/CalendarWidget.svelte";
|
||||
import { entriesBusyStore, entriesStore, searchEntriesAsItems } from "$lib/stores/entries";
|
||||
import { createFragmentDraft, fragmentsStore, serializeFragment } from "$lib/stores/fragments";
|
||||
import { createListDraft, createListFromLabel, listsStore } from "$lib/stores/lists";
|
||||
import { createTodoListDraft, createTodoListFromLabel, serializeTodoList, todoListsStore, todosStore } from "$lib/stores/todos";
|
||||
import {
|
||||
entriesBusyStore,
|
||||
entriesStore,
|
||||
searchEntriesAsItems,
|
||||
} from "$lib/stores/entries";
|
||||
import {
|
||||
createFragmentDraft,
|
||||
fragmentsStore,
|
||||
serializeFragment,
|
||||
} from "$lib/stores/fragments";
|
||||
import {
|
||||
createListDraft,
|
||||
createListFromLabel,
|
||||
listsStore,
|
||||
} from "$lib/stores/lists";
|
||||
import {
|
||||
createTodoListDraft,
|
||||
createTodoListFromLabel,
|
||||
serializeTodoList,
|
||||
todoListsStore,
|
||||
todosStore,
|
||||
} from "$lib/stores/todos";
|
||||
import { extractEntryTags } from "$lib/utils/metadata";
|
||||
|
||||
export let activeSection = "entries";
|
||||
export let activeDocumentId = "";
|
||||
export let templateRefreshToken = 0;
|
||||
export let onOpenDocument: (doc: { id: string; label: string; initialContent: string }) => void = () => {};
|
||||
export let onEditItem: (doc: { id: string; label: string; initialContent: string }) => void = () => {};
|
||||
export let onDeleteItem: (doc: { id: string; label: string }) => void = () => {};
|
||||
export let onCalendarStateChange: (state: { items: SidePanelItem[]; busy: boolean; error: string }) => void = () => {};
|
||||
export let onOpenDocument: (doc: {
|
||||
id: string;
|
||||
label: string;
|
||||
initialContent: string;
|
||||
}) => void = () => {};
|
||||
export let onEditItem: (doc: {
|
||||
id: string;
|
||||
label: string;
|
||||
initialContent: string;
|
||||
}) => void = () => {};
|
||||
export let onDeleteItem: (doc: {
|
||||
id: string;
|
||||
label: string;
|
||||
}) => void = () => {};
|
||||
export let onCalendarStateChange: (state: {
|
||||
items: SidePanelItem[];
|
||||
busy: boolean;
|
||||
error: string;
|
||||
}) => void = () => {};
|
||||
|
||||
let showNewItemInput = false;
|
||||
let newItemName = "";
|
||||
@ -56,14 +93,18 @@
|
||||
calendar: "Calendar",
|
||||
fragments: "Fragments",
|
||||
todos: "To-Do List",
|
||||
lists: "Lists"
|
||||
lists: "Lists",
|
||||
};
|
||||
|
||||
const today = new Date();
|
||||
let calendarYear = today.getFullYear();
|
||||
let calendarMonth = today.getMonth();
|
||||
let calendarMonthLabel = new Date(calendarYear, calendarMonth, 1).toLocaleString(undefined, {
|
||||
month: "long"
|
||||
let calendarMonthLabel = new Date(
|
||||
calendarYear,
|
||||
calendarMonth,
|
||||
1,
|
||||
).toLocaleString(undefined, {
|
||||
month: "long",
|
||||
});
|
||||
let calendarViewMode: CalendarViewMode = "month";
|
||||
let calendarSortMode: CalendarSortMode = "desc";
|
||||
@ -86,14 +127,23 @@
|
||||
let calendarDateExplicitlySelected = false;
|
||||
const CALENDAR_SAVED_VIEWS_KEY = "journal.calendar.savedViews";
|
||||
|
||||
let selectedCalendarDate: { year: number; month: number; day: number; key: string } | null = {
|
||||
let selectedCalendarDate: {
|
||||
year: number;
|
||||
month: number;
|
||||
day: number;
|
||||
key: string;
|
||||
} | null = {
|
||||
year: today.getFullYear(),
|
||||
month: today.getMonth(),
|
||||
day: today.getDate(),
|
||||
key: `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`
|
||||
key: `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`,
|
||||
};
|
||||
|
||||
function handleVisibleMonthChange(payload: { year: number; month: number; label: string }) {
|
||||
function handleVisibleMonthChange(payload: {
|
||||
year: number;
|
||||
month: number;
|
||||
label: string;
|
||||
}) {
|
||||
calendarYear = payload.year;
|
||||
calendarMonth = payload.month;
|
||||
calendarMonthLabel = payload.label;
|
||||
@ -111,7 +161,10 @@
|
||||
}
|
||||
|
||||
function parseDateLabel(value: string): Date | null {
|
||||
const token = value.trim().split(/\s*[|·]\s*/)[0].trim();
|
||||
const token = value
|
||||
.trim()
|
||||
.split(/\s*[|·]\s*/)[0]
|
||||
.trim();
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(token)) return null;
|
||||
const date = new Date(`${token}T00:00:00`);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
@ -127,7 +180,11 @@
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function isWithinRange(date: Date, startDate: string, endDate: string): boolean {
|
||||
function isWithinRange(
|
||||
date: Date,
|
||||
startDate: string,
|
||||
endDate: string,
|
||||
): boolean {
|
||||
const start = parseDateLabel(startDate);
|
||||
const end = parseDateLabel(endDate);
|
||||
if (!start || !end) return true;
|
||||
@ -149,7 +206,7 @@
|
||||
const selected = selectedCalendarDate ?? {
|
||||
year: calendarYear,
|
||||
month: calendarMonth,
|
||||
day: 1
|
||||
day: 1,
|
||||
};
|
||||
const selectedDate = new Date(selected.year, selected.month, selected.day);
|
||||
|
||||
@ -173,8 +230,11 @@
|
||||
}
|
||||
|
||||
async function getDataDirectory(): Promise<string> {
|
||||
const config = await sendCommand<{ dataDirectory?: string; DataDirectory?: string }>({
|
||||
action: "config.get"
|
||||
const config = await sendCommand<{
|
||||
dataDirectory?: string;
|
||||
DataDirectory?: string;
|
||||
}>({
|
||||
action: "config.get",
|
||||
});
|
||||
return (config.dataDirectory ?? config.DataDirectory ?? "").trim();
|
||||
}
|
||||
@ -182,7 +242,8 @@
|
||||
function toFragmentTimelineItem(fragment: FragmentDto): SidePanelItem {
|
||||
const split = fragment.description.split(/\n{2,}/);
|
||||
const title = (split[0] ?? "").trim() || "Untitled Fragment";
|
||||
const body = split.slice(1).join("\n\n").trim() || "Add details for this fragment.";
|
||||
const body =
|
||||
split.slice(1).join("\n\n").trim() || "Add details for this fragment.";
|
||||
const date = parseIsoDate(fragment.time) ?? new Date();
|
||||
const dateKey = toIsoDate(date);
|
||||
return {
|
||||
@ -192,20 +253,23 @@
|
||||
title,
|
||||
type: fragment.type,
|
||||
tags: fragment.tags ?? [],
|
||||
body
|
||||
body,
|
||||
}),
|
||||
sortDate: date.toISOString()
|
||||
sortDate: date.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function toListTimelineItem(list: ListDocumentDto): SidePanelItem {
|
||||
const created = parseIsoDate(list.createdAt) ?? parseIsoDate(list.updatedAt) ?? new Date();
|
||||
const created =
|
||||
parseIsoDate(list.createdAt) ??
|
||||
parseIsoDate(list.updatedAt) ??
|
||||
new Date();
|
||||
const dateKey = toIsoDate(created);
|
||||
return {
|
||||
id: `lists/${list.id}`,
|
||||
label: `${dateKey} | List | ${list.label}`,
|
||||
initialContent: list.content || `# ${list.label}\n\n`,
|
||||
sortDate: created.toISOString()
|
||||
sortDate: created.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
@ -215,13 +279,13 @@
|
||||
const items = list.items.map((item, index) => ({
|
||||
id: Date.now() + index,
|
||||
text: item.text,
|
||||
done: item.done
|
||||
done: item.done,
|
||||
}));
|
||||
return {
|
||||
id: `todos/${list.id}`,
|
||||
label: `${dateKey} | To-Do | ${list.label}`,
|
||||
initialContent: serializeTodoList(list.label, items),
|
||||
sortDate: created.toISOString()
|
||||
sortDate: created.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
@ -235,9 +299,14 @@
|
||||
return tokens.some((token) => lower.includes(token));
|
||||
}
|
||||
|
||||
function matchesTags(itemTags: string[] | undefined, tagTokens: string[]): boolean {
|
||||
function matchesTags(
|
||||
itemTags: string[] | undefined,
|
||||
tagTokens: string[],
|
||||
): boolean {
|
||||
if (tagTokens.length === 0) return true;
|
||||
const normalized = (itemTags ?? []).map((tag) => tag.toLowerCase().trim()).filter(Boolean);
|
||||
const normalized = (itemTags ?? [])
|
||||
.map((tag) => tag.toLowerCase().trim())
|
||||
.filter(Boolean);
|
||||
return tagTokens.some((token) => normalized.includes(token));
|
||||
}
|
||||
|
||||
@ -254,13 +323,13 @@
|
||||
const { startDate, endDate } = getActiveDateRange();
|
||||
const entryItems = await searchEntriesAsItems({
|
||||
dataDirectory,
|
||||
query: calendarQuery.trim() || undefined
|
||||
query: calendarQuery.trim() || undefined,
|
||||
});
|
||||
|
||||
const [fragmentDtos, listDtos, todoDtos] = await Promise.all([
|
||||
listFragments(),
|
||||
listLists(),
|
||||
listTodoLists()
|
||||
listTodoLists(),
|
||||
]);
|
||||
|
||||
const query = calendarQuery.trim();
|
||||
@ -274,32 +343,40 @@
|
||||
if (!date || !isWithinRange(date, startDate, endDate)) return false;
|
||||
if (!matchesTextQuery(fragment.description, query)) return false;
|
||||
if (!matchesTags(fragment.tags, tagTokens)) return false;
|
||||
if (hasTypeFilter && !matchesAnyToken(fragment.type ?? "", typeTokens)) return false;
|
||||
if (
|
||||
hasTypeFilter &&
|
||||
!matchesAnyToken(fragment.type ?? "", typeTokens)
|
||||
)
|
||||
return false;
|
||||
return true;
|
||||
})
|
||||
.map(toFragmentTimelineItem);
|
||||
|
||||
const listItems = listDtos
|
||||
.map(toListTimelineItem)
|
||||
.filter((item) => {
|
||||
if (hasTypeFilter) return false;
|
||||
if (tagTokens.length > 0) return false;
|
||||
const date = item.sortDate ? parseIsoDate(item.sortDate) : null;
|
||||
if (!date || !isWithinRange(date, startDate, endDate)) return false;
|
||||
if (!matchesTextQuery(item.initialContent, query) && !matchesTextQuery(item.label, query)) return false;
|
||||
return true;
|
||||
});
|
||||
const listItems = listDtos.map(toListTimelineItem).filter((item) => {
|
||||
if (hasTypeFilter) return false;
|
||||
if (tagTokens.length > 0) return false;
|
||||
const date = item.sortDate ? parseIsoDate(item.sortDate) : null;
|
||||
if (!date || !isWithinRange(date, startDate, endDate)) return false;
|
||||
if (
|
||||
!matchesTextQuery(item.initialContent, query) &&
|
||||
!matchesTextQuery(item.label, query)
|
||||
)
|
||||
return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const todoItems = todoDtos
|
||||
.map(toTodoTimelineItem)
|
||||
.filter((item) => {
|
||||
if (hasTypeFilter) return false;
|
||||
if (tagTokens.length > 0) return false;
|
||||
const date = item.sortDate ? parseIsoDate(item.sortDate) : null;
|
||||
if (!date || !isWithinRange(date, startDate, endDate)) return false;
|
||||
if (!matchesTextQuery(item.initialContent, query) && !matchesTextQuery(item.label, query)) return false;
|
||||
return true;
|
||||
});
|
||||
const todoItems = todoDtos.map(toTodoTimelineItem).filter((item) => {
|
||||
if (hasTypeFilter) return false;
|
||||
if (tagTokens.length > 0) return false;
|
||||
const date = item.sortDate ? parseIsoDate(item.sortDate) : null;
|
||||
if (!date || !isWithinRange(date, startDate, endDate)) return false;
|
||||
if (
|
||||
!matchesTextQuery(item.initialContent, query) &&
|
||||
!matchesTextQuery(item.label, query)
|
||||
)
|
||||
return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const entriesWithKind: SidePanelItem[] = entryItems
|
||||
.map((item) => {
|
||||
@ -309,29 +386,43 @@
|
||||
id: item.id,
|
||||
label: `${dateKey} | Entry | ${item.label}`,
|
||||
initialContent: item.initialContent,
|
||||
sortDate: date ? date.toISOString() : undefined
|
||||
sortDate: date ? date.toISOString() : undefined,
|
||||
};
|
||||
})
|
||||
.filter((item) => {
|
||||
if (hasTypeFilter) return false;
|
||||
if (!matchesTags(extractEntryTags(item.initialContent), tagTokens)) return false;
|
||||
if (!matchesTextQuery(item.initialContent, query) && !matchesTextQuery(item.label, query)) return false;
|
||||
if (!matchesTags(extractEntryTags(item.initialContent), tagTokens))
|
||||
return false;
|
||||
if (
|
||||
!matchesTextQuery(item.initialContent, query) &&
|
||||
!matchesTextQuery(item.label, query)
|
||||
)
|
||||
return false;
|
||||
const date = item.sortDate ? parseIsoDate(item.sortDate) : null;
|
||||
if (date && !isWithinRange(date, startDate, endDate)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const merged = [...entriesWithKind, ...fragmentItems, ...listItems, ...todoItems];
|
||||
const merged = [
|
||||
...entriesWithKind,
|
||||
...fragmentItems,
|
||||
...listItems,
|
||||
...todoItems,
|
||||
];
|
||||
const sorted = merged.sort((a, b) => {
|
||||
const aDate = a.sortDate ? parseIsoDate(a.sortDate)?.getTime() ?? 0 : parseDateLabel(a.label)?.getTime() ?? 0;
|
||||
const bDate = b.sortDate ? parseIsoDate(b.sortDate)?.getTime() ?? 0 : parseDateLabel(b.label)?.getTime() ?? 0;
|
||||
const aDate = a.sortDate
|
||||
? (parseIsoDate(a.sortDate)?.getTime() ?? 0)
|
||||
: (parseDateLabel(a.label)?.getTime() ?? 0);
|
||||
const bDate = b.sortDate
|
||||
? (parseIsoDate(b.sortDate)?.getTime() ?? 0)
|
||||
: (parseDateLabel(b.label)?.getTime() ?? 0);
|
||||
return calendarSortMode === "asc" ? aDate - bDate : bDate - aDate;
|
||||
});
|
||||
calendarTimelineItems = sorted;
|
||||
calendarLastRefreshedAt = new Date().toLocaleTimeString(undefined, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit"
|
||||
second: "2-digit",
|
||||
});
|
||||
} catch (error) {
|
||||
calendarError = String(error);
|
||||
@ -341,7 +432,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function forceRefreshCalendar(options?: { allowWhileBusy?: boolean }): Promise<void> {
|
||||
async function forceRefreshCalendar(options?: {
|
||||
allowWhileBusy?: boolean;
|
||||
}): Promise<void> {
|
||||
if (activeSection !== "calendar") return;
|
||||
if (calendarBusy && !options?.allowWhileBusy) return;
|
||||
if (calendarTimelineDebounce) {
|
||||
@ -369,7 +462,10 @@
|
||||
|
||||
function persistSavedViews() {
|
||||
if (typeof window === "undefined") return;
|
||||
window.localStorage.setItem(CALENDAR_SAVED_VIEWS_KEY, JSON.stringify(calendarSavedViews));
|
||||
window.localStorage.setItem(
|
||||
CALENDAR_SAVED_VIEWS_KEY,
|
||||
JSON.stringify(calendarSavedViews),
|
||||
);
|
||||
}
|
||||
|
||||
function applySavedView(view: SavedCalendarView) {
|
||||
@ -399,7 +495,7 @@
|
||||
tags: calendarTags,
|
||||
types: calendarTypes,
|
||||
startDate: calendarStartDate,
|
||||
endDate: calendarEndDate
|
||||
endDate: calendarEndDate,
|
||||
};
|
||||
calendarSavedViews = [view, ...calendarSavedViews];
|
||||
persistSavedViews();
|
||||
@ -422,7 +518,7 @@
|
||||
tags: "",
|
||||
types: "",
|
||||
startDate: "",
|
||||
endDate: ""
|
||||
endDate: "",
|
||||
},
|
||||
{
|
||||
id: "builtin-trigger",
|
||||
@ -433,13 +529,16 @@
|
||||
tags: "stress, trigger",
|
||||
types: "!TRIGGER",
|
||||
startDate: "",
|
||||
endDate: ""
|
||||
endDate: "",
|
||||
},
|
||||
|
||||
];
|
||||
|
||||
function openOrCreateDailyEntry(dateKey: string) {
|
||||
const existing = $entriesStore.find((item) => item.label === dateKey && !item.id.startsWith("entries/template-draft-"));
|
||||
const existing = $entriesStore.find(
|
||||
(item) =>
|
||||
item.label === dateKey &&
|
||||
!item.id.startsWith("entries/template-draft-"),
|
||||
);
|
||||
if (existing) {
|
||||
onOpenDocument(existing);
|
||||
return;
|
||||
@ -448,17 +547,27 @@
|
||||
const draft: SidePanelItem = {
|
||||
id: `entries/draft-${Date.now()}`,
|
||||
label: dateKey,
|
||||
initialContent: `# ${dateKey}\n\n`
|
||||
initialContent: `# ${dateKey}\n\n`,
|
||||
};
|
||||
entriesStore.update((items) => [draft, ...items]);
|
||||
onEditItem(draft);
|
||||
}
|
||||
|
||||
function handleSelectedDateChange(payload: { year: number; month: number; day: number; key: string }) {
|
||||
function handleSelectedDateChange(payload: {
|
||||
year: number;
|
||||
month: number;
|
||||
day: number;
|
||||
key: string;
|
||||
}) {
|
||||
selectedCalendarDate = payload;
|
||||
}
|
||||
|
||||
function handleDateActivate(payload: { year: number; month: number; day: number; key: string }) {
|
||||
function handleDateActivate(payload: {
|
||||
year: number;
|
||||
month: number;
|
||||
day: number;
|
||||
key: string;
|
||||
}) {
|
||||
selectedCalendarDate = payload;
|
||||
if (activeSection !== "calendar") return;
|
||||
calendarDateExplicitlySelected = true;
|
||||
@ -478,7 +587,7 @@
|
||||
return {
|
||||
id: toTemplateStoreId(item.filePath),
|
||||
label: toTemplateLabel(item.fileName),
|
||||
initialContent: ""
|
||||
initialContent: "",
|
||||
};
|
||||
}
|
||||
|
||||
@ -498,7 +607,11 @@
|
||||
}
|
||||
|
||||
function handleAddItem() {
|
||||
if (activeSection === "entries" || activeSection === "todos" || activeSection === "lists") {
|
||||
if (
|
||||
activeSection === "entries" ||
|
||||
activeSection === "todos" ||
|
||||
activeSection === "lists"
|
||||
) {
|
||||
if (activeSection === "entries") {
|
||||
createTemplateMode = false;
|
||||
}
|
||||
@ -519,7 +632,7 @@
|
||||
year: calendarYear,
|
||||
month: calendarMonth,
|
||||
day: 1,
|
||||
key: toDateKey(calendarYear, calendarMonth, 1)
|
||||
key: toDateKey(calendarYear, calendarMonth, 1),
|
||||
};
|
||||
openOrCreateDailyEntry(selected.key);
|
||||
} else {
|
||||
@ -553,7 +666,10 @@
|
||||
newItemName = "";
|
||||
|
||||
const label = toDailyNoteLabel(new Date());
|
||||
const existing = $entriesStore.find((item) => item.label === label && !item.id.startsWith("entries/template-draft-"));
|
||||
const existing = $entriesStore.find(
|
||||
(item) =>
|
||||
item.label === label && !item.id.startsWith("entries/template-draft-"),
|
||||
);
|
||||
if (existing) {
|
||||
onEditItem(existing);
|
||||
return;
|
||||
@ -581,8 +697,14 @@
|
||||
? label
|
||||
: `${label}_template`
|
||||
: label;
|
||||
const id = isTemplate ? `entries/template-draft-${Date.now()}` : `entries/draft-${Date.now()}`;
|
||||
const item = { id, label: displayLabel, initialContent: `# ${displayLabel}\n\n` };
|
||||
const id = isTemplate
|
||||
? `entries/template-draft-${Date.now()}`
|
||||
: `entries/draft-${Date.now()}`;
|
||||
const item = {
|
||||
id,
|
||||
label: displayLabel,
|
||||
initialContent: `# ${displayLabel}\n\n`,
|
||||
};
|
||||
entriesStore.update((items) => [item, ...items]);
|
||||
onEditItem(item);
|
||||
createTemplateMode = false;
|
||||
@ -597,17 +719,20 @@
|
||||
onOpenDocument({
|
||||
id: meta.id,
|
||||
label: meta.label,
|
||||
initialContent: serializeTodoList(meta.label, todoItems)
|
||||
initialContent: serializeTodoList(meta.label, todoItems),
|
||||
});
|
||||
} catch (error) {
|
||||
const draft = createTodoListDraft();
|
||||
draft.meta.label = label;
|
||||
todoListsStore.update((lists) => [draft.meta, ...lists]);
|
||||
todosStore.update((lists) => ({ ...lists, [draft.meta.id]: draft.items }));
|
||||
todosStore.update((lists) => ({
|
||||
...lists,
|
||||
[draft.meta.id]: draft.items,
|
||||
}));
|
||||
onOpenDocument({
|
||||
id: draft.meta.id,
|
||||
label: draft.meta.label,
|
||||
initialContent: serializeTodoList(draft.meta.label, draft.items)
|
||||
initialContent: serializeTodoList(draft.meta.label, draft.items),
|
||||
});
|
||||
}
|
||||
} else if (activeSection === "lists") {
|
||||
@ -643,28 +768,42 @@
|
||||
$: todoDocuments = $todoListsStore.map(({ id, label }) => ({
|
||||
id,
|
||||
label,
|
||||
initialContent: serializeTodoList(label, $todosStore[id] ?? [])
|
||||
initialContent: serializeTodoList(label, $todosStore[id] ?? []),
|
||||
}));
|
||||
$: items = activeSection === "entries"
|
||||
? $entriesStore
|
||||
: activeSection === "todos"
|
||||
? todoDocuments
|
||||
: activeSection === "fragments"
|
||||
? $fragmentsStore
|
||||
: activeSection === "lists"
|
||||
? $listsStore
|
||||
: [];
|
||||
$: items =
|
||||
activeSection === "entries"
|
||||
? $entriesStore
|
||||
: activeSection === "todos"
|
||||
? todoDocuments
|
||||
: activeSection === "fragments"
|
||||
? $fragmentsStore
|
||||
: activeSection === "lists"
|
||||
? $listsStore
|
||||
: [];
|
||||
$: isCalendarSection = activeSection === "calendar";
|
||||
$: showItemActions = activeSection === "entries" || activeSection === "todos" || activeSection === "lists" || activeSection === "fragments";
|
||||
$: entryItems = activeSection === "entries"
|
||||
? $entriesStore.filter((item) => !item.id.startsWith("entries/template-draft-"))
|
||||
: [];
|
||||
$: templateDraftItems = activeSection === "entries"
|
||||
? $entriesStore.filter((item) => item.id.startsWith("entries/template-draft-"))
|
||||
: [];
|
||||
$: showItemActions =
|
||||
activeSection === "entries" ||
|
||||
activeSection === "todos" ||
|
||||
activeSection === "lists" ||
|
||||
activeSection === "fragments";
|
||||
$: entryItems =
|
||||
activeSection === "entries"
|
||||
? $entriesStore.filter(
|
||||
(item) => !item.id.startsWith("entries/template-draft-"),
|
||||
)
|
||||
: [];
|
||||
$: templateDraftItems =
|
||||
activeSection === "entries"
|
||||
? $entriesStore.filter((item) =>
|
||||
item.id.startsWith("entries/template-draft-"),
|
||||
)
|
||||
: [];
|
||||
$: allTemplateItems = [...templateDraftItems, ...templateItems];
|
||||
|
||||
$: if (activeSection === "entries" && (!wasEntriesSection || templateRefreshToken !== lastTemplateRefreshToken)) {
|
||||
$: if (
|
||||
activeSection === "entries" &&
|
||||
(!wasEntriesSection || templateRefreshToken !== lastTemplateRefreshToken)
|
||||
) {
|
||||
wasEntriesSection = true;
|
||||
lastTemplateRefreshToken = templateRefreshToken;
|
||||
void refreshTemplates();
|
||||
@ -691,8 +830,12 @@
|
||||
calendarTypes,
|
||||
calendarStartDate,
|
||||
calendarEndDate,
|
||||
entriesSig: $entriesStore.map((item) => `${item.id}:${item.label}`).join("|"),
|
||||
fragmentsSig: $fragmentsStore.map((item) => `${item.id}:${item.label}`).join("|"),
|
||||
entriesSig: $entriesStore
|
||||
.map((item) => `${item.id}:${item.label}`)
|
||||
.join("|"),
|
||||
fragmentsSig: $fragmentsStore
|
||||
.map((item) => `${item.id}:${item.label}`)
|
||||
.join("|"),
|
||||
listsSig: $listsStore.map((item) => `${item.id}:${item.label}`).join("|"),
|
||||
todosSig: $todoListsStore
|
||||
.map((item) => {
|
||||
@ -701,9 +844,12 @@
|
||||
.join("~");
|
||||
return `${item.id}:${item.label}:${todos}`;
|
||||
})
|
||||
.join("|")
|
||||
.join("|"),
|
||||
});
|
||||
$: if (activeSection === "calendar" && calendarTimelineRefreshKey !== lastCalendarTimelineKey) {
|
||||
$: if (
|
||||
activeSection === "calendar" &&
|
||||
calendarTimelineRefreshKey !== lastCalendarTimelineKey
|
||||
) {
|
||||
lastCalendarTimelineKey = calendarTimelineRefreshKey;
|
||||
if (calendarTimelineDebounce) {
|
||||
clearTimeout(calendarTimelineDebounce);
|
||||
@ -726,7 +872,7 @@
|
||||
onCalendarStateChange({
|
||||
items: calendarTimelineItems,
|
||||
busy: calendarBusy,
|
||||
error: calendarError
|
||||
error: calendarError,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@ -736,19 +882,43 @@
|
||||
<h2>{panelTitle}</h2>
|
||||
<div class="panel-header-actions">
|
||||
{#if activeSection === "calendar"}
|
||||
<button type="button" class="panel-action" aria-label="Refresh calendar" title="Refresh calendar" on:click={handleRefreshClick}>
|
||||
<span class="material-symbols-outlined">refresh</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="panel-action"
|
||||
aria-label="Refresh calendar"
|
||||
title="Refresh calendar"
|
||||
on:click={handleRefreshClick}
|
||||
>
|
||||
<span class="material-symbols-outlined">refresh</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#if activeSection === "entries"}
|
||||
<button type="button" class="panel-action" aria-label="Add template" title="Add template" on:click={handleAddTemplate}>
|
||||
<span class="material-symbols-outlined">palette</span>
|
||||
</button>
|
||||
<button type="button" class="panel-action" aria-label="Add daily note" title="Add daily note" on:click={handleAddDailyNote}>
|
||||
<span class="material-symbols-outlined">calendar_month</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="panel-action"
|
||||
aria-label="Add template"
|
||||
title="Add template"
|
||||
on:click={handleAddTemplate}
|
||||
>
|
||||
<span class="material-symbols-outlined">palette</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="panel-action"
|
||||
aria-label="Add daily note"
|
||||
title="Add daily note"
|
||||
on:click={handleAddDailyNote}
|
||||
>
|
||||
<span class="material-symbols-outlined">calendar_month</span>
|
||||
</button>
|
||||
{/if}
|
||||
<button type="button" class="panel-action" aria-label="Add item" title="Add item" on:click={handleAddItem}>
|
||||
<button
|
||||
type="button"
|
||||
class="panel-action"
|
||||
aria-label="Add item"
|
||||
title="Add item"
|
||||
on:click={handleAddItem}
|
||||
>
|
||||
<span class="material-symbols-outlined">add</span>
|
||||
</button>
|
||||
</div>
|
||||
@ -799,12 +969,24 @@
|
||||
</div>
|
||||
|
||||
<div class="calendar-control-row">
|
||||
<input type="text" bind:value={calendarQuery} placeholder="Text query" />
|
||||
<input type="text" bind:value={calendarTags} placeholder="Tags (comma)" />
|
||||
<input
|
||||
type="text"
|
||||
bind:value={calendarQuery}
|
||||
placeholder="Text query"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={calendarTags}
|
||||
placeholder="Tags (comma)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="calendar-control-row">
|
||||
<input type="text" bind:value={calendarTypes} placeholder="Fragment types (comma)" />
|
||||
<input
|
||||
type="text"
|
||||
bind:value={calendarTypes}
|
||||
placeholder="Fragment types (comma)"
|
||||
/>
|
||||
<span></span>
|
||||
</div>
|
||||
|
||||
@ -812,9 +994,17 @@
|
||||
<h3>Saved Views</h3>
|
||||
<div class="saved-view-actions">
|
||||
{#each builtInViews as view}
|
||||
<button type="button" class="saved-view-btn" on:click={() => applySavedView(view)}>{view.name}</button>
|
||||
<button
|
||||
type="button"
|
||||
class="saved-view-btn"
|
||||
on:click={() => applySavedView(view)}>{view.name}</button
|
||||
>
|
||||
{/each}
|
||||
<button type="button" class="saved-view-btn" on:click={() => (showSaveViewInput = true)}>Save Current</button>
|
||||
<button
|
||||
type="button"
|
||||
class="saved-view-btn"
|
||||
on:click={() => (showSaveViewInput = true)}>Save Current</button
|
||||
>
|
||||
</div>
|
||||
{#if showSaveViewInput}
|
||||
<div class="saved-view-input">
|
||||
@ -837,8 +1027,17 @@
|
||||
<ul class="saved-view-list">
|
||||
{#each calendarSavedViews as view}
|
||||
<li>
|
||||
<button type="button" class="saved-view-btn" on:click={() => applySavedView(view)}>{view.name}</button>
|
||||
<button type="button" class="saved-view-delete" on:click={() => deleteSavedView(view.id)} aria-label="Delete saved view">
|
||||
<button
|
||||
type="button"
|
||||
class="saved-view-btn"
|
||||
on:click={() => applySavedView(view)}>{view.name}</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="saved-view-delete"
|
||||
on:click={() => deleteSavedView(view.id)}
|
||||
aria-label="Delete saved view"
|
||||
>
|
||||
<span class="material-symbols-outlined">delete</span>
|
||||
</button>
|
||||
</li>
|
||||
@ -856,12 +1055,17 @@
|
||||
|
||||
<div class="calendar-entries">
|
||||
<h3>{calendarMonthLabel} {calendarYear} Timeline</h3>
|
||||
<p class="section-copy">Last refreshed: {calendarLastRefreshedAt || "Not yet"}</p>
|
||||
<p class="section-copy">
|
||||
Last refreshed: {calendarLastRefreshedAt || "Not yet"}
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="panel-search">
|
||||
<span class="material-symbols-outlined">search</span>
|
||||
<input type="text" placeholder={`Search ${panelTitle.toLowerCase()}...`} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={`Search ${panelTitle.toLowerCase()}...`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if showNewItemInput}
|
||||
@ -900,10 +1104,20 @@
|
||||
{item.label}
|
||||
</button>
|
||||
<div class="item-actions">
|
||||
<button type="button" class="item-action" on:click|stopPropagation={() => onEditItem(item)} aria-label="Edit">
|
||||
<button
|
||||
type="button"
|
||||
class="item-action"
|
||||
on:click|stopPropagation={() => onEditItem(item)}
|
||||
aria-label="Edit"
|
||||
>
|
||||
<span class="material-symbols-outlined">edit</span>
|
||||
</button>
|
||||
<button type="button" class="item-action item-action-danger" on:click|stopPropagation={() => onDeleteItem(item)} aria-label="Delete">
|
||||
<button
|
||||
type="button"
|
||||
class="item-action item-action-danger"
|
||||
on:click|stopPropagation={() => onDeleteItem(item)}
|
||||
aria-label="Delete"
|
||||
>
|
||||
<span class="material-symbols-outlined">delete</span>
|
||||
</button>
|
||||
</div>
|
||||
@ -932,10 +1146,20 @@
|
||||
{item.label}
|
||||
</button>
|
||||
<div class="item-actions">
|
||||
<button type="button" class="item-action" on:click|stopPropagation={() => onEditItem(item)} aria-label="Edit">
|
||||
<button
|
||||
type="button"
|
||||
class="item-action"
|
||||
on:click|stopPropagation={() => onEditItem(item)}
|
||||
aria-label="Edit"
|
||||
>
|
||||
<span class="material-symbols-outlined">edit</span>
|
||||
</button>
|
||||
<button type="button" class="item-action item-action-danger" on:click|stopPropagation={() => onDeleteItem(item)} aria-label="Delete">
|
||||
<button
|
||||
type="button"
|
||||
class="item-action item-action-danger"
|
||||
on:click|stopPropagation={() => onDeleteItem(item)}
|
||||
aria-label="Delete"
|
||||
>
|
||||
<span class="material-symbols-outlined">delete</span>
|
||||
</button>
|
||||
</div>
|
||||
@ -963,10 +1187,20 @@
|
||||
</button>
|
||||
{#if showItemActions}
|
||||
<div class="item-actions">
|
||||
<button type="button" class="item-action" on:click|stopPropagation={() => onEditItem(item)} aria-label="Edit">
|
||||
<button
|
||||
type="button"
|
||||
class="item-action"
|
||||
on:click|stopPropagation={() => onEditItem(item)}
|
||||
aria-label="Edit"
|
||||
>
|
||||
<span class="material-symbols-outlined">edit</span>
|
||||
</button>
|
||||
<button type="button" class="item-action item-action-danger" on:click|stopPropagation={() => onDeleteItem(item)} aria-label="Delete">
|
||||
<button
|
||||
type="button"
|
||||
class="item-action item-action-danger"
|
||||
on:click|stopPropagation={() => onDeleteItem(item)}
|
||||
aria-label="Delete"
|
||||
>
|
||||
<span class="material-symbols-outlined">delete</span>
|
||||
</button>
|
||||
</div>
|
||||
@ -980,7 +1214,11 @@
|
||||
|
||||
<style>
|
||||
.side-panel {
|
||||
background: linear-gradient(180deg, var(--surface-2) 0%, var(--bg-panel) 100%);
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
var(--surface-2) 0%,
|
||||
var(--bg-panel) 100%
|
||||
);
|
||||
border-right: 1px solid var(--border-soft);
|
||||
padding: 16px 14px;
|
||||
display: flex;
|
||||
@ -1312,5 +1550,25 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.side-panel {
|
||||
padding: 12px 10px;
|
||||
}
|
||||
|
||||
.panel-list .item-actions {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.panel-list .item-action {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.calendar-control-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
<!-- @format -->
|
||||
<script lang="ts">
|
||||
import {
|
||||
createFragmentFromParsed,
|
||||
@ -7,7 +8,7 @@
|
||||
parseFragmentContent,
|
||||
serializeFragment,
|
||||
updateFragmentFromParsed,
|
||||
type FragmentItem
|
||||
type FragmentItem,
|
||||
} from "$lib/stores/fragments";
|
||||
import { settingsFragmentTypes, settingsTags } from "$lib/stores/settings";
|
||||
import { renderMarkdown } from "$lib/utils/markdown";
|
||||
@ -17,7 +18,11 @@
|
||||
export let openDocumentName = "";
|
||||
export let openDocumentContent = "";
|
||||
export let onDocumentContentChange: (content: string) => void = () => {};
|
||||
export let onOpenDocument: (doc: { id: string; label: string; initialContent: string }) => void = () => {};
|
||||
export let onOpenDocument: (doc: {
|
||||
id: string;
|
||||
label: string;
|
||||
initialContent: string;
|
||||
}) => void = () => {};
|
||||
export let onDeleteDocument: (id: string) => void = () => {};
|
||||
export let externalEditRequested = false;
|
||||
|
||||
@ -35,18 +40,30 @@
|
||||
const customTypeValue = "__custom_type__";
|
||||
const customTagValue = "__custom_tag__";
|
||||
|
||||
function buildFragmentContent(): { title: string; resolvedType: string; body: string; content: string; tags: string[] } | null {
|
||||
function buildFragmentContent(): {
|
||||
title: string;
|
||||
resolvedType: string;
|
||||
body: string;
|
||||
content: string;
|
||||
tags: string[];
|
||||
} | null {
|
||||
const title = fragmentTitle.trim();
|
||||
if (!title) return null;
|
||||
const resolvedType = fragmentType === customTypeValue ? customFragmentType.trim() : fragmentType;
|
||||
const resolvedType =
|
||||
fragmentType === customTypeValue
|
||||
? customFragmentType.trim()
|
||||
: fragmentType;
|
||||
if (!resolvedType) return null;
|
||||
const selectedTags = fragmentTag && fragmentTag !== customTagValue ? [fragmentTag] : [];
|
||||
const selectedTags =
|
||||
fragmentTag && fragmentTag !== customTagValue ? [fragmentTag] : [];
|
||||
const customTags = customFragmentTags
|
||||
.split(",")
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean);
|
||||
const tagList = [...selectedTags, ...customTags];
|
||||
const uniqueTagList = [...new Set(tagList.map((tag) => tag.toLowerCase()))].map((lower) => {
|
||||
const uniqueTagList = [
|
||||
...new Set(tagList.map((tag) => tag.toLowerCase())),
|
||||
].map((lower) => {
|
||||
return tagList.find((tag) => tag.toLowerCase() === lower) ?? lower;
|
||||
});
|
||||
const body = fragmentBody.trim() || "Add details for this fragment.";
|
||||
@ -54,7 +71,7 @@
|
||||
title,
|
||||
type: resolvedType,
|
||||
tags: uniqueTagList,
|
||||
body
|
||||
body,
|
||||
});
|
||||
return { title, resolvedType, body, content, tags: uniqueTagList };
|
||||
}
|
||||
@ -73,7 +90,7 @@
|
||||
title: payload.title,
|
||||
type: payload.resolvedType,
|
||||
tags: payload.tags,
|
||||
body: payload.body
|
||||
body: payload.body,
|
||||
});
|
||||
if (!updated) return;
|
||||
|
||||
@ -92,7 +109,7 @@
|
||||
title: payload.title,
|
||||
type: payload.resolvedType,
|
||||
tags: payload.tags,
|
||||
body: payload.body
|
||||
body: payload.body,
|
||||
});
|
||||
onOpenDocument(item);
|
||||
fragmentMode = "view";
|
||||
@ -141,7 +158,10 @@
|
||||
function loadFragmentFormFromDocument() {
|
||||
const content = openDocumentContent ?? "";
|
||||
const isDraftFragment = openDocumentId.startsWith("fragments/new-");
|
||||
const parsed = parseFragmentContent(content, openDocumentName || "Untitled Fragment");
|
||||
const parsed = parseFragmentContent(
|
||||
content,
|
||||
openDocumentName || "Untitled Fragment",
|
||||
);
|
||||
fragmentTitle = parsed.title;
|
||||
const parsedType = parsed.type;
|
||||
if (!parsedType) {
|
||||
@ -175,22 +195,35 @@
|
||||
fragmentMode = isDraftFragment ? "create" : "view";
|
||||
}
|
||||
|
||||
$: fragmentTypeOptions = $settingsFragmentTypes.length ? $settingsFragmentTypes : ["General"];
|
||||
$: fragmentTypeOptions = $settingsFragmentTypes.length
|
||||
? $settingsFragmentTypes
|
||||
: ["General"];
|
||||
$: tagOptions = $settingsTags;
|
||||
$: if (openDocumentId !== lastFragmentDocumentId) {
|
||||
loadFragmentFormFromDocument();
|
||||
lastFragmentDocumentId = openDocumentId;
|
||||
}
|
||||
$: if (!fragmentType || (!fragmentTypeOptions.includes(fragmentType) && fragmentType !== customTypeValue)) {
|
||||
$: if (
|
||||
!fragmentType ||
|
||||
(!fragmentTypeOptions.includes(fragmentType) &&
|
||||
fragmentType !== customTypeValue)
|
||||
) {
|
||||
fragmentType = fragmentTypeOptions[0];
|
||||
}
|
||||
$: if (!fragmentTag || (!tagOptions.includes(fragmentTag) && fragmentTag !== customTagValue)) {
|
||||
$: if (
|
||||
!fragmentTag ||
|
||||
(!tagOptions.includes(fragmentTag) && fragmentTag !== customTagValue)
|
||||
) {
|
||||
fragmentTag = tagOptions[0] ?? customTagValue;
|
||||
}
|
||||
$: if (!externalEditRequested) {
|
||||
suppressExternalEditRequest = false;
|
||||
}
|
||||
$: if (externalEditRequested && !suppressExternalEditRequest && fragmentMode === "view") {
|
||||
$: if (
|
||||
externalEditRequested &&
|
||||
!suppressExternalEditRequest &&
|
||||
fragmentMode === "view"
|
||||
) {
|
||||
fragmentMode = "edit";
|
||||
}
|
||||
</script>
|
||||
@ -203,10 +236,17 @@
|
||||
{:else}
|
||||
<form
|
||||
class="fragment-form"
|
||||
on:submit|preventDefault={fragmentMode === "create" ? createNewFragment : saveFragmentEdits}
|
||||
on:submit|preventDefault={fragmentMode === "create"
|
||||
? createNewFragment
|
||||
: saveFragmentEdits}
|
||||
>
|
||||
<h2>{fragmentMode === "create" ? "New Fragment" : "Edit Fragment"}</h2>
|
||||
<input type="text" placeholder="Title" bind:value={fragmentTitle} aria-label="Fragment title" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Title"
|
||||
bind:value={fragmentTitle}
|
||||
aria-label="Fragment title"
|
||||
/>
|
||||
<div class="fragment-form-row">
|
||||
<select bind:value={fragmentType} aria-label="Fragment type">
|
||||
{#each fragmentTypeOptions as type}
|
||||
@ -222,7 +262,12 @@
|
||||
aria-label="Custom fragment type"
|
||||
/>
|
||||
{:else}
|
||||
<input type="text" value={fragmentType} disabled aria-label="Selected fragment type" />
|
||||
<input
|
||||
type="text"
|
||||
value={fragmentType}
|
||||
disabled
|
||||
aria-label="Selected fragment type"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="fragment-form-row">
|
||||
@ -252,8 +297,16 @@
|
||||
aria-label="Fragment body"
|
||||
></textarea>
|
||||
<div class="fragment-actions">
|
||||
<button type="submit" class="fragment-submit">{fragmentMode === "create" ? "Create Fragment" : "Save Fragment"}</button>
|
||||
<button type="button" class="fragment-secondary" on:click={cancelFragmentEdit}>Cancel</button>
|
||||
<button type="submit" class="fragment-submit"
|
||||
>{fragmentMode === "create"
|
||||
? "Create Fragment"
|
||||
: "Save Fragment"}</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="fragment-secondary"
|
||||
on:click={cancelFragmentEdit}>Cancel</button
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
@ -292,7 +345,9 @@
|
||||
color: var(--text-primary);
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.65;
|
||||
font-family: "Segoe UI Variable", "Segoe UI", "Segoe UI Emoji", "Apple Color Emoji", "Noto Color Emoji", Inter, Roboto, Helvetica, Arial, sans-serif;
|
||||
font-family:
|
||||
"Segoe UI Variable", "Segoe UI", "Segoe UI Emoji", "Apple Color Emoji",
|
||||
"Noto Color Emoji", Inter, Roboto, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
.fragment-view :global(h1),
|
||||
@ -334,11 +389,17 @@
|
||||
width: 100%;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 8px;
|
||||
background-color: color-mix(in srgb, var(--surface-1) 88%, var(--bg-editor) 12%);
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--surface-1) 88%,
|
||||
var(--bg-editor) 12%
|
||||
);
|
||||
color: var(--text-primary);
|
||||
padding: 10px 11px;
|
||||
font-size: 0.88rem;
|
||||
font-family: "Segoe UI Variable", "Segoe UI", "Segoe UI Emoji", "Apple Color Emoji", "Noto Color Emoji", Inter, Roboto, Helvetica, Arial, sans-serif;
|
||||
font-family:
|
||||
"Segoe UI Variable", "Segoe UI", "Segoe UI Emoji", "Apple Color Emoji",
|
||||
"Noto Color Emoji", Inter, Roboto, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
.fragment-form textarea {
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
<!-- @format -->
|
||||
<script lang="ts">
|
||||
export let openDocumentId = "";
|
||||
export let openDocumentName = "";
|
||||
@ -134,14 +135,30 @@
|
||||
}}
|
||||
/>
|
||||
<div class="list-actions">
|
||||
<button type="button" class="list-btn save" on:click={saveEditItem}>Save</button>
|
||||
<button type="button" class="list-btn ghost" on:click={cancelEditItem}>Cancel</button>
|
||||
<button
|
||||
type="button"
|
||||
class="list-btn save"
|
||||
on:click={saveEditItem}>Save</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="list-btn ghost"
|
||||
on:click={cancelEditItem}>Cancel</button
|
||||
>
|
||||
</div>
|
||||
{:else}
|
||||
<span class="list-text">{item.text}</span>
|
||||
<div class="list-actions">
|
||||
<button type="button" class="list-btn ghost" on:click={() => startEditItem(item.id)}>Edit</button>
|
||||
<button type="button" class="list-btn danger" on:click={() => removeItem(item.id)}>Remove</button>
|
||||
<button
|
||||
type="button"
|
||||
class="list-btn ghost"
|
||||
on:click={() => startEditItem(item.id)}>Edit</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="list-btn danger"
|
||||
on:click={() => removeItem(item.id)}>Remove</button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</li>
|
||||
@ -266,6 +283,14 @@
|
||||
padding: 18px 16px;
|
||||
}
|
||||
|
||||
.list-create {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.list-add-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.list-item {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
row-gap: 8px;
|
||||
|
||||
@ -1,10 +1,19 @@
|
||||
<!-- @format -->
|
||||
<script lang="ts">
|
||||
import { listEntryTemplates, loadEntryTemplate, type EntryTemplateItemDto } from "$lib/backend/templates";
|
||||
import {
|
||||
listEntryTemplates,
|
||||
loadEntryTemplate,
|
||||
type EntryTemplateItemDto,
|
||||
} from "$lib/backend/templates";
|
||||
import MarkdownToolbar from "$lib/components/editor/MarkdownToolbar.svelte";
|
||||
import { entriesStore } from "$lib/stores/entries";
|
||||
import { fragmentsStore } from "$lib/stores/fragments";
|
||||
import { listsStore } from "$lib/stores/lists";
|
||||
import { serializeTodoList, todoListsStore, todosStore } from "$lib/stores/todos";
|
||||
import {
|
||||
serializeTodoList,
|
||||
todoListsStore,
|
||||
todosStore,
|
||||
} from "$lib/stores/todos";
|
||||
import { renderMarkdown, extractEditorTitle } from "$lib/utils/markdown";
|
||||
import { onMount } from "svelte";
|
||||
import { get } from "svelte/store";
|
||||
@ -95,14 +104,20 @@
|
||||
applyWrap("[", "](https://example.com)");
|
||||
}
|
||||
|
||||
function lineMatchesListMode(line: string, mode: Exclude<ListMode, null>): boolean {
|
||||
function lineMatchesListMode(
|
||||
line: string,
|
||||
mode: Exclude<ListMode, null>,
|
||||
): boolean {
|
||||
if (mode === "ul") {
|
||||
return /^\s*[-*+]\s/.test(line);
|
||||
}
|
||||
return /^\s*\d+\.\s/.test(line);
|
||||
}
|
||||
|
||||
function isMarkerOnlyLine(line: string, mode: Exclude<ListMode, null>): boolean {
|
||||
function isMarkerOnlyLine(
|
||||
line: string,
|
||||
mode: Exclude<ListMode, null>,
|
||||
): boolean {
|
||||
if (mode === "ul") {
|
||||
return /^\s*[-*+]\s$/.test(line);
|
||||
}
|
||||
@ -178,7 +193,10 @@
|
||||
return value.replace(/]/g, "\\]");
|
||||
}
|
||||
|
||||
function appendToAttachmentsSection(lineToAppend: string, attachmentId: string) {
|
||||
function appendToAttachmentsSection(
|
||||
lineToAppend: string,
|
||||
attachmentId: string,
|
||||
) {
|
||||
const current = markdownText;
|
||||
const normalized = current.replace(/\r\n/g, "\n");
|
||||
if (normalized.includes(`(journal:${attachmentId})`)) {
|
||||
@ -196,18 +214,25 @@
|
||||
const headerStart = headerMatch.index;
|
||||
const headerEnd = headerStart + headerMatch[0].length;
|
||||
const bodyStart = normalized.indexOf("\n", headerEnd);
|
||||
const sectionBodyStart = bodyStart === -1 ? normalized.length : bodyStart + 1;
|
||||
const sectionBodyStart =
|
||||
bodyStart === -1 ? normalized.length : bodyStart + 1;
|
||||
const nextHeaderMatch = /^##\s+/m.exec(normalized.slice(sectionBodyStart));
|
||||
const sectionEnd = nextHeaderMatch ? sectionBodyStart + nextHeaderMatch.index : normalized.length;
|
||||
const sectionEnd = nextHeaderMatch
|
||||
? sectionBodyStart + nextHeaderMatch.index
|
||||
: normalized.length;
|
||||
|
||||
const sectionBody = normalized.slice(sectionBodyStart, sectionEnd);
|
||||
const bodyPrefix = sectionBody.length > 0 && !sectionBody.endsWith("\n") ? "\n" : "";
|
||||
const bodyPrefix =
|
||||
sectionBody.length > 0 && !sectionBody.endsWith("\n") ? "\n" : "";
|
||||
const insertion = `${bodyPrefix}${lineToAppend}\n`;
|
||||
const next = `${normalized.slice(0, sectionEnd)}${insertion}${normalized.slice(sectionEnd)}`;
|
||||
updateDraft(next);
|
||||
}
|
||||
|
||||
function attachReference(kind: "Fragment" | "List" | "To-Do", option: AttachmentOption) {
|
||||
function attachReference(
|
||||
kind: "Fragment" | "List" | "To-Do",
|
||||
option: AttachmentOption,
|
||||
) {
|
||||
const label = escapeMarkdownLinkText(option.label.trim() || `${kind} Item`);
|
||||
const line = `- ${kind}: [${label}](journal:${option.id})`;
|
||||
appendToAttachmentsSection(line, option.id);
|
||||
@ -221,7 +246,10 @@
|
||||
attachmentModalOpen = false;
|
||||
}
|
||||
|
||||
function attachFromModal(kind: "Fragment" | "List" | "To-Do", option: AttachmentOption) {
|
||||
function attachFromModal(
|
||||
kind: "Fragment" | "List" | "To-Do",
|
||||
option: AttachmentOption,
|
||||
) {
|
||||
attachReference(kind, option);
|
||||
attachmentModalOpen = false;
|
||||
}
|
||||
@ -243,19 +271,29 @@
|
||||
});
|
||||
}
|
||||
|
||||
function resolveJournalLinkTarget(targetId: string): { id: string; label: string; initialContent: string } | null {
|
||||
function resolveJournalLinkTarget(
|
||||
targetId: string,
|
||||
): { id: string; label: string; initialContent: string } | null {
|
||||
if (!targetId) return null;
|
||||
|
||||
if (targetId.startsWith("fragments/")) {
|
||||
const fragment = get(fragmentsStore).find((item) => item.id === targetId);
|
||||
if (!fragment) return null;
|
||||
return { id: fragment.id, label: fragment.label, initialContent: fragment.initialContent };
|
||||
return {
|
||||
id: fragment.id,
|
||||
label: fragment.label,
|
||||
initialContent: fragment.initialContent,
|
||||
};
|
||||
}
|
||||
|
||||
if (targetId.startsWith("lists/")) {
|
||||
const list = get(listsStore).find((item) => item.id === targetId);
|
||||
if (!list) return null;
|
||||
return { id: list.id, label: list.label, initialContent: list.initialContent };
|
||||
return {
|
||||
id: list.id,
|
||||
label: list.label,
|
||||
initialContent: list.initialContent,
|
||||
};
|
||||
}
|
||||
|
||||
if (targetId.startsWith("todos/")) {
|
||||
@ -265,14 +303,18 @@
|
||||
return {
|
||||
id: todoList.id,
|
||||
label: todoList.label,
|
||||
initialContent: serializeTodoList(todoList.label, todoItems)
|
||||
initialContent: serializeTodoList(todoList.label, todoItems),
|
||||
};
|
||||
}
|
||||
|
||||
if (targetId.startsWith("entries/")) {
|
||||
const entry = get(entriesStore).find((item) => item.id === targetId);
|
||||
if (!entry) return null;
|
||||
return { id: entry.id, label: entry.label, initialContent: entry.initialContent };
|
||||
return {
|
||||
id: entry.id,
|
||||
label: entry.label,
|
||||
initialContent: entry.initialContent,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
@ -295,8 +337,8 @@
|
||||
id: openDocumentId,
|
||||
label: openDocumentName,
|
||||
initialContent: openDocumentContent,
|
||||
section: "entries"
|
||||
}
|
||||
section: "entries",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -307,7 +349,7 @@
|
||||
return {
|
||||
destroy() {
|
||||
node.removeEventListener("click", onClick);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -427,7 +469,9 @@
|
||||
lastOpenDocumentId = openDocumentId;
|
||||
listMode = null;
|
||||
}
|
||||
$: isEntryDocument = openDocumentId.startsWith("entries/file/") || openDocumentId.startsWith("entries/draft-");
|
||||
$: isEntryDocument =
|
||||
openDocumentId.startsWith("entries/file/") ||
|
||||
openDocumentId.startsWith("entries/draft-");
|
||||
$: fragmentAttachmentOptions = $fragmentsStore
|
||||
.filter((item) => item.id && item.label)
|
||||
.map((item) => ({ id: item.id, label: item.label }));
|
||||
@ -452,7 +496,10 @@
|
||||
{templatesBusy}
|
||||
{templateOptions}
|
||||
{listMode}
|
||||
attachmentsDisabled={fragmentAttachmentOptions.length + listAttachmentOptions.length + todoAttachmentOptions.length === 0}
|
||||
attachmentsDisabled={fragmentAttachmentOptions.length +
|
||||
listAttachmentOptions.length +
|
||||
todoAttachmentOptions.length ===
|
||||
0}
|
||||
onApplyHeading={applyHeading}
|
||||
onApplyTemplate={(filePath) => void applyTemplateByPath(filePath)}
|
||||
onOpenAttachments={openAttachmentModal}
|
||||
@ -477,16 +524,30 @@
|
||||
|
||||
{#if attachmentModalOpen}
|
||||
<div class="attachment-modal-backdrop" role="presentation">
|
||||
<div class="attachment-modal" role="dialog" aria-modal="true" aria-label="Attach item">
|
||||
<div
|
||||
class="attachment-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Attach item"
|
||||
>
|
||||
<header class="attachment-modal-header">
|
||||
<h2>Attach Item</h2>
|
||||
<button type="button" class="attachment-modal-close" on:click={closeAttachmentModal} aria-label="Close attach dialog">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">close</span>
|
||||
<button
|
||||
type="button"
|
||||
class="attachment-modal-close"
|
||||
on:click={closeAttachmentModal}
|
||||
aria-label="Close attach dialog"
|
||||
>
|
||||
<span class="material-symbols-outlined" aria-hidden="true"
|
||||
>close</span
|
||||
>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{#if fragmentAttachmentOptions.length + listAttachmentOptions.length + todoAttachmentOptions.length === 0}
|
||||
<p class="attachment-empty">No fragments, lists, or to-do lists are available to attach.</p>
|
||||
<p class="attachment-empty">
|
||||
No fragments, lists, or to-do lists are available to attach.
|
||||
</p>
|
||||
{:else}
|
||||
{#if fragmentAttachmentOptions.length > 0}
|
||||
<div class="attachment-group">
|
||||
@ -496,7 +557,9 @@
|
||||
aria-label="Attach fragment"
|
||||
on:change={(event) => {
|
||||
const target = event.currentTarget as HTMLSelectElement;
|
||||
const selected = fragmentAttachmentOptions.find((option) => option.id === target.value);
|
||||
const selected = fragmentAttachmentOptions.find(
|
||||
(option) => option.id === target.value,
|
||||
);
|
||||
if (selected) attachFromModal("Fragment", selected);
|
||||
target.value = "";
|
||||
}}
|
||||
@ -517,7 +580,9 @@
|
||||
aria-label="Attach list"
|
||||
on:change={(event) => {
|
||||
const target = event.currentTarget as HTMLSelectElement;
|
||||
const selected = listAttachmentOptions.find((option) => option.id === target.value);
|
||||
const selected = listAttachmentOptions.find(
|
||||
(option) => option.id === target.value,
|
||||
);
|
||||
if (selected) attachFromModal("List", selected);
|
||||
target.value = "";
|
||||
}}
|
||||
@ -538,7 +603,9 @@
|
||||
aria-label="Attach to-do list"
|
||||
on:change={(event) => {
|
||||
const target = event.currentTarget as HTMLSelectElement;
|
||||
const selected = todoAttachmentOptions.find((option) => option.id === target.value);
|
||||
const selected = todoAttachmentOptions.find(
|
||||
(option) => option.id === target.value,
|
||||
);
|
||||
if (selected) attachFromModal("To-Do", selected);
|
||||
target.value = "";
|
||||
}}
|
||||
@ -557,7 +624,11 @@
|
||||
|
||||
<div class="editor-workspace">
|
||||
{#if previewOnly}
|
||||
<article class="markdown-preview" aria-label="Markdown preview" use:interceptJournalLinks>
|
||||
<article
|
||||
class="markdown-preview"
|
||||
aria-label="Markdown preview"
|
||||
use:interceptJournalLinks
|
||||
>
|
||||
{@html renderedHtml}
|
||||
</article>
|
||||
{:else}
|
||||
@ -679,7 +750,11 @@
|
||||
width: 100%;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 8px;
|
||||
background-color: color-mix(in srgb, var(--surface-2) 90%, var(--bg-editor) 10%);
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--surface-2) 90%,
|
||||
var(--bg-editor) 10%
|
||||
);
|
||||
color: var(--text-primary);
|
||||
padding: 8px 34px 8px 10px;
|
||||
font-size: 0.82rem;
|
||||
@ -720,7 +795,9 @@
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.65;
|
||||
overflow: visible;
|
||||
font-family: "Segoe UI Variable", "Segoe UI", "Segoe UI Emoji", "Apple Color Emoji", "Noto Color Emoji", Inter, Roboto, Helvetica, Arial, sans-serif;
|
||||
font-family:
|
||||
"Segoe UI Variable", "Segoe UI", "Segoe UI Emoji", "Apple Color Emoji",
|
||||
"Noto Color Emoji", Inter, Roboto, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
.markdown-input {
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
<!-- @format -->
|
||||
<script lang="ts">
|
||||
import type { EntryTemplateItemDto } from "$lib/backend/templates";
|
||||
type AttachmentOption = { id: string; label: string };
|
||||
@ -61,7 +62,11 @@
|
||||
|
||||
<div class="editor-toolbar">
|
||||
<div class="toolbar-group">
|
||||
<div class="toolbar-select-wrap heading-wrap" bind:this={headingMenuEl} on:focusout={handleHeadingFocusOut}>
|
||||
<div
|
||||
class="toolbar-select-wrap heading-wrap"
|
||||
bind:this={headingMenuEl}
|
||||
on:focusout={handleHeadingFocusOut}
|
||||
>
|
||||
<span class="material-symbols-outlined" aria-hidden="true">title</span>
|
||||
<button
|
||||
type="button"
|
||||
@ -72,22 +77,54 @@
|
||||
aria-expanded={headingMenuOpen}
|
||||
on:click={toggleHeadingMenu}
|
||||
>
|
||||
<span class="material-symbols-outlined" aria-hidden="true">expand_more</span>
|
||||
<span class="material-symbols-outlined" aria-hidden="true"
|
||||
>expand_more</span
|
||||
>
|
||||
</button>
|
||||
{#if headingMenuOpen}
|
||||
<div class="heading-menu" role="listbox" aria-label="Header size">
|
||||
<button type="button" class="heading-option" on:click={() => selectHeading(1)}>H1</button>
|
||||
<button type="button" class="heading-option" on:click={() => selectHeading(2)}>H2</button>
|
||||
<button type="button" class="heading-option" on:click={() => selectHeading(3)}>H3</button>
|
||||
<button type="button" class="heading-option" on:click={() => selectHeading(4)}>H4</button>
|
||||
<button type="button" class="heading-option" on:click={() => selectHeading(5)}>H5</button>
|
||||
<button type="button" class="heading-option" on:click={() => selectHeading(6)}>H6</button>
|
||||
<button
|
||||
type="button"
|
||||
class="heading-option"
|
||||
on:click={() => selectHeading(1)}>H1</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="heading-option"
|
||||
on:click={() => selectHeading(2)}>H2</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="heading-option"
|
||||
on:click={() => selectHeading(3)}>H3</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="heading-option"
|
||||
on:click={() => selectHeading(4)}>H4</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="heading-option"
|
||||
on:click={() => selectHeading(5)}>H5</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="heading-option"
|
||||
on:click={() => selectHeading(6)}>H6</button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if isEntryDocument}
|
||||
<div class="toolbar-select-wrap template-wrap" bind:this={templateMenuEl} on:focusout={handleTemplateFocusOut}>
|
||||
<span class="material-symbols-outlined" aria-hidden="true">description</span>
|
||||
<div
|
||||
class="toolbar-select-wrap template-wrap"
|
||||
bind:this={templateMenuEl}
|
||||
on:focusout={handleTemplateFocusOut}
|
||||
>
|
||||
<span class="material-symbols-outlined" aria-hidden="true"
|
||||
>description</span
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="template-trigger"
|
||||
@ -98,8 +135,12 @@
|
||||
disabled={templatesBusy}
|
||||
on:click={toggleTemplateMenu}
|
||||
>
|
||||
<span class="template-trigger-text">{templatesBusy ? "Loading..." : "Template"}</span>
|
||||
<span class="material-symbols-outlined" aria-hidden="true">expand_more</span>
|
||||
<span class="template-trigger-text"
|
||||
>{templatesBusy ? "Loading..." : "Template"}</span
|
||||
>
|
||||
<span class="material-symbols-outlined" aria-hidden="true"
|
||||
>expand_more</span
|
||||
>
|
||||
</button>
|
||||
{#if templateMenuOpen}
|
||||
<div class="template-menu" role="listbox" aria-label="Template">
|
||||
@ -134,19 +175,55 @@
|
||||
</div>
|
||||
<div class="toolbar-divider" aria-hidden="true"></div>
|
||||
<div class="toolbar-group">
|
||||
<button type="button" class="toolbar-btn toolbar-icon-btn" on:click={onBold} aria-label="Bold" title="Bold">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">format_bold</span>
|
||||
<button
|
||||
type="button"
|
||||
class="toolbar-btn toolbar-icon-btn"
|
||||
on:click={onBold}
|
||||
aria-label="Bold"
|
||||
title="Bold"
|
||||
>
|
||||
<span class="material-symbols-outlined" aria-hidden="true"
|
||||
>format_bold</span
|
||||
>
|
||||
</button>
|
||||
<button type="button" class="toolbar-btn toolbar-icon-btn" on:click={onItalic} aria-label="Italic" title="Italic">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">format_italic</span>
|
||||
<button
|
||||
type="button"
|
||||
class="toolbar-btn toolbar-icon-btn"
|
||||
on:click={onItalic}
|
||||
aria-label="Italic"
|
||||
title="Italic"
|
||||
>
|
||||
<span class="material-symbols-outlined" aria-hidden="true"
|
||||
>format_italic</span
|
||||
>
|
||||
</button>
|
||||
<button type="button" class="toolbar-btn toolbar-icon-btn" on:click={onUnderline} aria-label="Underline" title="Underline">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">format_underlined</span>
|
||||
<button
|
||||
type="button"
|
||||
class="toolbar-btn toolbar-icon-btn"
|
||||
on:click={onUnderline}
|
||||
aria-label="Underline"
|
||||
title="Underline"
|
||||
>
|
||||
<span class="material-symbols-outlined" aria-hidden="true"
|
||||
>format_underlined</span
|
||||
>
|
||||
</button>
|
||||
<button type="button" class="toolbar-btn toolbar-icon-btn" on:click={onTag} aria-label="Tag" title="Tag [[...]]">
|
||||
<button
|
||||
type="button"
|
||||
class="toolbar-btn toolbar-icon-btn"
|
||||
on:click={onTag}
|
||||
aria-label="Tag"
|
||||
title="Tag [[...]]"
|
||||
>
|
||||
<span class="material-symbols-outlined" aria-hidden="true">sell</span>
|
||||
</button>
|
||||
<button type="button" class="toolbar-btn toolbar-icon-btn" on:click={onLink} aria-label="Link" title="Link">
|
||||
<button
|
||||
type="button"
|
||||
class="toolbar-btn toolbar-icon-btn"
|
||||
on:click={onLink}
|
||||
aria-label="Link"
|
||||
title="Link"
|
||||
>
|
||||
<span class="material-symbols-outlined" aria-hidden="true">link</span>
|
||||
</button>
|
||||
<button
|
||||
@ -157,7 +234,9 @@
|
||||
aria-label="Bulleted list"
|
||||
title="Bulleted list"
|
||||
>
|
||||
<span class="material-symbols-outlined" aria-hidden="true">format_list_bulleted</span>
|
||||
<span class="material-symbols-outlined" aria-hidden="true"
|
||||
>format_list_bulleted</span
|
||||
>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@ -167,9 +246,17 @@
|
||||
aria-label="Numbered list"
|
||||
title="Numbered list"
|
||||
>
|
||||
<span class="material-symbols-outlined" aria-hidden="true">format_list_numbered</span>
|
||||
<span class="material-symbols-outlined" aria-hidden="true"
|
||||
>format_list_numbered</span
|
||||
>
|
||||
</button>
|
||||
<button type="button" class="toolbar-btn toolbar-icon-btn" on:click={onCode} aria-label="Inline code" title="Inline code">
|
||||
<button
|
||||
type="button"
|
||||
class="toolbar-btn toolbar-icon-btn"
|
||||
on:click={onCode}
|
||||
aria-label="Inline code"
|
||||
title="Inline code"
|
||||
>
|
||||
<span class="material-symbols-outlined" aria-hidden="true">code</span>
|
||||
</button>
|
||||
</div>
|
||||
@ -245,7 +332,11 @@
|
||||
letter-spacing: 0.01em;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background-color 120ms ease, color 120ms ease, border-color 120ms ease, transform 120ms ease;
|
||||
transition:
|
||||
background-color 120ms ease,
|
||||
color 120ms ease,
|
||||
border-color 120ms ease,
|
||||
transform 120ms ease;
|
||||
}
|
||||
|
||||
.toolbar-icon-btn {
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
<!-- @format -->
|
||||
<script lang="ts">
|
||||
import {
|
||||
addTodoItem,
|
||||
@ -12,7 +13,7 @@
|
||||
todosStore,
|
||||
type TodoItem,
|
||||
updateTodoItemText,
|
||||
updateTodoItemTextBackend
|
||||
updateTodoItemTextBackend,
|
||||
} from "$lib/stores/todos";
|
||||
import { get } from "svelte/store";
|
||||
|
||||
@ -45,7 +46,9 @@
|
||||
if (!ok) {
|
||||
todoItems = toggleTodoItem(todoItems, id);
|
||||
} else {
|
||||
todoItems = todoItems.map((t) => (t.id === id ? { ...t, done: !t.done } : t));
|
||||
todoItems = todoItems.map((t) =>
|
||||
t.id === id ? { ...t, done: !t.done } : t,
|
||||
);
|
||||
}
|
||||
persistTodosForCurrentList();
|
||||
}
|
||||
@ -68,7 +71,9 @@
|
||||
if (!ok) {
|
||||
todoItems = updateTodoItemText(todoItems, id, text);
|
||||
} else {
|
||||
todoItems = todoItems.map((t) => (t.id === id ? { ...t, text: text.trim() } : t));
|
||||
todoItems = todoItems.map((t) =>
|
||||
t.id === id ? { ...t, text: text.trim() } : t,
|
||||
);
|
||||
}
|
||||
persistTodosForCurrentList();
|
||||
}
|
||||
@ -138,7 +143,11 @@
|
||||
{#each todoItems as todo}
|
||||
<li class="todo-item">
|
||||
<label class="todo-check">
|
||||
<input type="checkbox" checked={todo.done} on:change={() => toggleTodoDone(todo.id)} />
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={todo.done}
|
||||
on:change={() => toggleTodoDone(todo.id)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{#if editingTodoId === todo.id}
|
||||
@ -152,14 +161,30 @@
|
||||
}}
|
||||
/>
|
||||
<div class="todo-actions">
|
||||
<button type="button" class="todo-btn save" on:click={saveEditTodo}>Save</button>
|
||||
<button type="button" class="todo-btn ghost" on:click={cancelEditTodo}>Cancel</button>
|
||||
<button
|
||||
type="button"
|
||||
class="todo-btn save"
|
||||
on:click={saveEditTodo}>Save</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="todo-btn ghost"
|
||||
on:click={cancelEditTodo}>Cancel</button
|
||||
>
|
||||
</div>
|
||||
{:else}
|
||||
<span class="todo-text" class:is-done={todo.done}>{todo.text}</span>
|
||||
<div class="todo-actions">
|
||||
<button type="button" class="todo-btn ghost" on:click={() => startEditTodo(todo.id)}>Edit</button>
|
||||
<button type="button" class="todo-btn danger" on:click={() => removeTodo(todo.id)}>Remove</button>
|
||||
<button
|
||||
type="button"
|
||||
class="todo-btn ghost"
|
||||
on:click={() => startEditTodo(todo.id)}>Edit</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="todo-btn danger"
|
||||
on:click={() => removeTodo(todo.id)}>Remove</button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</li>
|
||||
@ -293,6 +318,14 @@
|
||||
padding: 18px 16px;
|
||||
}
|
||||
|
||||
.todo-create {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.todo-add-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.todo-item {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
row-gap: 8px;
|
||||
|
||||
@ -32,7 +32,10 @@ export function isTauriRuntime(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Object.prototype.hasOwnProperty.call(window as WindowWithTauri, "__TAURI_INTERNALS__");
|
||||
return Object.prototype.hasOwnProperty.call(
|
||||
window as WindowWithTauri,
|
||||
"__TAURI_INTERNALS__",
|
||||
);
|
||||
}
|
||||
|
||||
function readUiSettingsFromLocalStorage(): UiSettingsPayload {
|
||||
@ -49,8 +52,13 @@ function readUiSettingsFromLocalStorage(): UiSettingsPayload {
|
||||
const parsed = JSON.parse(raw) as UiSettingsPayload;
|
||||
return {
|
||||
tags: Array.isArray(parsed.tags) ? parsed.tags : undefined,
|
||||
fragmentTypes: Array.isArray(parsed.fragmentTypes) ? parsed.fragmentTypes : undefined,
|
||||
defaultStartupView: typeof parsed.defaultStartupView === "string" ? parsed.defaultStartupView : undefined
|
||||
fragmentTypes: Array.isArray(parsed.fragmentTypes)
|
||||
? parsed.fragmentTypes
|
||||
: undefined,
|
||||
defaultStartupView:
|
||||
typeof parsed.defaultStartupView === "string"
|
||||
? parsed.defaultStartupView
|
||||
: undefined,
|
||||
};
|
||||
} catch {
|
||||
return {};
|
||||
@ -64,21 +72,30 @@ function writeUiSettingsToLocalStorage(payload: UiSettingsPayload): void {
|
||||
|
||||
const safePayload: UiSettingsPayload = {
|
||||
tags: Array.isArray(payload.tags) ? payload.tags : undefined,
|
||||
fragmentTypes: Array.isArray(payload.fragmentTypes) ? payload.fragmentTypes : undefined,
|
||||
defaultStartupView: typeof payload.defaultStartupView === "string" ? payload.defaultStartupView : undefined
|
||||
fragmentTypes: Array.isArray(payload.fragmentTypes)
|
||||
? payload.fragmentTypes
|
||||
: undefined,
|
||||
defaultStartupView:
|
||||
typeof payload.defaultStartupView === "string"
|
||||
? payload.defaultStartupView
|
||||
: undefined,
|
||||
};
|
||||
|
||||
window.localStorage.setItem(UI_SETTINGS_KEY, JSON.stringify(safePayload));
|
||||
}
|
||||
|
||||
async function fetchJson<T>(path: string, init: RequestInit = {}, options: FetchJsonOptions = {}): Promise<T> {
|
||||
async function fetchJson<T>(
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
options: FetchJsonOptions = {},
|
||||
): Promise<T> {
|
||||
const response = await fetch(`${normalizedApiBase()}${path}`, {
|
||||
...init,
|
||||
keepalive: options.keepalive === true,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(init.headers ?? {})
|
||||
}
|
||||
...(init.headers ?? {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@ -93,7 +110,10 @@ async function fetchJson<T>(path: string, init: RequestInit = {}, options: Fetch
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
export async function invoke<T>(command: string, args?: InvokeArgs): Promise<T> {
|
||||
export async function invoke<T>(
|
||||
command: string,
|
||||
args?: InvokeArgs,
|
||||
): Promise<T> {
|
||||
if (isTauriRuntime()) {
|
||||
const tauriCore = await import("@tauri-apps/api/core");
|
||||
return tauriCore.invoke<T>(command, args);
|
||||
@ -112,9 +132,9 @@ export async function invoke<T>(command: string, args?: InvokeArgs): Promise<T>
|
||||
"/command",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(envelope as BackendCommand)
|
||||
body: JSON.stringify(envelope as BackendCommand),
|
||||
},
|
||||
{ keepalive }
|
||||
{ keepalive },
|
||||
);
|
||||
}
|
||||
case "get_sidecar_root":
|
||||
@ -123,23 +143,32 @@ export async function invoke<T>(command: string, args?: InvokeArgs): Promise<T>
|
||||
const path = typeof args?.path === "string" ? args.path : "";
|
||||
return fetchJson<T>("/sidecar/root", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ path })
|
||||
body: JSON.stringify({ path }),
|
||||
});
|
||||
}
|
||||
case "get_ui_settings":
|
||||
return readUiSettingsFromLocalStorage() as T;
|
||||
case "set_ui_settings": {
|
||||
const tags = Array.isArray(args?.tags) ? (args?.tags as string[]) : undefined;
|
||||
const fragmentTypes =
|
||||
Array.isArray(args?.fragmentTypes) ? (args?.fragmentTypes as string[]) :
|
||||
Array.isArray(args?.fragment_types) ? (args?.fragment_types as string[]) :
|
||||
undefined;
|
||||
const tags = Array.isArray(args?.tags)
|
||||
? (args?.tags as string[])
|
||||
: undefined;
|
||||
const fragmentTypes = Array.isArray(args?.fragmentTypes)
|
||||
? (args?.fragmentTypes as string[])
|
||||
: Array.isArray(args?.fragment_types)
|
||||
? (args?.fragment_types as string[])
|
||||
: undefined;
|
||||
const defaultStartupView =
|
||||
typeof args?.defaultStartupView === "string" ? args.defaultStartupView :
|
||||
typeof args?.default_startup_view === "string" ? args.default_startup_view :
|
||||
undefined;
|
||||
typeof args?.defaultStartupView === "string"
|
||||
? args.defaultStartupView
|
||||
: typeof args?.default_startup_view === "string"
|
||||
? args.default_startup_view
|
||||
: undefined;
|
||||
|
||||
writeUiSettingsToLocalStorage({ tags, fragmentTypes, defaultStartupView });
|
||||
writeUiSettingsToLocalStorage({
|
||||
tags,
|
||||
fragmentTypes,
|
||||
defaultStartupView,
|
||||
});
|
||||
return undefined as T;
|
||||
}
|
||||
case "shutdown":
|
||||
|
||||
@ -6,7 +6,7 @@ import {
|
||||
saveEntry as saveEntryCommand,
|
||||
searchEntries as searchEntriesCommand,
|
||||
type EntryListItemDto,
|
||||
type EntrySearchRequestDto
|
||||
type EntrySearchRequestDto,
|
||||
} from "$lib/backend/entries";
|
||||
|
||||
export type EntryItem = {
|
||||
@ -59,17 +59,19 @@ function fromListDto(dto: EntryListItemDto): EntryItem {
|
||||
id: toStoreId(dto.filePath),
|
||||
label: toLabel(dto.fileName),
|
||||
initialContent: "",
|
||||
filePath: dto.filePath
|
||||
filePath: dto.filePath,
|
||||
};
|
||||
}
|
||||
|
||||
function fromLoadResult(result: Awaited<ReturnType<typeof loadEntryCommand>>): EntryItem {
|
||||
function fromLoadResult(
|
||||
result: Awaited<ReturnType<typeof loadEntryCommand>>,
|
||||
): EntryItem {
|
||||
return {
|
||||
id: toStoreId(result.filePath),
|
||||
label: toLabel(result.fileName),
|
||||
initialContent: result.entry.rawContent,
|
||||
filePath: result.filePath,
|
||||
date: result.entry.date
|
||||
date: result.entry.date,
|
||||
};
|
||||
}
|
||||
|
||||
@ -82,7 +84,7 @@ export function createEntryDraft(): EntryItem {
|
||||
return {
|
||||
id,
|
||||
label: "Untitled Entry",
|
||||
initialContent: "# Untitled Entry\n\nStart writing..."
|
||||
initialContent: "# Untitled Entry\n\nStart writing...",
|
||||
};
|
||||
}
|
||||
|
||||
@ -100,7 +102,9 @@ export async function hydrateEntries(dataDirectory?: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadEntryByStoreId(storeId: string): Promise<EntryItem | null> {
|
||||
export async function loadEntryByStoreId(
|
||||
storeId: string,
|
||||
): Promise<EntryItem | null> {
|
||||
const filePath = toBackendPath(storeId);
|
||||
if (!filePath) return null;
|
||||
|
||||
@ -115,12 +119,21 @@ export async function loadEntryByStoreId(storeId: string): Promise<EntryItem | n
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveEntryFromStore(storeId: string, content: string, mode?: string): Promise<EntryItem | null> {
|
||||
export async function saveEntryFromStore(
|
||||
storeId: string,
|
||||
content: string,
|
||||
mode?: string,
|
||||
): Promise<EntryItem | null> {
|
||||
const trimmed = content?.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
const existingPath = toBackendPath(storeId);
|
||||
let payload: { content: string; filePath?: string; mode?: string; fileName?: string };
|
||||
let payload: {
|
||||
content: string;
|
||||
filePath?: string;
|
||||
mode?: string;
|
||||
fileName?: string;
|
||||
};
|
||||
|
||||
if (existingPath) {
|
||||
payload = { content: trimmed, filePath: existingPath, mode };
|
||||
@ -133,7 +146,9 @@ export async function saveEntryFromStore(storeId: string, content: string, mode?
|
||||
const loaded = await loadEntryCommand(saved.filePath);
|
||||
const item = fromLoadResult(loaded);
|
||||
entriesStore.update((items) => {
|
||||
const filtered = existingPath ? items : items.filter((i) => i.id !== storeId);
|
||||
const filtered = existingPath
|
||||
? items
|
||||
: items.filter((i) => i.id !== storeId);
|
||||
return upsertById(filtered, item);
|
||||
});
|
||||
return item;
|
||||
@ -143,7 +158,9 @@ export async function saveEntryFromStore(storeId: string, content: string, mode?
|
||||
}
|
||||
}
|
||||
|
||||
export async function searchEntriesAsItems(payload: EntrySearchRequestDto): Promise<EntryItem[]> {
|
||||
export async function searchEntriesAsItems(
|
||||
payload: EntrySearchRequestDto,
|
||||
): Promise<EntryItem[]> {
|
||||
const results = await searchEntriesCommand(payload);
|
||||
const dataDirectory = payload.dataDirectory?.trim() ?? "";
|
||||
const separator = dataDirectory.includes("\\") ? "\\" : "/";
|
||||
@ -154,8 +171,10 @@ export async function searchEntriesAsItems(payload: EntrySearchRequestDto): Prom
|
||||
: `entries/search/${encodeURIComponent(result.fileName)}`,
|
||||
label: toLabel(result.fileName),
|
||||
initialContent: result.entry.rawContent,
|
||||
filePath: basePath ? `${basePath}${separator}${result.fileName}` : undefined,
|
||||
date: result.entry.date
|
||||
filePath: basePath
|
||||
? `${basePath}${separator}${result.fileName}`
|
||||
: undefined,
|
||||
date: result.entry.date,
|
||||
}));
|
||||
return mapped;
|
||||
}
|
||||
|
||||
@ -4,7 +4,7 @@ import {
|
||||
deleteFragment as deleteFragmentCommand,
|
||||
listFragments,
|
||||
updateFragment as updateFragmentCommand,
|
||||
type FragmentDto
|
||||
type FragmentDto,
|
||||
} from "$lib/backend/fragments";
|
||||
|
||||
export type FragmentItem = {
|
||||
@ -36,7 +36,10 @@ function toBackendId(id: string): string | null {
|
||||
return backendId || null;
|
||||
}
|
||||
|
||||
function splitDescription(description: string): { title: string; body: string } {
|
||||
function splitDescription(description: string): {
|
||||
title: string;
|
||||
body: string;
|
||||
} {
|
||||
const normalized = description.trim();
|
||||
if (!normalized) {
|
||||
return { title: "Untitled Fragment", body: "" };
|
||||
@ -67,8 +70,8 @@ function dtoToItem(dto: FragmentDto): FragmentItem {
|
||||
title: parsed.title,
|
||||
type: dto.type,
|
||||
tags: dto.tags ?? [],
|
||||
body: parsed.body
|
||||
})
|
||||
body: parsed.body,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@ -95,12 +98,17 @@ export function createFragmentId(title: string): string {
|
||||
export function serializeFragment(payload: ParsedFragment): string {
|
||||
const title = payload.title.trim() || "Untitled Fragment";
|
||||
const type = payload.type.trim();
|
||||
const tagsLine = payload.tags.length ? payload.tags.map((tag) => `#${tag}`).join(" ") : "(none)";
|
||||
const tagsLine = payload.tags.length
|
||||
? payload.tags.map((tag) => `#${tag}`).join(" ")
|
||||
: "(none)";
|
||||
const body = payload.body.trim() || "Add details for this fragment.";
|
||||
return `# ${title}\n\nType: ${type}\nTags: ${tagsLine}\n\n${body}`;
|
||||
}
|
||||
|
||||
export function parseFragmentContent(content: string, fallbackTitle = "Untitled Fragment"): ParsedFragment {
|
||||
export function parseFragmentContent(
|
||||
content: string,
|
||||
fallbackTitle = "Untitled Fragment",
|
||||
): ParsedFragment {
|
||||
const headingMatch = content.match(/^#\s+(.+)$/m);
|
||||
const typeMatch = content.match(/^Type:\s*(.+)$/m);
|
||||
const tagsMatch = content.match(/^Tags:\s*(.+)$/m);
|
||||
@ -119,7 +127,7 @@ export function parseFragmentContent(content: string, fallbackTitle = "Untitled
|
||||
title: headingMatch?.[1]?.trim() || fallbackTitle,
|
||||
type: typeMatch?.[1]?.trim() || "",
|
||||
tags,
|
||||
body: bodyMatch?.[1]?.trim() || ""
|
||||
body: bodyMatch?.[1]?.trim() || "",
|
||||
};
|
||||
}
|
||||
|
||||
@ -128,31 +136,49 @@ export function createFragmentDraft(): FragmentItem {
|
||||
return {
|
||||
id,
|
||||
label: "New Fragment",
|
||||
initialContent: "# New Fragment\n\nType: \nTags: (none)\n\n"
|
||||
initialContent: "# New Fragment\n\nType: \nTags: (none)\n\n",
|
||||
};
|
||||
}
|
||||
|
||||
export function createFragmentItem(title: string, content: string): FragmentItem {
|
||||
export function createFragmentItem(
|
||||
title: string,
|
||||
content: string,
|
||||
): FragmentItem {
|
||||
return {
|
||||
id: createFragmentId(title),
|
||||
label: title.trim() || "Untitled Fragment",
|
||||
initialContent: content
|
||||
initialContent: content,
|
||||
};
|
||||
}
|
||||
|
||||
export function updateFragmentItem(items: FragmentItem[], id: string, title: string, content: string): FragmentItem[] {
|
||||
export function updateFragmentItem(
|
||||
items: FragmentItem[],
|
||||
id: string,
|
||||
title: string,
|
||||
content: string,
|
||||
): FragmentItem[] {
|
||||
return items.map((item) =>
|
||||
item.id === id
|
||||
? { ...item, label: title.trim() || "Untitled Fragment", initialContent: content }
|
||||
: item
|
||||
? {
|
||||
...item,
|
||||
label: title.trim() || "Untitled Fragment",
|
||||
initialContent: content,
|
||||
}
|
||||
: item,
|
||||
);
|
||||
}
|
||||
|
||||
export function prependFragmentItem(items: FragmentItem[], item: FragmentItem): FragmentItem[] {
|
||||
export function prependFragmentItem(
|
||||
items: FragmentItem[],
|
||||
item: FragmentItem,
|
||||
): FragmentItem[] {
|
||||
return [item, ...items];
|
||||
}
|
||||
|
||||
export function removeFragmentItem(items: FragmentItem[], id: string): FragmentItem[] {
|
||||
export function removeFragmentItem(
|
||||
items: FragmentItem[],
|
||||
id: string,
|
||||
): FragmentItem[] {
|
||||
return items.filter((item) => item.id !== id);
|
||||
}
|
||||
|
||||
@ -169,38 +195,45 @@ export async function hydrateFragments(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function createFragmentFromParsed(payload: ParsedFragment): Promise<FragmentItem> {
|
||||
export async function createFragmentFromParsed(
|
||||
payload: ParsedFragment,
|
||||
): Promise<FragmentItem> {
|
||||
const created = await createFragmentCommand({
|
||||
type: payload.type.trim(),
|
||||
description: composeDescription(payload.title, payload.body),
|
||||
tags: payload.tags
|
||||
tags: payload.tags,
|
||||
});
|
||||
const item = dtoToItem(created);
|
||||
fragmentsStore.update((items) => prependFragmentItem(items, item));
|
||||
return item;
|
||||
}
|
||||
|
||||
export async function updateFragmentFromParsed(storeId: string, payload: ParsedFragment): Promise<FragmentItem | null> {
|
||||
export async function updateFragmentFromParsed(
|
||||
storeId: string,
|
||||
payload: ParsedFragment,
|
||||
): Promise<FragmentItem | null> {
|
||||
const backendId = toBackendId(storeId);
|
||||
if (!backendId) return null;
|
||||
|
||||
const ok = await updateFragmentCommand(backendId, {
|
||||
type: payload.type.trim(),
|
||||
description: composeDescription(payload.title, payload.body),
|
||||
tags: payload.tags
|
||||
tags: payload.tags,
|
||||
});
|
||||
if (!ok) return null;
|
||||
|
||||
const item: FragmentItem = {
|
||||
id: storeId,
|
||||
label: payload.title.trim() || "Untitled Fragment",
|
||||
initialContent: serializeFragment(payload)
|
||||
initialContent: serializeFragment(payload),
|
||||
};
|
||||
fragmentsStore.update((items) => upsertById(items, item));
|
||||
return item;
|
||||
}
|
||||
|
||||
export async function deleteFragmentByStoreId(storeId: string): Promise<boolean> {
|
||||
export async function deleteFragmentByStoreId(
|
||||
storeId: string,
|
||||
): Promise<boolean> {
|
||||
const backendId = toBackendId(storeId);
|
||||
if (!backendId) return false;
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@ import {
|
||||
deleteList as deleteListCommand,
|
||||
listLists,
|
||||
updateList as updateListCommand,
|
||||
type ListDocumentDto
|
||||
type ListDocumentDto,
|
||||
} from "$lib/backend/lists";
|
||||
|
||||
export type ListItem = {
|
||||
@ -31,7 +31,7 @@ function dtoToItem(dto: ListDocumentDto): ListItem {
|
||||
return {
|
||||
id: toStoreId(dto.id),
|
||||
label: dto.label,
|
||||
initialContent: dto.content || `# ${dto.label}\n\n`
|
||||
initialContent: dto.content || `# ${dto.label}\n\n`,
|
||||
};
|
||||
}
|
||||
|
||||
@ -48,7 +48,7 @@ export function createListDraft(): ListItem {
|
||||
return {
|
||||
id,
|
||||
label: "Untitled List",
|
||||
initialContent: "# Untitled List\n\n- Item 1"
|
||||
initialContent: "# Untitled List\n\n- Item 1",
|
||||
};
|
||||
}
|
||||
|
||||
@ -65,10 +65,16 @@ export async function hydrateLists(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function createListFromLabel(label: string, content = ""): Promise<ListItem> {
|
||||
export async function createListFromLabel(
|
||||
label: string,
|
||||
content = "",
|
||||
): Promise<ListItem> {
|
||||
const resolvedLabel = label.trim() || "Untitled List";
|
||||
const resolvedContent = content || `# ${resolvedLabel}\n\n`;
|
||||
const created = await createListCommand({ label: resolvedLabel, content: resolvedContent });
|
||||
const created = await createListCommand({
|
||||
label: resolvedLabel,
|
||||
content: resolvedContent,
|
||||
});
|
||||
const item = dtoToItem(created);
|
||||
listsStore.update((items) => [item, ...items]);
|
||||
return item;
|
||||
@ -77,7 +83,7 @@ export async function createListFromLabel(label: string, content = ""): Promise<
|
||||
export async function updateListByStoreId(
|
||||
storeId: string,
|
||||
label?: string,
|
||||
content?: string
|
||||
content?: string,
|
||||
): Promise<boolean> {
|
||||
const backendId = toBackendId(storeId);
|
||||
if (!backendId) return false;
|
||||
@ -95,10 +101,10 @@ export async function updateListByStoreId(
|
||||
? {
|
||||
...item,
|
||||
label: label ?? item.label,
|
||||
initialContent: content ?? item.initialContent
|
||||
initialContent: content ?? item.initialContent,
|
||||
}
|
||||
: item
|
||||
)
|
||||
: item,
|
||||
),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -4,13 +4,22 @@ import { invoke } from "$lib/runtime/invoke";
|
||||
|
||||
const defaultTags = ["Personal", "Work", "Ideas", "Journal"];
|
||||
const defaultFragmentTypes = ["Quote", "Snippet", "Reference"];
|
||||
const startupViews = ["entries", "calendar", "fragments", "todos", "lists"] as const;
|
||||
const startupViews = [
|
||||
"entries",
|
||||
"calendar",
|
||||
"fragments",
|
||||
"todos",
|
||||
"lists",
|
||||
] as const;
|
||||
const defaultStartupView = "entries";
|
||||
export type StartupView = typeof startupViews[number];
|
||||
export type StartupView = (typeof startupViews)[number];
|
||||
|
||||
export const settingsTags = writable<string[]>([...defaultTags]);
|
||||
export const settingsFragmentTypes = writable<string[]>([...defaultFragmentTypes]);
|
||||
export const settingsDefaultStartupView = writable<StartupView>(defaultStartupView);
|
||||
export const settingsFragmentTypes = writable<string[]>([
|
||||
...defaultFragmentTypes,
|
||||
]);
|
||||
export const settingsDefaultStartupView =
|
||||
writable<StartupView>(defaultStartupView);
|
||||
|
||||
let hydrationComplete = false;
|
||||
let hydrating = false;
|
||||
@ -25,9 +34,15 @@ function normalize(value: string): string {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function hasDuplicate(values: string[], candidate: string, excludeIndex?: number): boolean {
|
||||
function hasDuplicate(
|
||||
values: string[],
|
||||
candidate: string,
|
||||
excludeIndex?: number,
|
||||
): boolean {
|
||||
const normalized = normalize(candidate);
|
||||
return values.some((value, index) => index !== excludeIndex && normalize(value) === normalized);
|
||||
return values.some(
|
||||
(value, index) => index !== excludeIndex && normalize(value) === normalized,
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeValues(values: string[], fallback: string[]): string[] {
|
||||
@ -58,8 +73,12 @@ export async function hydrateUiSettings(): Promise<void> {
|
||||
try {
|
||||
const payload = await invoke<UiSettingsPayload>("get_ui_settings");
|
||||
settingsTags.set(normalizeValues(payload.tags ?? [], defaultTags));
|
||||
settingsFragmentTypes.set(normalizeValues(payload.fragmentTypes ?? [], defaultFragmentTypes));
|
||||
settingsDefaultStartupView.set(normalizeStartupView(payload.defaultStartupView));
|
||||
settingsFragmentTypes.set(
|
||||
normalizeValues(payload.fragmentTypes ?? [], defaultFragmentTypes),
|
||||
);
|
||||
settingsDefaultStartupView.set(
|
||||
normalizeStartupView(payload.defaultStartupView),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("[settings] hydrate failed", error);
|
||||
} finally {
|
||||
@ -70,7 +89,10 @@ export async function hydrateUiSettings(): Promise<void> {
|
||||
|
||||
export async function persistUiSettings(): Promise<void> {
|
||||
const tags = normalizeValues(get(settingsTags), defaultTags);
|
||||
const fragmentTypes = normalizeValues(get(settingsFragmentTypes), defaultFragmentTypes);
|
||||
const fragmentTypes = normalizeValues(
|
||||
get(settingsFragmentTypes),
|
||||
defaultFragmentTypes,
|
||||
);
|
||||
const startupView = normalizeStartupView(get(settingsDefaultStartupView));
|
||||
settingsTags.set(tags);
|
||||
settingsFragmentTypes.set(fragmentTypes);
|
||||
@ -81,7 +103,7 @@ export async function persistUiSettings(): Promise<void> {
|
||||
fragmentTypes,
|
||||
fragment_types: fragmentTypes,
|
||||
defaultStartupView: startupView,
|
||||
default_startup_view: startupView
|
||||
default_startup_view: startupView,
|
||||
});
|
||||
}
|
||||
|
||||
@ -137,7 +159,9 @@ export function updateFragmentType(index: number, value: string): boolean {
|
||||
const types = get(settingsFragmentTypes);
|
||||
if (index < 0 || index >= types.length) return false;
|
||||
if (hasDuplicate(types, next, index)) return false;
|
||||
settingsFragmentTypes.set(types.map((type, idx) => (idx === index ? next : type)));
|
||||
settingsFragmentTypes.set(
|
||||
types.map((type, idx) => (idx === index ? next : type)),
|
||||
);
|
||||
queuePersist();
|
||||
return true;
|
||||
}
|
||||
@ -157,4 +181,3 @@ export function setDefaultStartupView(value: string): boolean {
|
||||
queuePersist();
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@ -7,12 +7,17 @@ import {
|
||||
listTodoLists,
|
||||
updateTodoItem as updateTodoItemCommand,
|
||||
updateTodoList as updateTodoListCommand,
|
||||
type TodoListDto
|
||||
type TodoListDto,
|
||||
} from "$lib/backend/todos";
|
||||
|
||||
// TodoItem keeps a numeric `id` for local array operations (used by EditorPanel)
|
||||
// plus a `backendId` (guid string) for backend persistence.
|
||||
export type TodoItem = { id: number; text: string; done: boolean; backendId?: string };
|
||||
export type TodoItem = {
|
||||
id: number;
|
||||
text: string;
|
||||
done: boolean;
|
||||
backendId?: string;
|
||||
};
|
||||
export type TodoListMeta = { id: string; label: string; backendId?: string };
|
||||
|
||||
export const todoListsStore = writable<TodoListMeta[]>([]);
|
||||
@ -42,7 +47,7 @@ function dtoToMeta(dto: TodoListDto): TodoListMeta {
|
||||
return {
|
||||
id: toStoreId(dto.id),
|
||||
label: dto.label,
|
||||
backendId: dto.id
|
||||
backendId: dto.id,
|
||||
};
|
||||
}
|
||||
|
||||
@ -51,7 +56,7 @@ function dtoToItems(dto: TodoListDto): TodoItem[] {
|
||||
id: createTodoId() + index,
|
||||
text: item.text,
|
||||
done: item.done,
|
||||
backendId: item.id
|
||||
backendId: item.id,
|
||||
}));
|
||||
}
|
||||
|
||||
@ -81,7 +86,7 @@ export async function hydrateTodos(): Promise<void> {
|
||||
// ── List CRUD ────────────────────────────────────────────────────
|
||||
|
||||
export async function createTodoListFromLabel(
|
||||
label: string
|
||||
label: string,
|
||||
): Promise<{ meta: TodoListMeta; items: TodoItem[] }> {
|
||||
const resolvedLabel = label.trim() || "New List";
|
||||
const created = await createTodoListCommand({ label: resolvedLabel });
|
||||
@ -92,7 +97,9 @@ export async function createTodoListFromLabel(
|
||||
return { meta, items: [] };
|
||||
}
|
||||
|
||||
export async function deleteTodoListByStoreId(storeId: string): Promise<boolean> {
|
||||
export async function deleteTodoListByStoreId(
|
||||
storeId: string,
|
||||
): Promise<boolean> {
|
||||
const backendId = toBackendId(storeId);
|
||||
if (!backendId) return false;
|
||||
|
||||
@ -111,7 +118,7 @@ export async function deleteTodoListByStoreId(storeId: string): Promise<boolean>
|
||||
|
||||
export async function addTodoItemBackend(
|
||||
storeId: string,
|
||||
text: string
|
||||
text: string,
|
||||
): Promise<TodoItem | null> {
|
||||
const backendListId = toBackendId(storeId);
|
||||
if (!backendListId || !text.trim()) return null;
|
||||
@ -122,26 +129,26 @@ export async function addTodoItemBackend(
|
||||
const created = await createTodoItemCommand({
|
||||
listId: backendListId,
|
||||
text: text.trim(),
|
||||
sortOrder
|
||||
sortOrder,
|
||||
});
|
||||
|
||||
const item: TodoItem = {
|
||||
id: createTodoId(),
|
||||
text: created.text,
|
||||
done: created.done,
|
||||
backendId: created.id
|
||||
backendId: created.id,
|
||||
};
|
||||
|
||||
todosStore.update((lists) => ({
|
||||
...lists,
|
||||
[storeId]: [item, ...(lists[storeId] ?? [])]
|
||||
[storeId]: [item, ...(lists[storeId] ?? [])],
|
||||
}));
|
||||
return item;
|
||||
}
|
||||
|
||||
export async function toggleTodoItemBackend(
|
||||
storeId: string,
|
||||
localId: number
|
||||
localId: number,
|
||||
): Promise<boolean> {
|
||||
const items = get(todosStore)[storeId];
|
||||
const todo = items?.find((t) => t.id === localId);
|
||||
@ -153,8 +160,8 @@ export async function toggleTodoItemBackend(
|
||||
todosStore.update((lists) => ({
|
||||
...lists,
|
||||
[storeId]: (lists[storeId] ?? []).map((t) =>
|
||||
t.id === localId ? { ...t, done: !t.done } : t
|
||||
)
|
||||
t.id === localId ? { ...t, done: !t.done } : t,
|
||||
),
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
@ -162,7 +169,7 @@ export async function toggleTodoItemBackend(
|
||||
export async function updateTodoItemTextBackend(
|
||||
storeId: string,
|
||||
localId: number,
|
||||
text: string
|
||||
text: string,
|
||||
): Promise<boolean> {
|
||||
const items = get(todosStore)[storeId];
|
||||
const todo = items?.find((t) => t.id === localId);
|
||||
@ -174,15 +181,15 @@ export async function updateTodoItemTextBackend(
|
||||
todosStore.update((lists) => ({
|
||||
...lists,
|
||||
[storeId]: (lists[storeId] ?? []).map((t) =>
|
||||
t.id === localId ? { ...t, text: text.trim() } : t
|
||||
)
|
||||
t.id === localId ? { ...t, text: text.trim() } : t,
|
||||
),
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function removeTodoItemBackend(
|
||||
storeId: string,
|
||||
localId: number
|
||||
localId: number,
|
||||
): Promise<boolean> {
|
||||
const items = get(todosStore)[storeId];
|
||||
const todo = items?.find((t) => t.id === localId);
|
||||
@ -193,7 +200,7 @@ export async function removeTodoItemBackend(
|
||||
|
||||
todosStore.update((lists) => ({
|
||||
...lists,
|
||||
[storeId]: (lists[storeId] ?? []).filter((t) => t.id !== localId)
|
||||
[storeId]: (lists[storeId] ?? []).filter((t) => t.id !== localId),
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
@ -202,7 +209,9 @@ export async function removeTodoItemBackend(
|
||||
|
||||
export function serializeTodoList(title: string, todos: TodoItem[]): string {
|
||||
const heading = title?.trim() ? `# ${title}` : "# To-Do List";
|
||||
const lines = todos.map((todo) => `- [${todo.done ? "x" : " "}] ${todo.text}`);
|
||||
const lines = todos.map(
|
||||
(todo) => `- [${todo.done ? "x" : " "}] ${todo.text}`,
|
||||
);
|
||||
return `${heading}\n\n${lines.join("\n")}`;
|
||||
}
|
||||
|
||||
@ -215,7 +224,7 @@ export function parseTodoList(content: string): TodoItem[] {
|
||||
parsed.push({
|
||||
id: createTodoId(),
|
||||
text: match[2].trim(),
|
||||
done: match[1].toLowerCase() === "x"
|
||||
done: match[1].toLowerCase() === "x",
|
||||
});
|
||||
}
|
||||
return parsed;
|
||||
@ -224,7 +233,7 @@ export function parseTodoList(content: string): TodoItem[] {
|
||||
export function getOrCreateTodoList(
|
||||
lists: Record<string, TodoItem[]>,
|
||||
documentId: string,
|
||||
fallbackContent: string
|
||||
fallbackContent: string,
|
||||
): { lists: Record<string, TodoItem[]>; todos: TodoItem[] } {
|
||||
const existing = lists[documentId];
|
||||
if (existing) {
|
||||
@ -237,7 +246,7 @@ export function getOrCreateTodoList(
|
||||
export function setTodoList(
|
||||
lists: Record<string, TodoItem[]>,
|
||||
documentId: string,
|
||||
todos: TodoItem[]
|
||||
todos: TodoItem[],
|
||||
): Record<string, TodoItem[]> {
|
||||
return { ...lists, [documentId]: todos };
|
||||
}
|
||||
@ -247,21 +256,32 @@ export function addTodoItem(todos: TodoItem[], text: string): TodoItem[] {
|
||||
}
|
||||
|
||||
export function toggleTodoItem(todos: TodoItem[], id: number): TodoItem[] {
|
||||
return todos.map((todo) => (todo.id === id ? { ...todo, done: !todo.done } : todo));
|
||||
return todos.map((todo) =>
|
||||
todo.id === id ? { ...todo, done: !todo.done } : todo,
|
||||
);
|
||||
}
|
||||
|
||||
export function updateTodoItemText(todos: TodoItem[], id: number, text: string): TodoItem[] {
|
||||
return todos.map((todo) => (todo.id === id ? { ...todo, text: text.trim() } : todo));
|
||||
export function updateTodoItemText(
|
||||
todos: TodoItem[],
|
||||
id: number,
|
||||
text: string,
|
||||
): TodoItem[] {
|
||||
return todos.map((todo) =>
|
||||
todo.id === id ? { ...todo, text: text.trim() } : todo,
|
||||
);
|
||||
}
|
||||
|
||||
export function removeTodoItem(todos: TodoItem[], id: number): TodoItem[] {
|
||||
return todos.filter((todo) => todo.id !== id);
|
||||
}
|
||||
|
||||
export function createTodoListDraft(): { meta: TodoListMeta; items: TodoItem[] } {
|
||||
export function createTodoListDraft(): {
|
||||
meta: TodoListMeta;
|
||||
items: TodoItem[];
|
||||
} {
|
||||
const id = `todos/draft-${Date.now()}`;
|
||||
return {
|
||||
meta: { id, label: "New List" },
|
||||
items: []
|
||||
items: [],
|
||||
};
|
||||
}
|
||||
|
||||
@ -15,11 +15,11 @@ export function parseInline(input: string): string {
|
||||
value = value.replace(/\*([^*]+)\*/g, "<em>$1</em>");
|
||||
value = value.replace(
|
||||
/\[([^\]]+)\]\((journal:[^\s)]+)\)/g,
|
||||
'<a href="$2">$1</a>'
|
||||
'<a href="$2">$1</a>',
|
||||
);
|
||||
value = value.replace(
|
||||
/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g,
|
||||
'<a href="$2" target="_blank" rel="noreferrer">$1</a>'
|
||||
'<a href="$2" target="_blank" rel="noreferrer">$1</a>',
|
||||
);
|
||||
return value;
|
||||
}
|
||||
@ -37,7 +37,9 @@ export function renderMarkdown(markdown: string): string {
|
||||
|
||||
if (trimmed.startsWith("```")) {
|
||||
if (inCode) {
|
||||
output.push(`<pre><code>${escapeHtml(codeLines.join("\n"))}</code></pre>`);
|
||||
output.push(
|
||||
`<pre><code>${escapeHtml(codeLines.join("\n"))}</code></pre>`,
|
||||
);
|
||||
codeLines = [];
|
||||
inCode = false;
|
||||
} else {
|
||||
@ -69,7 +71,9 @@ export function renderMarkdown(markdown: string): string {
|
||||
if (/^[-*+]\s+/.test(trimmed)) {
|
||||
const items: string[] = [];
|
||||
while (i < lines.length && /^[-*+]\s+/.test(lines[i].trim())) {
|
||||
items.push(`<li>${parseInline(lines[i].trim().replace(/^[-*+]\s+/, ""))}</li>`);
|
||||
items.push(
|
||||
`<li>${parseInline(lines[i].trim().replace(/^[-*+]\s+/, ""))}</li>`,
|
||||
);
|
||||
i += 1;
|
||||
}
|
||||
output.push(`<ul>${items.join("")}</ul>`);
|
||||
@ -79,7 +83,9 @@ export function renderMarkdown(markdown: string): string {
|
||||
if (/^\d+\.\s+/.test(trimmed)) {
|
||||
const items: string[] = [];
|
||||
while (i < lines.length && /^\d+\.\s+/.test(lines[i].trim())) {
|
||||
items.push(`<li>${parseInline(lines[i].trim().replace(/^\d+\.\s+/, ""))}</li>`);
|
||||
items.push(
|
||||
`<li>${parseInline(lines[i].trim().replace(/^\d+\.\s+/, ""))}</li>`,
|
||||
);
|
||||
i += 1;
|
||||
}
|
||||
output.push(`<ol>${items.join("")}</ol>`);
|
||||
@ -87,7 +93,9 @@ export function renderMarkdown(markdown: string): string {
|
||||
}
|
||||
|
||||
if (/^>\s+/.test(trimmed)) {
|
||||
output.push(`<blockquote>${parseInline(trimmed.replace(/^>\s+/, ""))}</blockquote>`);
|
||||
output.push(
|
||||
`<blockquote>${parseInline(trimmed.replace(/^>\s+/, ""))}</blockquote>`,
|
||||
);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
@ -114,7 +122,8 @@ export function renderMarkdown(markdown: string): string {
|
||||
}
|
||||
|
||||
export function extractEditorTitle(markdown: string, fallback: string): string {
|
||||
const firstLine = markdown.replace(/\r\n/g, "\n").split("\n")[0]?.trim() ?? "";
|
||||
const firstLine =
|
||||
markdown.replace(/\r\n/g, "\n").split("\n")[0]?.trim() ?? "";
|
||||
const headingMatch = firstLine.match(/^#\s+(.+)$/);
|
||||
return headingMatch ? headingMatch[1] : fallback;
|
||||
}
|
||||
|
||||
@ -12,7 +12,10 @@ function normalizeTags(tags: string[]): string[] {
|
||||
return result;
|
||||
}
|
||||
|
||||
function splitFrontmatter(content: string): { frontmatter: string | null; body: string } {
|
||||
function splitFrontmatter(content: string): {
|
||||
frontmatter: string | null;
|
||||
body: string;
|
||||
} {
|
||||
const normalized = (content ?? "").replace(/\r\n/g, "\n");
|
||||
if (!normalized.startsWith("---\n")) {
|
||||
return { frontmatter: null, body: normalized };
|
||||
@ -38,14 +41,12 @@ function parseTagsValue(rawValue: string): string[] {
|
||||
value
|
||||
.slice(1, -1)
|
||||
.split(",")
|
||||
.map((token) => token.trim().replace(/^["']|["']$/g, ""))
|
||||
.map((token) => token.trim().replace(/^["']|["']$/g, "")),
|
||||
);
|
||||
}
|
||||
|
||||
return normalizeTags(
|
||||
value
|
||||
.split(",")
|
||||
.map((token) => token.trim().replace(/^["']|["']$/g, ""))
|
||||
value.split(",").map((token) => token.trim().replace(/^["']|["']$/g, "")),
|
||||
);
|
||||
}
|
||||
|
||||
@ -59,7 +60,9 @@ export function parseTagsFromMarkdown(content: string): string[] {
|
||||
const { frontmatter } = splitFrontmatter(content);
|
||||
if (!frontmatter) return [];
|
||||
|
||||
const line = frontmatter.split("\n").find((entry) => /^\s*tags\s*:/i.test(entry));
|
||||
const line = frontmatter
|
||||
.split("\n")
|
||||
.find((entry) => /^\s*tags\s*:/i.test(entry));
|
||||
if (!line) return [];
|
||||
const value = line.replace(/^\s*tags\s*:/i, "");
|
||||
return parseTagsValue(value);
|
||||
@ -72,7 +75,8 @@ export function stripFrontmatter(content: string): string {
|
||||
export function setTagsInMarkdown(content: string, tags: string[]): string {
|
||||
const normalizedBody = (content ?? "").replace(/\r\n/g, "\n");
|
||||
const normalizedTags = normalizeTags(tags);
|
||||
const tagsLine = normalizedTags.length > 0 ? `tags: ${formatTagsValue(normalizedTags)}` : "";
|
||||
const tagsLine =
|
||||
normalizedTags.length > 0 ? `tags: ${formatTagsValue(normalizedTags)}` : "";
|
||||
const { frontmatter, body } = splitFrontmatter(normalizedBody);
|
||||
|
||||
if (!frontmatter) {
|
||||
@ -108,7 +112,7 @@ export function extractBracketTags(content: string): string[] {
|
||||
...raw
|
||||
.split(",")
|
||||
.map((token) => token.trim())
|
||||
.filter(Boolean)
|
||||
.filter(Boolean),
|
||||
);
|
||||
}
|
||||
return normalizeTags(tokens);
|
||||
@ -137,7 +141,7 @@ export function extractTagsFromTagsSection(content: string): string[] {
|
||||
...cleaned
|
||||
.split(",")
|
||||
.map((token) => token.trim())
|
||||
.filter(Boolean)
|
||||
.filter(Boolean),
|
||||
);
|
||||
}
|
||||
|
||||
@ -148,6 +152,6 @@ export function extractEntryTags(content: string): string[] {
|
||||
return normalizeTags([
|
||||
...parseTagsFromMarkdown(content),
|
||||
...extractBracketTags(content),
|
||||
...extractTagsFromTagsSection(content)
|
||||
...extractTagsFromTagsSection(content),
|
||||
]);
|
||||
}
|
||||
|
||||
@ -1,6 +1,11 @@
|
||||
<!-- @format -->
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { getSessionPassword, clearVaultSession, flushBeforeClose } from "$lib/stores/session";
|
||||
import {
|
||||
getSessionPassword,
|
||||
clearVaultSession,
|
||||
flushBeforeClose,
|
||||
} from "$lib/stores/session";
|
||||
import { persistAndClearVault } from "$lib/backend/auth";
|
||||
import { hydrateUiSettings } from "$lib/stores/settings";
|
||||
import { invoke, isTauriRuntime } from "$lib/runtime/invoke";
|
||||
|
||||
@ -1,13 +1,40 @@
|
||||
<!-- @format -->
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import { unlockVaultWorkspace } from "$lib/backend/auth";
|
||||
import AppModal from "$lib/components/AppModal.svelte";
|
||||
import { deleteEntryTemplate, loadEntryTemplate, saveEntryTemplate } from "$lib/backend/templates";
|
||||
import { deleteEntryByStoreId, entriesStore, getDefaultEntry, hydrateEntries, loadEntryByStoreId, saveEntryFromStore } from "$lib/stores/entries";
|
||||
import { deleteFragmentByStoreId, hydrateFragments } from "$lib/stores/fragments";
|
||||
import { deleteListByStoreId, hydrateLists, updateListByStoreId } from "$lib/stores/lists";
|
||||
import { hydrateUiSettings, settingsDefaultStartupView, type StartupView } from "$lib/stores/settings";
|
||||
import { isVaultReady, setFlushCallback, setVaultSession } from "$lib/stores/session";
|
||||
import {
|
||||
deleteEntryTemplate,
|
||||
loadEntryTemplate,
|
||||
saveEntryTemplate,
|
||||
} from "$lib/backend/templates";
|
||||
import {
|
||||
deleteEntryByStoreId,
|
||||
entriesStore,
|
||||
getDefaultEntry,
|
||||
hydrateEntries,
|
||||
loadEntryByStoreId,
|
||||
saveEntryFromStore,
|
||||
} from "$lib/stores/entries";
|
||||
import {
|
||||
deleteFragmentByStoreId,
|
||||
hydrateFragments,
|
||||
} from "$lib/stores/fragments";
|
||||
import {
|
||||
deleteListByStoreId,
|
||||
hydrateLists,
|
||||
updateListByStoreId,
|
||||
} from "$lib/stores/lists";
|
||||
import {
|
||||
hydrateUiSettings,
|
||||
settingsDefaultStartupView,
|
||||
type StartupView,
|
||||
} from "$lib/stores/settings";
|
||||
import {
|
||||
isVaultReady,
|
||||
setFlushCallback,
|
||||
setVaultSession,
|
||||
} from "$lib/stores/session";
|
||||
import { deleteTodoListByStoreId, hydrateTodos } from "$lib/stores/todos";
|
||||
import Navbar from "$lib/components/Navbar.svelte";
|
||||
import SidePanel from "$lib/components/SidePanel.svelte";
|
||||
@ -41,7 +68,10 @@
|
||||
let activeDocumentLabel = initialEntry?.label ?? "Daily Notes";
|
||||
let openDocuments: Record<string, string> = initialEntry
|
||||
? { [initialEntry.id]: initialEntry.initialContent }
|
||||
: { "entries/daily-notes": "# Daily Notes\n\nStart writing today's entry..." };
|
||||
: {
|
||||
"entries/daily-notes":
|
||||
"# Daily Notes\n\nStart writing today's entry...",
|
||||
};
|
||||
let modalOpen = false;
|
||||
let modalTitle = "";
|
||||
let modalMessage = "";
|
||||
@ -49,7 +79,12 @@
|
||||
let modalCancelText = "Cancel";
|
||||
let modalShowCancel = false;
|
||||
let modalTone: "default" | "danger" = "default";
|
||||
let modalAction: "logout-confirm" | "logout-info" | "unlock-vault" | "delete-confirm" | null = null;
|
||||
let modalAction:
|
||||
| "logout-confirm"
|
||||
| "logout-info"
|
||||
| "unlock-vault"
|
||||
| "delete-confirm"
|
||||
| null = null;
|
||||
let modalInputEnabled = false;
|
||||
let modalInputType = "text";
|
||||
let modalInputPlaceholder = "";
|
||||
@ -63,7 +98,7 @@
|
||||
let calendarPanelState: CalendarPanelState = {
|
||||
items: [],
|
||||
busy: false,
|
||||
error: ""
|
||||
error: "",
|
||||
};
|
||||
|
||||
function resolveStartupSection(value: string): StartupView {
|
||||
@ -130,7 +165,11 @@
|
||||
}
|
||||
|
||||
function showModal(options: {
|
||||
action: "logout-confirm" | "logout-info" | "unlock-vault" | "delete-confirm";
|
||||
action:
|
||||
| "logout-confirm"
|
||||
| "logout-info"
|
||||
| "unlock-vault"
|
||||
| "delete-confirm";
|
||||
title: string;
|
||||
message: string;
|
||||
confirmText?: string;
|
||||
@ -175,7 +214,7 @@
|
||||
action: "logout-info",
|
||||
title: "Logout Requested",
|
||||
message: "You have been logged out.",
|
||||
confirmText: "Close"
|
||||
confirmText: "Close",
|
||||
});
|
||||
return;
|
||||
}
|
||||
@ -226,7 +265,7 @@
|
||||
inputType: "password",
|
||||
inputPlaceholder: "Vault password",
|
||||
inputAriaLabel: "Vault password",
|
||||
inputValue: ""
|
||||
inputValue: "",
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -234,7 +273,10 @@
|
||||
function isLockedError(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const normalized = message.toLowerCase();
|
||||
return normalized.includes("database is locked") || normalized.includes("incorrect vault password");
|
||||
return (
|
||||
normalized.includes("database is locked") ||
|
||||
normalized.includes("incorrect vault password")
|
||||
);
|
||||
}
|
||||
|
||||
async function bootstrapFragmentsWithUnlock(maxAttempts = 3) {
|
||||
@ -284,7 +326,6 @@
|
||||
attempts += 1;
|
||||
}
|
||||
}
|
||||
|
||||
} finally {
|
||||
fragmentBootstrapInFlight = false;
|
||||
}
|
||||
@ -296,39 +337,62 @@
|
||||
if (!content?.trim()) return;
|
||||
|
||||
try {
|
||||
if (selectedSection === "entries" && activeDocumentId.startsWith("entries/template-draft-")) {
|
||||
const draft = get(entriesStore).find((item) => item.id === activeDocumentId);
|
||||
if (
|
||||
selectedSection === "entries" &&
|
||||
activeDocumentId.startsWith("entries/template-draft-")
|
||||
) {
|
||||
const draft = get(entriesStore).find(
|
||||
(item) => item.id === activeDocumentId,
|
||||
);
|
||||
const draftLabel = (draft?.label ?? activeDocumentLabel ?? "").trim();
|
||||
const templateName = draftLabel.replace(/_template$/i, "").trim() || draftLabel;
|
||||
const templateName =
|
||||
draftLabel.replace(/_template$/i, "").trim() || draftLabel;
|
||||
await saveEntryTemplate({
|
||||
name: templateName,
|
||||
content
|
||||
content,
|
||||
});
|
||||
templateRefreshToken += 1;
|
||||
entriesStore.update((items) => items.filter((item) => item.id !== activeDocumentId));
|
||||
entriesStore.update((items) =>
|
||||
items.filter((item) => item.id !== activeDocumentId),
|
||||
);
|
||||
const { [activeDocumentId]: _, ...rest } = openDocuments;
|
||||
openDocuments = rest;
|
||||
activeDocumentId = "";
|
||||
activeDocumentLabel = "";
|
||||
editMode = false;
|
||||
} else if (selectedSection === "entries" && activeDocumentId.startsWith("entries/template-file/")) {
|
||||
} else if (
|
||||
selectedSection === "entries" &&
|
||||
activeDocumentId.startsWith("entries/template-file/")
|
||||
) {
|
||||
const filePath = toTemplatePath(activeDocumentId);
|
||||
if (!filePath) return;
|
||||
await saveEntryTemplate({
|
||||
name: templateNameFromPath(filePath),
|
||||
content,
|
||||
filePath
|
||||
filePath,
|
||||
});
|
||||
templateRefreshToken += 1;
|
||||
} else if (selectedSection === "entries" && (activeDocumentId.startsWith("entries/file/") || activeDocumentId.startsWith("entries/draft-"))) {
|
||||
const saved = await saveEntryFromStore(activeDocumentId, content, "Overwrite");
|
||||
} else if (
|
||||
selectedSection === "entries" &&
|
||||
(activeDocumentId.startsWith("entries/file/") ||
|
||||
activeDocumentId.startsWith("entries/draft-"))
|
||||
) {
|
||||
const saved = await saveEntryFromStore(
|
||||
activeDocumentId,
|
||||
content,
|
||||
"Overwrite",
|
||||
);
|
||||
if (saved && saved.id !== activeDocumentId) {
|
||||
const { [activeDocumentId]: _, ...rest } = openDocuments;
|
||||
openDocuments = { ...rest, [saved.id]: saved.initialContent };
|
||||
activeDocumentId = saved.id;
|
||||
activeDocumentLabel = saved.label;
|
||||
}
|
||||
} else if (selectedSection === "lists" && activeDocumentId.startsWith("lists/") && !activeDocumentId.startsWith("lists/draft-")) {
|
||||
} else if (
|
||||
selectedSection === "lists" &&
|
||||
activeDocumentId.startsWith("lists/") &&
|
||||
!activeDocumentId.startsWith("lists/draft-")
|
||||
) {
|
||||
await updateListByStoreId(activeDocumentId, undefined, content);
|
||||
}
|
||||
} catch {
|
||||
@ -355,7 +419,7 @@
|
||||
confirmText: "Log Out",
|
||||
cancelText: "Cancel",
|
||||
showCancel: true,
|
||||
tone: "danger"
|
||||
tone: "danger",
|
||||
});
|
||||
return;
|
||||
}
|
||||
@ -393,7 +457,11 @@
|
||||
}
|
||||
|
||||
let resolvedDoc = doc;
|
||||
if (effectiveSection === "entries" && doc.id.startsWith("entries/file/") && !doc.initialContent) {
|
||||
if (
|
||||
effectiveSection === "entries" &&
|
||||
doc.id.startsWith("entries/file/") &&
|
||||
!doc.initialContent
|
||||
) {
|
||||
try {
|
||||
const loaded = await loadEntryByStoreId(doc.id);
|
||||
if (loaded) {
|
||||
@ -402,7 +470,11 @@
|
||||
} catch {
|
||||
// entry content will use initialContent fallback
|
||||
}
|
||||
} else if (effectiveSection === "entries" && doc.id.startsWith("entries/template-file/") && !doc.initialContent) {
|
||||
} else if (
|
||||
effectiveSection === "entries" &&
|
||||
doc.id.startsWith("entries/template-file/") &&
|
||||
!doc.initialContent
|
||||
) {
|
||||
try {
|
||||
const filePath = toTemplatePath(doc.id);
|
||||
if (filePath) {
|
||||
@ -410,7 +482,7 @@
|
||||
resolvedDoc = {
|
||||
id: doc.id,
|
||||
label: loaded.fileName.replace(/\.template\.md$/i, ""),
|
||||
initialContent: loaded.content
|
||||
initialContent: loaded.content,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
@ -419,7 +491,10 @@
|
||||
}
|
||||
|
||||
if (!(resolvedDoc.id in openDocuments)) {
|
||||
openDocuments = { ...openDocuments, [resolvedDoc.id]: resolvedDoc.initialContent };
|
||||
openDocuments = {
|
||||
...openDocuments,
|
||||
[resolvedDoc.id]: resolvedDoc.initialContent,
|
||||
};
|
||||
}
|
||||
activeDocumentId = resolvedDoc.id;
|
||||
activeDocumentLabel = resolvedDoc.label;
|
||||
@ -433,7 +508,7 @@
|
||||
await handleOpenDocument({
|
||||
id: target.id,
|
||||
label: target.label,
|
||||
initialContent: target.initialContent
|
||||
initialContent: target.initialContent,
|
||||
});
|
||||
}
|
||||
|
||||
@ -459,7 +534,9 @@
|
||||
ok = await deleteEntryTemplate(templatePath);
|
||||
if (ok) templateRefreshToken += 1;
|
||||
} else if (id.startsWith("entries/template-draft-")) {
|
||||
entriesStore.update((items) => items.filter((item) => item.id !== id));
|
||||
entriesStore.update((items) =>
|
||||
items.filter((item) => item.id !== id),
|
||||
);
|
||||
ok = true;
|
||||
} else {
|
||||
ok = await deleteEntryByStoreId(id);
|
||||
@ -500,7 +577,7 @@
|
||||
confirmText: "Delete",
|
||||
cancelText: "Cancel",
|
||||
showCancel: true,
|
||||
tone: "danger"
|
||||
tone: "danger",
|
||||
});
|
||||
}
|
||||
|
||||
@ -508,8 +585,12 @@
|
||||
setFlushCallback(saveCurrentDocument);
|
||||
void (async () => {
|
||||
await hydrateUiSettings();
|
||||
const startupSection = resolveStartupSection(get(settingsDefaultStartupView));
|
||||
const sectionFromQuery = parseSectionQuery(new URLSearchParams(window.location.search).get("section"));
|
||||
const startupSection = resolveStartupSection(
|
||||
get(settingsDefaultStartupView),
|
||||
);
|
||||
const sectionFromQuery = parseSectionQuery(
|
||||
new URLSearchParams(window.location.search).get("section"),
|
||||
);
|
||||
applyStartupSection(sectionFromQuery ?? startupSection);
|
||||
await bootstrapFragmentsWithUnlock();
|
||||
})();
|
||||
@ -569,6 +650,7 @@
|
||||
<style>
|
||||
.app-shell {
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
<!-- @format -->
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import AppModal from "$lib/components/AppModal.svelte";
|
||||
@ -12,7 +13,7 @@
|
||||
settingsFragmentTypes,
|
||||
settingsTags,
|
||||
updateFragmentType,
|
||||
updateSettingsTag
|
||||
updateSettingsTag,
|
||||
} from "$lib/stores/settings";
|
||||
import { invoke, isTauriRuntime } from "$lib/runtime/invoke";
|
||||
import { onMount } from "svelte";
|
||||
@ -40,8 +41,16 @@
|
||||
let returnSection = "entries";
|
||||
|
||||
onMount(async () => {
|
||||
const queryReturn = new URLSearchParams(window.location.search).get("return")?.trim().toLowerCase() ?? "";
|
||||
if (["entries", "calendar", "fragments", "todos", "lists"].includes(queryReturn)) {
|
||||
const queryReturn =
|
||||
new URLSearchParams(window.location.search)
|
||||
.get("return")
|
||||
?.trim()
|
||||
.toLowerCase() ?? "";
|
||||
if (
|
||||
["entries", "calendar", "fragments", "todos", "lists"].includes(
|
||||
queryReturn,
|
||||
)
|
||||
) {
|
||||
returnSection = queryReturn;
|
||||
}
|
||||
|
||||
@ -57,7 +66,9 @@
|
||||
async function saveSidecarRoot() {
|
||||
sidecarRootError = "";
|
||||
try {
|
||||
const result: any = await invoke("set_sidecar_root", { path: sidecarRoot });
|
||||
const result: any = await invoke("set_sidecar_root", {
|
||||
path: sidecarRoot,
|
||||
});
|
||||
sidecarRoot = result.root;
|
||||
sidecarRootIsCustom = result.isCustom;
|
||||
} catch (e) {
|
||||
@ -88,7 +99,7 @@
|
||||
const picked = await open({
|
||||
directory: true,
|
||||
multiple: false,
|
||||
title: "Select Sidecar Root Directory"
|
||||
title: "Select Sidecar Root Directory",
|
||||
});
|
||||
if (typeof picked === "string" && picked.trim()) {
|
||||
sidecarRoot = picked;
|
||||
@ -131,7 +142,7 @@
|
||||
action: "logout-info",
|
||||
title: "Logout Requested",
|
||||
message: "You have been logged out.",
|
||||
confirmText: "Close"
|
||||
confirmText: "Close",
|
||||
});
|
||||
return;
|
||||
}
|
||||
@ -148,7 +159,7 @@
|
||||
confirmText: "Log Out",
|
||||
cancelText: "Cancel",
|
||||
showCancel: true,
|
||||
tone: "danger"
|
||||
tone: "danger",
|
||||
});
|
||||
return;
|
||||
}
|
||||
@ -213,7 +224,9 @@
|
||||
|
||||
function saveEditFragmentType() {
|
||||
if (editingFragmentTypeIndex === null) return;
|
||||
if (updateFragmentType(editingFragmentTypeIndex, editingFragmentTypeValue)) {
|
||||
if (
|
||||
updateFragmentType(editingFragmentTypeIndex, editingFragmentTypeValue)
|
||||
) {
|
||||
editingFragmentTypeIndex = null;
|
||||
editingFragmentTypeValue = "";
|
||||
}
|
||||
@ -234,7 +247,6 @@
|
||||
function updateDefaultStartupView(value: string) {
|
||||
setDefaultStartupView(value);
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<div class="app-shell panel-closed">
|
||||
@ -246,156 +258,225 @@
|
||||
<h1>Settings</h1>
|
||||
<p>Configure app behavior and interface options.</p>
|
||||
</div>
|
||||
<button type="button" class="header-close-btn" on:click={closeSettings} aria-label="Close settings">
|
||||
<button
|
||||
type="button"
|
||||
class="header-close-btn"
|
||||
on:click={closeSettings}
|
||||
aria-label="Close settings"
|
||||
>
|
||||
<span class="material-symbols-outlined" aria-hidden="true">close</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="settings-grid">
|
||||
<section class="route-card">
|
||||
<div class="card-head">
|
||||
<h2 class="card-title">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">rocket_launch</span>
|
||||
Startup
|
||||
</h2>
|
||||
</div>
|
||||
<label>
|
||||
Default startup view
|
||||
<select
|
||||
value={$settingsDefaultStartupView}
|
||||
on:change={(event) => updateDefaultStartupView((event.currentTarget as HTMLSelectElement).value)}
|
||||
>
|
||||
<option value="entries">Entries</option>
|
||||
<option value="calendar">Calendar</option>
|
||||
<option value="fragments">Fragments</option>
|
||||
<option value="todos">To-Do List</option>
|
||||
<option value="lists">Lists</option>
|
||||
</select>
|
||||
</label>
|
||||
</section>
|
||||
<section class="route-card">
|
||||
<div class="card-head">
|
||||
<h2 class="card-title">
|
||||
<span class="material-symbols-outlined" aria-hidden="true"
|
||||
>rocket_launch</span
|
||||
>
|
||||
Startup
|
||||
</h2>
|
||||
</div>
|
||||
<label>
|
||||
Default startup view
|
||||
<select
|
||||
value={$settingsDefaultStartupView}
|
||||
on:change={(event) =>
|
||||
updateDefaultStartupView(
|
||||
(event.currentTarget as HTMLSelectElement).value,
|
||||
)}
|
||||
>
|
||||
<option value="entries">Entries</option>
|
||||
<option value="calendar">Calendar</option>
|
||||
<option value="fragments">Fragments</option>
|
||||
<option value="todos">To-Do List</option>
|
||||
<option value="lists">Lists</option>
|
||||
</select>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="route-card">
|
||||
<div class="card-head">
|
||||
<h2 class="card-title">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">label</span>
|
||||
Tags
|
||||
</h2>
|
||||
<p class="section-copy">Add and manage tags used for notes and entries.</p>
|
||||
</div>
|
||||
<section class="route-card list-card">
|
||||
<div class="card-head">
|
||||
<h2 class="card-title">
|
||||
<span class="material-symbols-outlined" aria-hidden="true"
|
||||
>label</span
|
||||
>
|
||||
Tags
|
||||
</h2>
|
||||
<p class="section-copy">
|
||||
Add and manage tags used for notes and entries.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="create-row">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Add tag (example: Research)"
|
||||
bind:value={newTag}
|
||||
on:keydown={(event) => event.key === "Enter" && addTag()}
|
||||
/>
|
||||
<button type="button" class="secondary-btn" on:click={addTag}>Add</button>
|
||||
</div>
|
||||
<div class="create-row">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Add tag (example: Research)"
|
||||
bind:value={newTag}
|
||||
on:keydown={(event) => event.key === "Enter" && addTag()}
|
||||
/>
|
||||
<button type="button" class="secondary-btn" on:click={addTag}
|
||||
>Add</button
|
||||
>
|
||||
</div>
|
||||
|
||||
<ul class="item-list">
|
||||
{#each $settingsTags as tag, index}
|
||||
<li class="item-row">
|
||||
{#if editingTagIndex === index}
|
||||
<input
|
||||
type="text"
|
||||
bind:value={editingTagValue}
|
||||
on:keydown={(event) => {
|
||||
if (event.key === "Enter") saveEditTag();
|
||||
if (event.key === "Escape") cancelEditTag();
|
||||
}}
|
||||
/>
|
||||
<div class="row-actions">
|
||||
<button type="button" class="secondary-btn" on:click={saveEditTag}>Save</button>
|
||||
<button type="button" class="ghost-btn" on:click={cancelEditTag}>Cancel</button>
|
||||
</div>
|
||||
{:else}
|
||||
<span>{tag}</span>
|
||||
<div class="row-actions">
|
||||
<button type="button" class="ghost-btn" on:click={() => startEditTag(index, tag)}>Edit</button>
|
||||
<button type="button" class="danger-btn" on:click={() => removeTag(index)}>Remove</button>
|
||||
</div>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
<ul class="item-list">
|
||||
{#each $settingsTags as tag, index}
|
||||
<li class="item-row">
|
||||
{#if editingTagIndex === index}
|
||||
<input
|
||||
type="text"
|
||||
bind:value={editingTagValue}
|
||||
on:keydown={(event) => {
|
||||
if (event.key === "Enter") saveEditTag();
|
||||
if (event.key === "Escape") cancelEditTag();
|
||||
}}
|
||||
/>
|
||||
<div class="row-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="secondary-btn"
|
||||
on:click={saveEditTag}>Save</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="ghost-btn"
|
||||
on:click={cancelEditTag}>Cancel</button
|
||||
>
|
||||
</div>
|
||||
{:else}
|
||||
<span>{tag}</span>
|
||||
<div class="row-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="ghost-btn"
|
||||
on:click={() => startEditTag(index, tag)}>Edit</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="danger-btn"
|
||||
on:click={() => removeTag(index)}>Remove</button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="route-card">
|
||||
<div class="card-head">
|
||||
<h2 class="card-title">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">category</span>
|
||||
Fragment Types
|
||||
</h2>
|
||||
<p class="section-copy">Configure custom fragment types for the Fragments section.</p>
|
||||
</div>
|
||||
<section class="route-card list-card">
|
||||
<div class="card-head">
|
||||
<h2 class="card-title">
|
||||
<span class="material-symbols-outlined" aria-hidden="true"
|
||||
>category</span
|
||||
>
|
||||
Fragment Types
|
||||
</h2>
|
||||
<p class="section-copy">
|
||||
Configure custom fragment types for the Fragments section.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="create-row">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Add fragment type (example: Observation)"
|
||||
bind:value={newFragmentType}
|
||||
on:keydown={(event) => event.key === "Enter" && addFragmentTypeLocal()}
|
||||
/>
|
||||
<button type="button" class="secondary-btn" on:click={addFragmentTypeLocal}>Add</button>
|
||||
</div>
|
||||
<div class="create-row">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Add fragment type (example: Observation)"
|
||||
bind:value={newFragmentType}
|
||||
on:keydown={(event) =>
|
||||
event.key === "Enter" && addFragmentTypeLocal()}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="secondary-btn"
|
||||
on:click={addFragmentTypeLocal}>Add</button
|
||||
>
|
||||
</div>
|
||||
|
||||
<ul class="item-list">
|
||||
{#each $settingsFragmentTypes as type, index}
|
||||
<li class="item-row">
|
||||
{#if editingFragmentTypeIndex === index}
|
||||
<input
|
||||
type="text"
|
||||
bind:value={editingFragmentTypeValue}
|
||||
on:keydown={(event) => {
|
||||
if (event.key === "Enter") saveEditFragmentType();
|
||||
if (event.key === "Escape") cancelEditFragmentType();
|
||||
}}
|
||||
/>
|
||||
<div class="row-actions">
|
||||
<button type="button" class="secondary-btn" on:click={saveEditFragmentType}>Save</button>
|
||||
<button type="button" class="ghost-btn" on:click={cancelEditFragmentType}>Cancel</button>
|
||||
</div>
|
||||
{:else}
|
||||
<span>{type}</span>
|
||||
<div class="row-actions">
|
||||
<button type="button" class="ghost-btn" on:click={() => startEditFragmentType(index, type)}>Edit</button>
|
||||
<button type="button" class="danger-btn" on:click={() => removeFragmentTypeLocal(index)}>Remove</button>
|
||||
</div>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
<ul class="item-list">
|
||||
{#each $settingsFragmentTypes as type, index}
|
||||
<li class="item-row">
|
||||
{#if editingFragmentTypeIndex === index}
|
||||
<input
|
||||
type="text"
|
||||
bind:value={editingFragmentTypeValue}
|
||||
on:keydown={(event) => {
|
||||
if (event.key === "Enter") saveEditFragmentType();
|
||||
if (event.key === "Escape") cancelEditFragmentType();
|
||||
}}
|
||||
/>
|
||||
<div class="row-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="secondary-btn"
|
||||
on:click={saveEditFragmentType}>Save</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="ghost-btn"
|
||||
on:click={cancelEditFragmentType}>Cancel</button
|
||||
>
|
||||
</div>
|
||||
{:else}
|
||||
<span>{type}</span>
|
||||
<div class="row-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="ghost-btn"
|
||||
on:click={() => startEditFragmentType(index, type)}
|
||||
>Edit</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="danger-btn"
|
||||
on:click={() => removeFragmentTypeLocal(index)}
|
||||
>Remove</button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="route-card">
|
||||
<div class="card-head">
|
||||
<h2 class="card-title">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">hub</span>
|
||||
Sidecar
|
||||
</h2>
|
||||
<p class="section-copy">Root directory containing the Journal.Sidecar project.</p>
|
||||
</div>
|
||||
<section class="route-card">
|
||||
<div class="card-head">
|
||||
<h2 class="card-title">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">hub</span
|
||||
>
|
||||
Sidecar
|
||||
</h2>
|
||||
<p class="section-copy">
|
||||
Root directory containing the Journal.Sidecar project.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="create-row">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Auto-detected from working directory"
|
||||
bind:value={sidecarRoot}
|
||||
on:keydown={(event) => event.key === "Enter" && saveSidecarRoot()}
|
||||
/>
|
||||
<button type="button" class="ghost-btn" on:click={browseSidecarRoot} disabled={sidecarBrowseBusy}>
|
||||
{sidecarBrowseBusy ? "Browsing..." : "Browse"}
|
||||
</button>
|
||||
{#if sidecarRootIsCustom}
|
||||
<button type="button" class="ghost-btn" on:click={resetSidecarRoot}>Reset</button>
|
||||
<div class="create-row">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Auto-detected from working directory"
|
||||
bind:value={sidecarRoot}
|
||||
on:keydown={(event) => event.key === "Enter" && saveSidecarRoot()}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="ghost-btn"
|
||||
on:click={browseSidecarRoot}
|
||||
disabled={sidecarBrowseBusy}
|
||||
>
|
||||
{sidecarBrowseBusy ? "Browsing..." : "Browse"}
|
||||
</button>
|
||||
{#if sidecarRootIsCustom}
|
||||
<button type="button" class="ghost-btn" on:click={resetSidecarRoot}
|
||||
>Reset</button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if sidecarRootError}
|
||||
<p class="error-text">{sidecarRootError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if sidecarRootError}
|
||||
<p class="error-text">{sidecarRootError}</p>
|
||||
{/if}
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
@ -414,11 +495,14 @@
|
||||
|
||||
<style>
|
||||
.route-view {
|
||||
min-height: 100vh;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
min-height: 0;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
overflow: hidden;
|
||||
background: var(--bg-editor);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
@ -459,7 +543,11 @@
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
transition: background-color 120ms ease, color 120ms ease, border-color 120ms ease, transform 120ms ease;
|
||||
transition:
|
||||
background-color 120ms ease,
|
||||
color 120ms ease,
|
||||
border-color 120ms ease,
|
||||
transform 120ms ease;
|
||||
}
|
||||
|
||||
.header-close-btn .material-symbols-outlined {
|
||||
@ -474,8 +562,11 @@
|
||||
}
|
||||
|
||||
.settings-grid {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
columns: 2;
|
||||
column-gap: 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.route-card {
|
||||
@ -487,12 +578,19 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
inset 0 1px 0 color-mix(in srgb, var(--zinc-300) 8%, transparent 92%),
|
||||
0 8px 24px color-mix(in srgb, var(--bg-app) 32%, transparent 68%);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.list-card {
|
||||
overflow: hidden;
|
||||
max-height: clamp(280px, 46vh, 520px);
|
||||
}
|
||||
|
||||
.card-head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@ -527,7 +625,11 @@
|
||||
.route-card select {
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 8px;
|
||||
background-color: color-mix(in srgb, var(--surface-2) 88%, var(--bg-editor) 12%);
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--surface-2) 88%,
|
||||
var(--bg-editor) 12%
|
||||
);
|
||||
color: var(--text-primary);
|
||||
padding: 9px 34px 9px 10px;
|
||||
font-size: 0.84rem;
|
||||
@ -555,6 +657,10 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.item-row {
|
||||
@ -598,7 +704,11 @@
|
||||
letter-spacing: 0.01em;
|
||||
cursor: pointer;
|
||||
color: var(--text-primary);
|
||||
transition: background-color 120ms ease, color 120ms ease, border-color 120ms ease, transform 120ms ease;
|
||||
transition:
|
||||
background-color 120ms ease,
|
||||
color 120ms ease,
|
||||
border-color 120ms ease,
|
||||
transform 120ms ease;
|
||||
}
|
||||
|
||||
.secondary-btn {
|
||||
@ -633,6 +743,12 @@
|
||||
@media (max-width: 1100px) {
|
||||
.settings-grid {
|
||||
columns: 1;
|
||||
overflow: auto;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.list-card {
|
||||
max-height: min(42dvh, 460px);
|
||||
}
|
||||
}
|
||||
|
||||
@ -660,16 +776,24 @@
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.list-card {
|
||||
max-height: min(48dvh, 360px);
|
||||
}
|
||||
|
||||
.item-row {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.item-row input,
|
||||
.route-card input {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.row-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
:root {
|
||||
font-family: "Segoe UI Variable", "Segoe UI", Inter, Roboto, Helvetica, Arial, sans-serif;
|
||||
font-family:
|
||||
"Segoe UI Variable", "Segoe UI", Inter, Roboto, Helvetica, Arial, sans-serif;
|
||||
font-size: 15px;
|
||||
line-height: 1.45;
|
||||
font-weight: 400;
|
||||
@ -51,7 +52,11 @@ body {
|
||||
}
|
||||
|
||||
body {
|
||||
background: radial-gradient(circle at 15% -10%, var(--zinc-800) 0%, var(--bg-app) 42%);
|
||||
background: radial-gradient(
|
||||
circle at 15% -10%,
|
||||
var(--zinc-800) 0%,
|
||||
var(--bg-app) 42%
|
||||
);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
@ -84,6 +89,7 @@ select {
|
||||
|
||||
.app-shell {
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
display: grid;
|
||||
grid-template-columns: 72px 300px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user