Polish editors/settings UI and wire startup view + navigation
This commit is contained in:
parent
8b766a54f2
commit
0dde7c40f3
10
Journal.App/package-lock.json
generated
10
Journal.App/package-lock.json
generated
@ -10,6 +10,7 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
"@tauri-apps/plugin-opener": "^2"
|
||||
},
|
||||
"devDependencies": {
|
||||
@ -1208,6 +1209,15 @@
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-dialog": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.6.0.tgz",
|
||||
"integrity": "sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-opener": {
|
||||
"version": "2.5.3",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.3.tgz",
|
||||
|
||||
@ -14,16 +14,17 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
"@tauri-apps/plugin-opener": "^2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-static": "^3.0.6",
|
||||
"@sveltejs/kit": "^2.9.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@tauri-apps/cli": "^2",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "^6.0.3",
|
||||
"@tauri-apps/cli": "^2"
|
||||
"vite": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
67
Journal.App/src-tauri/Cargo.lock
generated
67
Journal.App/src-tauri/Cargo.lock
generated
@ -698,6 +698,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"block2",
|
||||
"libc",
|
||||
"objc2",
|
||||
]
|
||||
|
||||
@ -1776,6 +1778,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-opener",
|
||||
"tokio",
|
||||
]
|
||||
@ -2940,6 +2943,30 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rfd"
|
||||
version = "0.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
|
||||
dependencies = [
|
||||
"block2",
|
||||
"dispatch2",
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"gtk-sys",
|
||||
"js-sys",
|
||||
"log",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation",
|
||||
"raw-window-handle",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc_version"
|
||||
version = "0.4.1"
|
||||
@ -3626,6 +3653,46 @@ dependencies = [
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-dialog"
|
||||
version = "2.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9204b425d9be8d12aa60c2a83a289cf7d1caae40f57f336ed1155b3a5c0e359b"
|
||||
dependencies = [
|
||||
"log",
|
||||
"raw-window-handle",
|
||||
"rfd",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tauri-plugin-fs",
|
||||
"thiserror 2.0.18",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-fs"
|
||||
version = "2.4.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed390cc669f937afeb8b28032ce837bac8ea023d975a2e207375ec05afaf1804"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dunce",
|
||||
"glob",
|
||||
"percent-encoding",
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tauri-utils",
|
||||
"thiserror 2.0.18",
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-opener"
|
||||
version = "2.5.3"
|
||||
|
||||
@ -19,6 +19,7 @@ tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-opener = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"dialog:default",
|
||||
"opener:default"
|
||||
]
|
||||
}
|
||||
|
||||
@ -28,6 +28,7 @@ struct CommandEnvelope {
|
||||
|
||||
const DEFAULT_SETTINGS_TAGS: &[&str] = &["Personal", "Work", "Ideas", "Journal"];
|
||||
const DEFAULT_FRAGMENT_TYPES: &[&str] = &["Quote", "Snippet", "Reference"];
|
||||
const DEFAULT_STARTUP_VIEW: &str = "entries";
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
struct AppSettings {
|
||||
@ -37,6 +38,8 @@ struct AppSettings {
|
||||
tags: Vec<String>,
|
||||
#[serde(default = "default_fragment_types")]
|
||||
fragment_types: Vec<String>,
|
||||
#[serde(default = "default_startup_view")]
|
||||
default_startup_view: String,
|
||||
}
|
||||
|
||||
impl Default for AppSettings {
|
||||
@ -45,6 +48,7 @@ impl Default for AppSettings {
|
||||
sidecar_root: None,
|
||||
tags: default_settings_tags(),
|
||||
fragment_types: default_fragment_types(),
|
||||
default_startup_view: default_startup_view(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -63,6 +67,10 @@ fn default_fragment_types() -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn default_startup_view() -> String {
|
||||
DEFAULT_STARTUP_VIEW.to_string()
|
||||
}
|
||||
|
||||
fn normalize_items(values: Vec<String>, fallback: &[&str]) -> Vec<String> {
|
||||
let mut seen = HashSet::new();
|
||||
let mut normalized = Vec::new();
|
||||
@ -84,6 +92,17 @@ fn normalize_items(values: Vec<String>, fallback: &[&str]) -> Vec<String> {
|
||||
normalized
|
||||
}
|
||||
|
||||
fn normalize_startup_view(value: Option<String>) -> String {
|
||||
let normalized = value
|
||||
.unwrap_or_else(default_startup_view)
|
||||
.trim()
|
||||
.to_lowercase();
|
||||
match normalized.as_str() {
|
||||
"entries" | "calendar" | "fragments" | "todos" | "lists" => normalized,
|
||||
_ => default_startup_view(),
|
||||
}
|
||||
}
|
||||
|
||||
struct ManagedSidecar {
|
||||
child: Child,
|
||||
stdin: ChildStdin,
|
||||
@ -363,10 +382,12 @@ async fn get_ui_settings(state: tauri::State<'_, SidecarState>) -> Result<Value,
|
||||
let settings = load_settings(&state.config_path);
|
||||
let tags = normalize_items(settings.tags, DEFAULT_SETTINGS_TAGS);
|
||||
let fragment_types = normalize_items(settings.fragment_types, DEFAULT_FRAGMENT_TYPES);
|
||||
let startup_view = normalize_startup_view(Some(settings.default_startup_view));
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"tags": tags,
|
||||
"fragmentTypes": fragment_types
|
||||
"fragmentTypes": fragment_types,
|
||||
"defaultStartupView": startup_view
|
||||
}))
|
||||
}
|
||||
|
||||
@ -375,15 +396,18 @@ async fn set_ui_settings(
|
||||
state: tauri::State<'_, SidecarState>,
|
||||
tags: Vec<String>,
|
||||
fragment_types: Vec<String>,
|
||||
default_startup_view: Option<String>,
|
||||
) -> Result<Value, String> {
|
||||
let mut settings = load_settings(&state.config_path);
|
||||
settings.tags = normalize_items(tags, DEFAULT_SETTINGS_TAGS);
|
||||
settings.fragment_types = normalize_items(fragment_types, DEFAULT_FRAGMENT_TYPES);
|
||||
settings.default_startup_view = normalize_startup_view(default_startup_view);
|
||||
save_settings(&state.config_path, &settings)?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"tags": settings.tags,
|
||||
"fragmentTypes": settings.fragment_types
|
||||
"fragmentTypes": settings.fragment_types,
|
||||
"defaultStartupView": settings.default_startup_view
|
||||
}))
|
||||
}
|
||||
|
||||
@ -416,6 +440,7 @@ async fn sidecar_command(
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let app = tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
sidecar_command,
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import FragmentEditor from "$lib/components/editor/FragmentEditor.svelte";
|
||||
import ListEditor from "$lib/components/editor/ListEditor.svelte";
|
||||
import TodoEditor from "$lib/components/editor/TodoEditor.svelte";
|
||||
import MarkdownEditor from "$lib/components/editor/MarkdownEditor.svelte";
|
||||
|
||||
@ -36,6 +37,13 @@
|
||||
{openDocumentContent}
|
||||
{onDocumentContentChange}
|
||||
/>
|
||||
{:else if activeSection === "lists"}
|
||||
<ListEditor
|
||||
{openDocumentId}
|
||||
{openDocumentName}
|
||||
{openDocumentContent}
|
||||
{onDocumentContentChange}
|
||||
/>
|
||||
{:else}
|
||||
<MarkdownEditor
|
||||
{openDocumentId}
|
||||
@ -49,7 +57,7 @@
|
||||
|
||||
<style>
|
||||
.editor-panel {
|
||||
background: linear-gradient(180deg, var(--surface-2) 0%, var(--bg-editor) 100%);
|
||||
background: var(--bg-editor);
|
||||
padding: 18px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@ -208,6 +208,29 @@
|
||||
queueMicrotask(() => newItemInput?.focus());
|
||||
}
|
||||
|
||||
function toDailyNoteLabel(date: Date): string {
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function handleAddDailyNote() {
|
||||
if (activeSection !== "entries") return;
|
||||
createTemplateMode = false;
|
||||
showNewItemInput = false;
|
||||
newItemName = "";
|
||||
|
||||
const label = toDailyNoteLabel(new Date());
|
||||
const existing = $entriesStore.find((item) => item.label === label && !item.id.startsWith("entries/template-draft-"));
|
||||
if (existing) {
|
||||
onEditItem(existing);
|
||||
return;
|
||||
}
|
||||
|
||||
const id = `entries/draft-${Date.now()}`;
|
||||
const item = { id, label, initialContent: `# ${label}\n\n` };
|
||||
entriesStore.update((items) => [item, ...items]);
|
||||
onEditItem(item);
|
||||
}
|
||||
|
||||
async function confirmNewItem() {
|
||||
const label = newItemName.trim();
|
||||
if (!label) {
|
||||
@ -319,9 +342,12 @@
|
||||
<h2>{panelTitle}</h2>
|
||||
<div class="panel-header-actions">
|
||||
{#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 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}>
|
||||
<span class="material-symbols-outlined">add</span>
|
||||
|
||||
@ -31,6 +31,7 @@
|
||||
let lastFragmentDocumentId = "";
|
||||
let fragmentTypeOptions: string[] = [];
|
||||
let tagOptions: string[] = [];
|
||||
let suppressExternalEditRequest = false;
|
||||
const customTypeValue = "__custom_type__";
|
||||
const customTagValue = "__custom_tag__";
|
||||
|
||||
@ -129,10 +130,12 @@
|
||||
function cancelFragmentEdit() {
|
||||
if (fragmentMode === "create") {
|
||||
fragmentMode = "view";
|
||||
suppressExternalEditRequest = true;
|
||||
return;
|
||||
}
|
||||
loadFragmentFormFromDocument();
|
||||
fragmentMode = "view";
|
||||
suppressExternalEditRequest = true;
|
||||
}
|
||||
|
||||
function loadFragmentFormFromDocument() {
|
||||
@ -184,7 +187,10 @@
|
||||
$: if (!fragmentTag || (!tagOptions.includes(fragmentTag) && fragmentTag !== customTagValue)) {
|
||||
fragmentTag = tagOptions[0] ?? customTagValue;
|
||||
}
|
||||
$: if (externalEditRequested && fragmentMode === "view") {
|
||||
$: if (!externalEditRequested) {
|
||||
suppressExternalEditRequest = false;
|
||||
}
|
||||
$: if (externalEditRequested && !suppressExternalEditRequest && fragmentMode === "view") {
|
||||
fragmentMode = "edit";
|
||||
}
|
||||
</script>
|
||||
@ -195,7 +201,10 @@
|
||||
{@html renderMarkdown(openDocumentContent)}
|
||||
</article>
|
||||
{:else}
|
||||
<form class="fragment-form" on:submit|preventDefault={fragmentMode === "create" ? createNewFragment : saveFragmentEdits}>
|
||||
<form
|
||||
class="fragment-form"
|
||||
on:submit|preventDefault={fragmentMode === "create" ? createNewFragment : saveFragmentEdits}
|
||||
>
|
||||
<h2>{fragmentMode === "create" ? "New Fragment" : "Edit Fragment"}</h2>
|
||||
<input type="text" placeholder="Title" bind:value={fragmentTitle} aria-label="Fragment title" />
|
||||
<div class="fragment-form-row">
|
||||
@ -254,34 +263,35 @@
|
||||
.fragment-surface {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
padding: 8px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
overflow: auto;
|
||||
padding: 0 14px 14px;
|
||||
}
|
||||
|
||||
.fragment-form {
|
||||
width: min(760px, 100%);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 12px;
|
||||
background: var(--surface-1);
|
||||
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.25);
|
||||
padding: 14px;
|
||||
width: min(100%, 920px);
|
||||
margin: 0 auto;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
padding: 28px 36px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.fragment-view {
|
||||
width: min(760px, 100%);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 12px;
|
||||
background: var(--surface-1);
|
||||
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.25);
|
||||
padding: 14px;
|
||||
width: min(100%, 920px);
|
||||
margin: 0 auto;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
padding: 28px 36px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.fragment-view :global(h1),
|
||||
@ -311,9 +321,10 @@
|
||||
}
|
||||
|
||||
.fragment-form h2 {
|
||||
font-size: 0.94rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.fragment-form input,
|
||||
@ -322,15 +333,16 @@
|
||||
width: 100%;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-app);
|
||||
background: color-mix(in srgb, var(--surface-1) 88%, var(--bg-editor) 12%);
|
||||
color: var(--text-primary);
|
||||
padding: 9px 10px;
|
||||
font-size: 0.86rem;
|
||||
padding: 10px 11px;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.fragment-form textarea {
|
||||
resize: vertical;
|
||||
min-height: 160px;
|
||||
min-height: 220px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.fragment-form-row {
|
||||
@ -343,7 +355,7 @@
|
||||
width: fit-content;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-strong);
|
||||
background: var(--surface-3);
|
||||
background: color-mix(in srgb, var(--surface-2) 84%, var(--bg-hover) 16%);
|
||||
color: var(--text-primary);
|
||||
padding: 8px 12px;
|
||||
font-size: 0.82rem;
|
||||
@ -363,7 +375,7 @@
|
||||
.fragment-secondary {
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-soft);
|
||||
background: var(--surface-1);
|
||||
background: color-mix(in srgb, var(--surface-1) 90%, var(--bg-editor) 10%);
|
||||
color: var(--text-muted);
|
||||
padding: 8px 12px;
|
||||
font-size: 0.82rem;
|
||||
@ -374,4 +386,21 @@
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.fragment-surface {
|
||||
padding: 4px 8px 10px;
|
||||
}
|
||||
|
||||
.fragment-form,
|
||||
.fragment-view {
|
||||
width: 100%;
|
||||
padding: 18px 16px;
|
||||
font-size: 0.89rem;
|
||||
}
|
||||
|
||||
.fragment-form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
278
Journal.App/src/lib/components/editor/ListEditor.svelte
Normal file
278
Journal.App/src/lib/components/editor/ListEditor.svelte
Normal file
@ -0,0 +1,278 @@
|
||||
<script lang="ts">
|
||||
export let openDocumentId = "";
|
||||
export let openDocumentName = "";
|
||||
export let openDocumentContent = "";
|
||||
export let onDocumentContentChange: (content: string) => void = () => {};
|
||||
|
||||
type SimpleListItem = {
|
||||
id: number;
|
||||
text: string;
|
||||
};
|
||||
|
||||
let items: SimpleListItem[] = [];
|
||||
let nextItemId = 1;
|
||||
let lastDocumentId = "";
|
||||
let newItemText = "";
|
||||
let editingItemId: number | null = null;
|
||||
let editingItemText = "";
|
||||
|
||||
function parseListItems(content: string): SimpleListItem[] {
|
||||
const lines = (content ?? "").split(/\r?\n/);
|
||||
const parsed: SimpleListItem[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const bullet = line.match(/^\s*(?:[-*+]|\d+\.)\s+(.+)$/);
|
||||
if (bullet) {
|
||||
parsed.push({ id: parsed.length + 1, text: bullet[1].trim() });
|
||||
continue;
|
||||
}
|
||||
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
parsed.push({ id: parsed.length + 1, text: trimmed });
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function serializeList(title: string, listItems: SimpleListItem[]): string {
|
||||
const heading = (title ?? "").trim() || "Untitled List";
|
||||
if (!listItems.length) {
|
||||
return `# ${heading}\n\n`;
|
||||
}
|
||||
|
||||
const body = listItems
|
||||
.map((item) => item.text.trim())
|
||||
.filter(Boolean)
|
||||
.map((text) => `- ${text}`)
|
||||
.join("\n");
|
||||
|
||||
return `# ${heading}\n\n${body}`;
|
||||
}
|
||||
|
||||
function persist() {
|
||||
onDocumentContentChange(serializeList(openDocumentName, items));
|
||||
}
|
||||
|
||||
function resetForDocument() {
|
||||
items = parseListItems(openDocumentContent);
|
||||
nextItemId = (items[items.length - 1]?.id ?? 0) + 1;
|
||||
newItemText = "";
|
||||
editingItemId = null;
|
||||
editingItemText = "";
|
||||
}
|
||||
|
||||
function addItem() {
|
||||
const text = newItemText.trim();
|
||||
if (!text) return;
|
||||
items = [{ id: nextItemId, text }, ...items];
|
||||
nextItemId += 1;
|
||||
newItemText = "";
|
||||
persist();
|
||||
}
|
||||
|
||||
function startEditItem(id: number) {
|
||||
const existing = items.find((item) => item.id === id);
|
||||
if (!existing) return;
|
||||
editingItemId = id;
|
||||
editingItemText = existing.text;
|
||||
}
|
||||
|
||||
function saveEditItem() {
|
||||
if (editingItemId === null) return;
|
||||
const text = editingItemText.trim();
|
||||
if (!text) return;
|
||||
const id = editingItemId;
|
||||
items = items.map((item) => (item.id === id ? { ...item, text } : item));
|
||||
editingItemId = null;
|
||||
editingItemText = "";
|
||||
persist();
|
||||
}
|
||||
|
||||
function cancelEditItem() {
|
||||
editingItemId = null;
|
||||
editingItemText = "";
|
||||
}
|
||||
|
||||
function removeItem(id: number) {
|
||||
if (editingItemId === id) {
|
||||
cancelEditItem();
|
||||
}
|
||||
items = items.filter((item) => item.id !== id);
|
||||
persist();
|
||||
}
|
||||
|
||||
$: if (openDocumentId !== lastDocumentId) {
|
||||
resetForDocument();
|
||||
lastDocumentId = openDocumentId;
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="list-surface">
|
||||
<div class="list-card">
|
||||
<form class="list-create" on:submit|preventDefault={addItem}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Add a list item"
|
||||
bind:value={newItemText}
|
||||
aria-label="Add list item"
|
||||
/>
|
||||
<button type="submit" class="list-add-btn">Add</button>
|
||||
</form>
|
||||
|
||||
<ul class="list-items">
|
||||
{#each items as item}
|
||||
<li class="list-item">
|
||||
{#if editingItemId === item.id}
|
||||
<input
|
||||
type="text"
|
||||
class="list-edit-input"
|
||||
bind:value={editingItemText}
|
||||
on:keydown={(event) => {
|
||||
if (event.key === "Enter") saveEditItem();
|
||||
if (event.key === "Escape") cancelEditItem();
|
||||
}}
|
||||
/>
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.list-surface {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 0 14px 14px;
|
||||
}
|
||||
|
||||
.list-card {
|
||||
width: min(100%, 920px);
|
||||
margin: 0 auto;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
padding: 28px 36px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
max-height: 100%;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.list-create {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.list-create input,
|
||||
.list-edit-input {
|
||||
width: 100%;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--surface-1) 88%, var(--bg-editor) 12%);
|
||||
color: var(--text-primary);
|
||||
padding: 10px 11px;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.list-add-btn {
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-strong);
|
||||
background: color-mix(in srgb, var(--surface-2) 84%, var(--bg-hover) 16%);
|
||||
color: var(--text-primary);
|
||||
padding: 9px 14px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.list-add-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.list-items {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.list-item {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
background: color-mix(in srgb, var(--surface-1) 90%, var(--bg-editor) 10%);
|
||||
}
|
||||
|
||||
.list-text {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.45;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.list-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.list-btn {
|
||||
border-radius: 7px;
|
||||
border: 1px solid var(--border-soft);
|
||||
background: color-mix(in srgb, var(--surface-1) 90%, var(--bg-editor) 10%);
|
||||
color: var(--text-muted);
|
||||
padding: 6px 10px;
|
||||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.list-btn.save {
|
||||
border-color: var(--border-strong);
|
||||
background: color-mix(in srgb, var(--surface-2) 84%, var(--bg-hover) 16%);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.list-btn.danger:hover,
|
||||
.list-btn.ghost:hover,
|
||||
.list-btn.save:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.list-surface {
|
||||
padding: 4px 8px 10px;
|
||||
}
|
||||
|
||||
.list-card {
|
||||
width: 100%;
|
||||
padding: 18px 16px;
|
||||
}
|
||||
|
||||
.list-item {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
row-gap: 8px;
|
||||
}
|
||||
|
||||
.list-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { listEntryTemplates, loadEntryTemplate, type EntryTemplateItemDto } from "$lib/backend/templates";
|
||||
import MarkdownToolbar from "$lib/components/editor/MarkdownToolbar.svelte";
|
||||
import { renderMarkdown, extractEditorTitle } from "$lib/utils/markdown";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
@ -8,6 +9,8 @@
|
||||
export let openDocumentContent = "";
|
||||
export let onDocumentContentChange: (content: string) => void = () => {};
|
||||
|
||||
type ListMode = "ul" | "ol" | null;
|
||||
|
||||
let markdownText = openDocumentContent;
|
||||
let lastOpenDocumentId = openDocumentId;
|
||||
export let previewOnly = true;
|
||||
@ -16,6 +19,7 @@
|
||||
let templatesBusy = false;
|
||||
let templateError = "";
|
||||
let templateRefreshRequested = false;
|
||||
let listMode: ListMode = null;
|
||||
|
||||
function updateDraft(value: string) {
|
||||
markdownText = value;
|
||||
@ -67,6 +71,51 @@
|
||||
applyWrap("[", "](https://example.com)");
|
||||
}
|
||||
|
||||
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 {
|
||||
if (mode === "ul") {
|
||||
return /^\s*[-*+]\s$/.test(line);
|
||||
}
|
||||
return /^\s*\d+\.\s$/.test(line);
|
||||
}
|
||||
|
||||
function handleMarkdownInput(event: Event) {
|
||||
const target = event.currentTarget as HTMLTextAreaElement;
|
||||
const nextValue = target.value;
|
||||
updateDraft(nextValue);
|
||||
|
||||
if (!listMode) return;
|
||||
const native = event as InputEvent;
|
||||
if (native.inputType !== "deleteContentBackward") return;
|
||||
|
||||
const cursor = target.selectionStart ?? 0;
|
||||
const lineStart = nextValue.lastIndexOf("\n", Math.max(0, cursor - 1)) + 1;
|
||||
const lineEndIndex = nextValue.indexOf("\n", cursor);
|
||||
const lineEnd = lineEndIndex === -1 ? nextValue.length : lineEndIndex;
|
||||
const line = nextValue.slice(lineStart, lineEnd);
|
||||
|
||||
if (!lineMatchesListMode(line, listMode)) {
|
||||
listMode = null;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleListMode(mode: Exclude<ListMode, null>) {
|
||||
if (listMode === mode) {
|
||||
listMode = null;
|
||||
return;
|
||||
}
|
||||
|
||||
listMode = mode;
|
||||
applyLinePrefix(mode === "ul" ? "- " : "1. ");
|
||||
queueMicrotask(() => editorInput?.focus());
|
||||
}
|
||||
|
||||
async function refreshTemplates() {
|
||||
templatesBusy = true;
|
||||
templateError = "";
|
||||
@ -111,6 +160,72 @@
|
||||
}
|
||||
}
|
||||
|
||||
function handleMarkdownKeydown(event: KeyboardEvent) {
|
||||
if (!editorInput) return;
|
||||
|
||||
if (event.key === "Escape" && listMode) {
|
||||
event.preventDefault();
|
||||
const current = markdownText;
|
||||
const start = editorInput.selectionStart ?? current.length;
|
||||
const lineStart = current.lastIndexOf("\n", Math.max(0, start - 1)) + 1;
|
||||
const lineEndIndex = current.indexOf("\n", start);
|
||||
const lineEnd = lineEndIndex === -1 ? current.length : lineEndIndex;
|
||||
const line = current.slice(lineStart, lineEnd);
|
||||
|
||||
if (isMarkerOnlyLine(line, listMode)) {
|
||||
const next = `${current.slice(0, lineStart)}${current.slice(lineEnd)}`;
|
||||
updateDraft(next);
|
||||
queueMicrotask(() => {
|
||||
if (!editorInput) return;
|
||||
editorInput.focus();
|
||||
editorInput.setSelectionRange(lineStart, lineStart);
|
||||
});
|
||||
}
|
||||
listMode = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key !== "Enter") return;
|
||||
const current = markdownText;
|
||||
const start = editorInput.selectionStart ?? current.length;
|
||||
const lineStart = current.lastIndexOf("\n", Math.max(0, start - 1)) + 1;
|
||||
const lineUntilCursor = current.slice(lineStart, start);
|
||||
|
||||
let effectiveListMode: ListMode = listMode;
|
||||
if (!effectiveListMode) {
|
||||
if (/^\s*[-*+]\s/.test(lineUntilCursor)) {
|
||||
effectiveListMode = "ul";
|
||||
} else if (/^\s*\d+\.\s/.test(lineUntilCursor)) {
|
||||
effectiveListMode = "ol";
|
||||
}
|
||||
}
|
||||
|
||||
if (!effectiveListMode) return;
|
||||
event.preventDefault();
|
||||
|
||||
let marker = "- ";
|
||||
if (effectiveListMode === "ol") {
|
||||
const match = lineUntilCursor.match(/^(\s*)(\d+)\.\s/);
|
||||
marker = match ? `${match[1]}${Number(match[2]) + 1}. ` : "1. ";
|
||||
} else {
|
||||
const match = lineUntilCursor.match(/^(\s*)[-*+]\s/);
|
||||
marker = match ? `${match[1]}- ` : "- ";
|
||||
}
|
||||
|
||||
listMode = effectiveListMode;
|
||||
|
||||
const insertion = `\n${marker}`;
|
||||
const next = `${current.slice(0, start)}${insertion}${current.slice(start)}`;
|
||||
updateDraft(next);
|
||||
|
||||
queueMicrotask(() => {
|
||||
if (!editorInput) return;
|
||||
const cursor = start + insertion.length;
|
||||
editorInput.focus();
|
||||
editorInput.setSelectionRange(cursor, cursor);
|
||||
});
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
void refreshTemplates();
|
||||
});
|
||||
@ -127,6 +242,7 @@
|
||||
$: if (openDocumentId !== lastOpenDocumentId) {
|
||||
markdownText = openDocumentContent;
|
||||
lastOpenDocumentId = openDocumentId;
|
||||
listMode = null;
|
||||
}
|
||||
$: isEntryDocument = openDocumentId.startsWith("entries/file/") || openDocumentId.startsWith("entries/draft-");
|
||||
$: editorTitle = extractEditorTitle(markdownText, openDocumentName);
|
||||
@ -139,52 +255,20 @@
|
||||
|
||||
<section class="editor-surface" class:preview-only={previewOnly}>
|
||||
{#if !previewOnly}
|
||||
<div class="editor-toolbar">
|
||||
<select
|
||||
class="toolbar-select"
|
||||
aria-label="Header size"
|
||||
on:change={(event) => {
|
||||
const target = event.currentTarget as HTMLSelectElement;
|
||||
const level = Number(target.value);
|
||||
if (level) applyHeading(level);
|
||||
target.value = "";
|
||||
}}
|
||||
>
|
||||
<option value="">Heading</option>
|
||||
<option value="1">H1</option>
|
||||
<option value="2">H2</option>
|
||||
<option value="3">H3</option>
|
||||
<option value="4">H4</option>
|
||||
<option value="5">H5</option>
|
||||
<option value="6">H6</option>
|
||||
</select>
|
||||
{#if isEntryDocument}
|
||||
<select
|
||||
class="toolbar-select"
|
||||
aria-label="Insert template"
|
||||
disabled={templatesBusy}
|
||||
on:change={(event) => {
|
||||
const target = event.currentTarget as HTMLSelectElement;
|
||||
const filePath = target.value;
|
||||
if (filePath) {
|
||||
void applyTemplateByPath(filePath);
|
||||
}
|
||||
target.value = "";
|
||||
}}
|
||||
>
|
||||
<option value="">{templatesBusy ? "Loading templates..." : "Template"}</option>
|
||||
{#each templateOptions as template}
|
||||
<option value={template.filePath}>{template.fileName.replace(/\.template\.md$/i, "")}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
<button type="button" on:click={() => applyWrap("**")}>Bold</button>
|
||||
<button type="button" on:click={() => applyWrap("*")}>Italic</button>
|
||||
<button type="button" on:click={insertLink}>Link</button>
|
||||
<button type="button" on:click={() => applyLinePrefix("- ")}>UL</button>
|
||||
<button type="button" on:click={() => applyLinePrefix("1. ")}>OL</button>
|
||||
<button type="button" on:click={() => applyWrap("`")}>Code</button>
|
||||
</div>
|
||||
<MarkdownToolbar
|
||||
{isEntryDocument}
|
||||
{templatesBusy}
|
||||
{templateOptions}
|
||||
{listMode}
|
||||
onApplyHeading={applyHeading}
|
||||
onApplyTemplate={(filePath) => void applyTemplateByPath(filePath)}
|
||||
onBold={() => applyWrap("**")}
|
||||
onItalic={() => applyWrap("*")}
|
||||
onLink={insertLink}
|
||||
onToggleUl={() => toggleListMode("ul")}
|
||||
onToggleOl={() => toggleListMode("ol")}
|
||||
onCode={() => applyWrap("`")}
|
||||
/>
|
||||
{#if templateError}
|
||||
<p class="template-error">{templateError}</p>
|
||||
{/if}
|
||||
@ -200,7 +284,8 @@
|
||||
bind:this={editorInput}
|
||||
class="markdown-input"
|
||||
bind:value={markdownText}
|
||||
on:input={(event) => updateDraft((event.currentTarget as HTMLTextAreaElement).value)}
|
||||
on:input={handleMarkdownInput}
|
||||
on:keydown={handleMarkdownKeydown}
|
||||
aria-label="Markdown input"
|
||||
></textarea>
|
||||
{/if}
|
||||
@ -224,94 +309,59 @@
|
||||
.editor-surface {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border-soft);
|
||||
background: var(--surface-1);
|
||||
padding: 10px;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.editor-surface.preview-only {
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.editor-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-app);
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.editor-toolbar button {
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border-soft);
|
||||
background: var(--surface-2);
|
||||
color: var(--text-muted);
|
||||
padding: 5px 8px;
|
||||
font-size: 0.74rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toolbar-select {
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border-soft);
|
||||
background: var(--surface-2);
|
||||
color: var(--text-muted);
|
||||
padding: 5px 8px;
|
||||
font-size: 0.74rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.editor-toolbar button:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.toolbar-select:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.template-error {
|
||||
color: #e74c3c;
|
||||
font-size: 0.78rem;
|
||||
margin: -2px 0 0;
|
||||
padding: 0 14px;
|
||||
}
|
||||
|
||||
.editor-workspace {
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
display: block;
|
||||
overflow: auto;
|
||||
padding: 0 14px 14px;
|
||||
}
|
||||
|
||||
.markdown-input,
|
||||
.markdown-preview {
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-app);
|
||||
width: min(100%, 920px);
|
||||
height: auto;
|
||||
min-height: 100%;
|
||||
margin: 0 auto;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
padding: 12px;
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.5;
|
||||
overflow: auto;
|
||||
padding: 28px 36px;
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.65;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.markdown-input {
|
||||
display: block;
|
||||
resize: none;
|
||||
overflow: auto;
|
||||
font-family: "Segoe UI Variable", "Segoe UI", Inter, Roboto, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
.markdown-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--border-strong);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.markdown-preview :global(h1),
|
||||
@ -362,4 +412,22 @@
|
||||
color: var(--text-primary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.editor-workspace {
|
||||
padding: 4px 8px 10px;
|
||||
}
|
||||
|
||||
.template-error {
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.markdown-input,
|
||||
.markdown-preview {
|
||||
width: 100%;
|
||||
border-radius: 0;
|
||||
padding: 18px 16px;
|
||||
font-size: 0.89rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
172
Journal.App/src/lib/components/editor/MarkdownToolbar.svelte
Normal file
172
Journal.App/src/lib/components/editor/MarkdownToolbar.svelte
Normal file
@ -0,0 +1,172 @@
|
||||
<script lang="ts">
|
||||
import type { EntryTemplateItemDto } from "$lib/backend/templates";
|
||||
|
||||
export let isEntryDocument = false;
|
||||
export let templatesBusy = false;
|
||||
export let templateOptions: EntryTemplateItemDto[] = [];
|
||||
export let listMode: "ul" | "ol" | null = null;
|
||||
|
||||
export let onApplyHeading: (level: number) => void = () => {};
|
||||
export let onApplyTemplate: (filePath: string) => void = () => {};
|
||||
export let onBold: () => void = () => {};
|
||||
export let onItalic: () => void = () => {};
|
||||
export let onLink: () => void = () => {};
|
||||
export let onToggleUl: () => void = () => {};
|
||||
export let onToggleOl: () => void = () => {};
|
||||
export let onCode: () => void = () => {};
|
||||
</script>
|
||||
|
||||
<div class="editor-toolbar">
|
||||
<div class="toolbar-group">
|
||||
<select
|
||||
class="toolbar-select"
|
||||
aria-label="Header size"
|
||||
on:change={(event) => {
|
||||
const target = event.currentTarget as HTMLSelectElement;
|
||||
const level = Number(target.value);
|
||||
if (level) onApplyHeading(level);
|
||||
target.value = "";
|
||||
}}
|
||||
>
|
||||
<option value="">Heading</option>
|
||||
<option value="1">H1</option>
|
||||
<option value="2">H2</option>
|
||||
<option value="3">H3</option>
|
||||
<option value="4">H4</option>
|
||||
<option value="5">H5</option>
|
||||
<option value="6">H6</option>
|
||||
</select>
|
||||
{#if isEntryDocument}
|
||||
<select
|
||||
class="toolbar-select"
|
||||
aria-label="Insert template"
|
||||
disabled={templatesBusy}
|
||||
on:change={(event) => {
|
||||
const target = event.currentTarget as HTMLSelectElement;
|
||||
const filePath = target.value;
|
||||
if (filePath) {
|
||||
onApplyTemplate(filePath);
|
||||
}
|
||||
target.value = "";
|
||||
}}
|
||||
>
|
||||
<option value="">{templatesBusy ? "Loading templates..." : "Template"}</option>
|
||||
{#each templateOptions as template}
|
||||
<option value={template.filePath}>{template.fileName.replace(/\.template\.md$/i, "")}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="toolbar-divider" aria-hidden="true"></div>
|
||||
<div class="toolbar-group">
|
||||
<button type="button" class="toolbar-btn" on:click={onBold}>Bold</button>
|
||||
<button type="button" class="toolbar-btn" on:click={onItalic}>Italic</button>
|
||||
<button type="button" class="toolbar-btn" on:click={onLink}>Link</button>
|
||||
<button type="button" class="toolbar-btn" class:is-active={listMode === "ul"} on:click={onToggleUl}>UL</button>
|
||||
<button type="button" class="toolbar-btn" class:is-active={listMode === "ol"} on:click={onToggleOl}>OL</button>
|
||||
<button type="button" class="toolbar-btn" on:click={onCode}>Code</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.editor-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 12px;
|
||||
background: color-mix(in srgb, var(--surface-1) 90%, var(--zinc-700) 10%);
|
||||
box-shadow:
|
||||
inset 0 1px 0 color-mix(in srgb, var(--zinc-300) 9%, transparent 91%),
|
||||
0 8px 24px color-mix(in srgb, var(--bg-app) 38%, transparent 62%);
|
||||
padding: 8px 10px;
|
||||
margin: 0 14px;
|
||||
}
|
||||
|
||||
.toolbar-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.toolbar-divider {
|
||||
width: 1px;
|
||||
height: 22px;
|
||||
background: color-mix(in srgb, var(--border-soft) 78%, transparent 22%);
|
||||
}
|
||||
|
||||
.toolbar-btn {
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-soft);
|
||||
background: color-mix(in srgb, var(--surface-2) 92%, var(--zinc-700) 8%);
|
||||
color: color-mix(in srgb, var(--text-muted) 86%, var(--text-primary) 14%);
|
||||
padding: 6px 10px;
|
||||
min-height: 30px;
|
||||
font-size: 0.73rem;
|
||||
letter-spacing: 0.01em;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background-color 120ms ease, color 120ms ease, border-color 120ms ease, transform 120ms ease;
|
||||
}
|
||||
|
||||
.toolbar-select {
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-soft);
|
||||
background: color-mix(in srgb, var(--surface-2) 92%, var(--zinc-700) 8%);
|
||||
color: color-mix(in srgb, var(--text-muted) 86%, var(--text-primary) 14%);
|
||||
padding: 6px 10px;
|
||||
min-height: 30px;
|
||||
font-size: 0.73rem;
|
||||
letter-spacing: 0.01em;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background-color 120ms ease, color 120ms ease, border-color 120ms ease;
|
||||
}
|
||||
|
||||
.toolbar-btn:hover,
|
||||
.toolbar-btn:focus-visible {
|
||||
background: color-mix(in srgb, var(--bg-hover) 88%, var(--surface-2) 12%);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-strong);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.toolbar-btn:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.toolbar-btn.is-active {
|
||||
background: color-mix(in srgb, var(--bg-active) 84%, var(--surface-2) 16%);
|
||||
border-color: var(--border-strong);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.toolbar-select:hover,
|
||||
.toolbar-select:focus-visible {
|
||||
background: color-mix(in srgb, var(--bg-hover) 88%, var(--surface-2) 12%);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-strong);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.toolbar-select:disabled,
|
||||
.toolbar-btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: default;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.editor-toolbar {
|
||||
margin: 0 8px;
|
||||
padding: 7px 8px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.toolbar-divider {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -172,23 +172,22 @@
|
||||
.todo-surface {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
padding: 8px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
overflow: auto;
|
||||
padding: 0 14px 14px;
|
||||
}
|
||||
|
||||
.todo-card {
|
||||
width: min(760px, 100%);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 12px;
|
||||
background: var(--surface-1);
|
||||
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.25);
|
||||
padding: 14px;
|
||||
width: min(100%, 920px);
|
||||
margin: 0 auto;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
padding: 28px 36px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
gap: 12px;
|
||||
max-height: 100%;
|
||||
overflow: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.todo-create {
|
||||
@ -201,19 +200,20 @@
|
||||
width: 100%;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-app);
|
||||
background: color-mix(in srgb, var(--surface-1) 88%, var(--bg-editor) 12%);
|
||||
color: var(--text-primary);
|
||||
padding: 8px 10px;
|
||||
font-size: 0.86rem;
|
||||
padding: 10px 11px;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.todo-add-btn {
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-strong);
|
||||
background: var(--surface-3);
|
||||
background: color-mix(in srgb, var(--surface-2) 84%, var(--bg-hover) 16%);
|
||||
color: var(--text-primary);
|
||||
padding: 8px 12px;
|
||||
padding: 9px 14px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@ -225,7 +225,7 @@
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.todo-item {
|
||||
@ -235,8 +235,8 @@
|
||||
gap: 10px;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
background: var(--bg-app);
|
||||
padding: 10px 12px;
|
||||
background: color-mix(in srgb, var(--surface-1) 90%, var(--bg-editor) 10%);
|
||||
}
|
||||
|
||||
.todo-check {
|
||||
@ -245,8 +245,9 @@
|
||||
}
|
||||
|
||||
.todo-text {
|
||||
font-size: 0.86rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.todo-text.is-done {
|
||||
@ -262,7 +263,7 @@
|
||||
.todo-btn {
|
||||
border-radius: 7px;
|
||||
border: 1px solid var(--border-soft);
|
||||
background: var(--surface-1);
|
||||
background: color-mix(in srgb, var(--surface-1) 90%, var(--bg-editor) 10%);
|
||||
color: var(--text-muted);
|
||||
padding: 6px 10px;
|
||||
font-size: 0.78rem;
|
||||
@ -271,7 +272,7 @@
|
||||
|
||||
.todo-btn.save {
|
||||
border-color: var(--border-strong);
|
||||
background: var(--surface-3);
|
||||
background: color-mix(in srgb, var(--surface-2) 84%, var(--bg-hover) 16%);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
@ -281,4 +282,25 @@
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.todo-surface {
|
||||
padding: 4px 8px 10px;
|
||||
}
|
||||
|
||||
.todo-card {
|
||||
width: 100%;
|
||||
padding: 18px 16px;
|
||||
}
|
||||
|
||||
.todo-item {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
row-gap: 8px;
|
||||
}
|
||||
|
||||
.todo-actions {
|
||||
grid-column: 1 / -1;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -9,6 +9,7 @@ type WindowWithTauri = Window & {
|
||||
type UiSettingsPayload = {
|
||||
tags?: string[];
|
||||
fragmentTypes?: string[];
|
||||
defaultStartupView?: string;
|
||||
};
|
||||
|
||||
type FetchJsonOptions = {
|
||||
@ -48,7 +49,8 @@ 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
|
||||
fragmentTypes: Array.isArray(parsed.fragmentTypes) ? parsed.fragmentTypes : undefined,
|
||||
defaultStartupView: typeof parsed.defaultStartupView === "string" ? parsed.defaultStartupView : undefined
|
||||
};
|
||||
} catch {
|
||||
return {};
|
||||
@ -62,7 +64,8 @@ function writeUiSettingsToLocalStorage(payload: UiSettingsPayload): void {
|
||||
|
||||
const safePayload: UiSettingsPayload = {
|
||||
tags: Array.isArray(payload.tags) ? payload.tags : undefined,
|
||||
fragmentTypes: Array.isArray(payload.fragmentTypes) ? payload.fragmentTypes : 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));
|
||||
@ -131,8 +134,12 @@ export async function invoke<T>(command: string, args?: InvokeArgs): Promise<T>
|
||||
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;
|
||||
|
||||
writeUiSettingsToLocalStorage({ tags, fragmentTypes });
|
||||
writeUiSettingsToLocalStorage({ tags, fragmentTypes, defaultStartupView });
|
||||
return undefined as T;
|
||||
}
|
||||
case "shutdown":
|
||||
|
||||
@ -4,9 +4,13 @@ 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 defaultStartupView = "entries";
|
||||
export type StartupView = typeof startupViews[number];
|
||||
|
||||
export const settingsTags = writable<string[]>([...defaultTags]);
|
||||
export const settingsFragmentTypes = writable<string[]>([...defaultFragmentTypes]);
|
||||
export const settingsDefaultStartupView = writable<StartupView>(defaultStartupView);
|
||||
|
||||
let hydrationComplete = false;
|
||||
let hydrating = false;
|
||||
@ -14,6 +18,7 @@ let hydrating = false;
|
||||
type UiSettingsPayload = {
|
||||
tags?: string[];
|
||||
fragmentTypes?: string[];
|
||||
defaultStartupView?: string;
|
||||
};
|
||||
|
||||
function normalize(value: string): string {
|
||||
@ -39,6 +44,14 @@ function normalizeValues(values: string[], fallback: string[]): string[] {
|
||||
return result.length ? result : [...fallback];
|
||||
}
|
||||
|
||||
function normalizeStartupView(value: string | undefined): StartupView {
|
||||
const normalized = (value ?? "").trim().toLowerCase();
|
||||
if (startupViews.includes(normalized as StartupView)) {
|
||||
return normalized as StartupView;
|
||||
}
|
||||
return defaultStartupView;
|
||||
}
|
||||
|
||||
export async function hydrateUiSettings(): Promise<void> {
|
||||
if (hydrating || hydrationComplete) return;
|
||||
hydrating = true;
|
||||
@ -46,6 +59,7 @@ export async function hydrateUiSettings(): Promise<void> {
|
||||
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));
|
||||
} catch (error) {
|
||||
console.error("[settings] hydrate failed", error);
|
||||
} finally {
|
||||
@ -57,13 +71,17 @@ 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 startupView = normalizeStartupView(get(settingsDefaultStartupView));
|
||||
settingsTags.set(tags);
|
||||
settingsFragmentTypes.set(fragmentTypes);
|
||||
settingsDefaultStartupView.set(startupView);
|
||||
|
||||
await invoke("set_ui_settings", {
|
||||
tags,
|
||||
fragmentTypes,
|
||||
fragment_types: fragmentTypes
|
||||
fragment_types: fragmentTypes,
|
||||
defaultStartupView: startupView,
|
||||
default_startup_view: startupView
|
||||
});
|
||||
}
|
||||
|
||||
@ -132,3 +150,11 @@ export function removeFragmentType(index: number): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
export function setDefaultStartupView(value: string): boolean {
|
||||
const next = normalizeStartupView(value);
|
||||
if (get(settingsDefaultStartupView) === next) return false;
|
||||
settingsDefaultStartupView.set(next);
|
||||
queuePersist();
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
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";
|
||||
@ -48,6 +49,43 @@
|
||||
let pendingDeleteItemId = "";
|
||||
let templateRefreshToken = 0;
|
||||
|
||||
function resolveStartupSection(value: string): StartupView {
|
||||
switch (value) {
|
||||
case "calendar":
|
||||
case "fragments":
|
||||
case "todos":
|
||||
case "lists":
|
||||
case "entries":
|
||||
return value;
|
||||
default:
|
||||
return "entries";
|
||||
}
|
||||
}
|
||||
|
||||
function parseSectionQuery(value: string | null): StartupView | null {
|
||||
if (!value) return null;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
switch (normalized) {
|
||||
case "entries":
|
||||
case "calendar":
|
||||
case "fragments":
|
||||
case "todos":
|
||||
case "lists":
|
||||
return normalized;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function applyStartupSection(section: StartupView) {
|
||||
selectedSection = section;
|
||||
editMode = false;
|
||||
if (section !== "entries") {
|
||||
activeDocumentId = "";
|
||||
activeDocumentLabel = "";
|
||||
}
|
||||
}
|
||||
|
||||
function toTemplatePath(id: string): string | null {
|
||||
const prefix = "entries/template-file/";
|
||||
if (!id.startsWith(prefix)) return null;
|
||||
@ -274,8 +312,13 @@
|
||||
}
|
||||
|
||||
async function handleSelect(id: string) {
|
||||
if (id === "account" || id === "settings") {
|
||||
goto(`/${id}`);
|
||||
if (id === "account") {
|
||||
goto("/account");
|
||||
return;
|
||||
}
|
||||
|
||||
if (id === "settings") {
|
||||
goto(`/settings?return=${encodeURIComponent(selectedSection)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -415,7 +458,13 @@
|
||||
|
||||
onMount(() => {
|
||||
setFlushCallback(saveCurrentDocument);
|
||||
bootstrapFragmentsWithUnlock();
|
||||
void (async () => {
|
||||
await hydrateUiSettings();
|
||||
const startupSection = resolveStartupSection(get(settingsDefaultStartupView));
|
||||
const sectionFromQuery = parseSectionQuery(new URLSearchParams(window.location.search).get("section"));
|
||||
applyStartupSection(sectionFromQuery ?? startupSection);
|
||||
await bootstrapFragmentsWithUnlock();
|
||||
})();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@ -7,12 +7,14 @@
|
||||
addSettingsTag,
|
||||
removeFragmentType,
|
||||
removeSettingsTag,
|
||||
setDefaultStartupView,
|
||||
settingsDefaultStartupView,
|
||||
settingsFragmentTypes,
|
||||
settingsTags,
|
||||
updateFragmentType,
|
||||
updateSettingsTag
|
||||
} from "$lib/stores/settings";
|
||||
import { invoke } from "$lib/runtime/invoke";
|
||||
import { invoke, isTauriRuntime } from "$lib/runtime/invoke";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
const activeSection = "settings";
|
||||
@ -34,8 +36,15 @@
|
||||
let sidecarRoot = "";
|
||||
let sidecarRootIsCustom = false;
|
||||
let sidecarRootError = "";
|
||||
let sidecarBrowseBusy = false;
|
||||
let returnSection = "entries";
|
||||
|
||||
onMount(async () => {
|
||||
const queryReturn = new URLSearchParams(window.location.search).get("return")?.trim().toLowerCase() ?? "";
|
||||
if (["entries", "calendar", "fragments", "todos", "lists"].includes(queryReturn)) {
|
||||
returnSection = queryReturn;
|
||||
}
|
||||
|
||||
try {
|
||||
const result: any = await invoke("get_sidecar_root");
|
||||
sidecarRoot = result.root;
|
||||
@ -67,6 +76,31 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function browseSidecarRoot() {
|
||||
sidecarRootError = "";
|
||||
if (!isTauriRuntime()) {
|
||||
sidecarRootError = "Folder picker is only available in desktop app.";
|
||||
return;
|
||||
}
|
||||
sidecarBrowseBusy = true;
|
||||
try {
|
||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||
const picked = await open({
|
||||
directory: true,
|
||||
multiple: false,
|
||||
title: "Select Sidecar Root Directory"
|
||||
});
|
||||
if (typeof picked === "string" && picked.trim()) {
|
||||
sidecarRoot = picked;
|
||||
await saveSidecarRoot();
|
||||
}
|
||||
} catch (e) {
|
||||
sidecarRootError = String(e);
|
||||
} finally {
|
||||
sidecarBrowseBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
function showModal(options: {
|
||||
action: "logout-confirm" | "logout-info";
|
||||
title: string;
|
||||
@ -128,7 +162,11 @@
|
||||
return;
|
||||
}
|
||||
|
||||
goto("/");
|
||||
goto(`/?section=${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
function closeSettings() {
|
||||
goto(`/?section=${encodeURIComponent(returnSection)}`);
|
||||
}
|
||||
|
||||
function addTag() {
|
||||
@ -193,6 +231,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
function updateDefaultStartupView(value: string) {
|
||||
setDefaultStartupView(value);
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<div class="app-shell panel-closed">
|
||||
@ -200,25 +242,46 @@
|
||||
|
||||
<main class="route-view">
|
||||
<header class="route-header">
|
||||
<h1>Settings</h1>
|
||||
<p>Configure app behavior and interface options.</p>
|
||||
<div class="route-header-main">
|
||||
<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">
|
||||
<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>
|
||||
<option>Entries</option>
|
||||
<option>Calendar</option>
|
||||
<option>Fragments</option>
|
||||
<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">
|
||||
<h2>Tags</h2>
|
||||
<p class="section-copy">Add and manage tags used for notes and entries.</p>
|
||||
<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
|
||||
@ -259,8 +322,13 @@
|
||||
</section>
|
||||
|
||||
<section class="route-card">
|
||||
<h2>Fragment Types</h2>
|
||||
<p class="section-copy">Configure custom fragment types for the Fragments section.</p>
|
||||
<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
|
||||
@ -301,8 +369,13 @@
|
||||
</section>
|
||||
|
||||
<section class="route-card">
|
||||
<h2>Sidecar</h2>
|
||||
<p class="section-copy">Root directory containing the Journal.Sidecar project.</p>
|
||||
<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
|
||||
@ -311,7 +384,9 @@
|
||||
bind:value={sidecarRoot}
|
||||
on:keydown={(event) => event.key === "Enter" && saveSidecarRoot()}
|
||||
/>
|
||||
<button type="button" class="secondary-btn" on:click={saveSidecarRoot}>Save</button>
|
||||
<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}
|
||||
@ -343,126 +418,196 @@
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
background: linear-gradient(180deg, var(--surface-2) 0%, var(--bg-editor) 100%);
|
||||
gap: 18px;
|
||||
background: var(--bg-editor);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.route-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 2px 2px 0;
|
||||
}
|
||||
|
||||
.route-header-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.route-header h1 {
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 4px;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.route-header p {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.header-close-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-soft);
|
||||
background: color-mix(in srgb, var(--surface-1) 92%, var(--bg-editor) 8%);
|
||||
color: var(--text-muted);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
transition: background-color 120ms ease, color 120ms ease, border-color 120ms ease, transform 120ms ease;
|
||||
}
|
||||
|
||||
.header-close-btn .material-symbols-outlined {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.header-close-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-strong);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.settings-grid {
|
||||
columns: 2;
|
||||
column-gap: 16px;
|
||||
column-gap: 14px;
|
||||
}
|
||||
|
||||
.route-card {
|
||||
break-inside: avoid;
|
||||
border: 1px solid var(--border-soft);
|
||||
background: var(--surface-1);
|
||||
border-radius: 10px;
|
||||
padding: 14px;
|
||||
background: color-mix(in srgb, var(--surface-1) 92%, var(--bg-editor) 8%);
|
||||
border-radius: 12px;
|
||||
padding: 14px 14px 13px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
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;
|
||||
}
|
||||
|
||||
.card-head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.96rem;
|
||||
font-weight: 650;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.card-title .material-symbols-outlined {
|
||||
font-size: 1rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.route-card label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
gap: 6px;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.82rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.route-card select {
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 7px;
|
||||
background: var(--bg-app);
|
||||
color: var(--text-primary);
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.route-card h2 {
|
||||
font-size: 0.95rem;
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--surface-2) 88%, var(--bg-editor) 12%);
|
||||
color: var(--text-primary);
|
||||
padding: 9px 10px;
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.section-copy {
|
||||
font-size: 0.82rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.create-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.create-row input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.item-list {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.item-row {
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
padding: 9px 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
font-size: 0.84rem;
|
||||
font-size: 0.83rem;
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-app);
|
||||
background: color-mix(in srgb, var(--surface-1) 90%, var(--bg-editor) 10%);
|
||||
}
|
||||
|
||||
.item-row input,
|
||||
.route-card input {
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 7px;
|
||||
background: var(--bg-app);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--surface-2) 88%, var(--bg-editor) 12%);
|
||||
color: var(--text-primary);
|
||||
padding: 6px 9px;
|
||||
padding: 8px 10px;
|
||||
font-size: 0.84rem;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.row-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.secondary-btn,
|
||||
.ghost-btn,
|
||||
.danger-btn {
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 7px;
|
||||
padding: 6px 10px;
|
||||
font-size: 0.78rem;
|
||||
border-radius: 8px;
|
||||
padding: 7px 11px;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 600;
|
||||
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;
|
||||
}
|
||||
|
||||
.secondary-btn {
|
||||
background: var(--surface-3);
|
||||
background: color-mix(in srgb, var(--surface-2) 84%, var(--bg-hover) 16%);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.ghost-btn {
|
||||
background: var(--surface-1);
|
||||
background: color-mix(in srgb, var(--surface-1) 92%, var(--bg-editor) 8%);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@ -476,11 +621,55 @@
|
||||
.danger-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-strong);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: #e74c3c;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.settings-grid {
|
||||
columns: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.route-view {
|
||||
padding: 14px 12px;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.route-card {
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.route-header {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.create-row {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.create-row > * {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.item-row {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.row-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@ -18,7 +18,7 @@
|
||||
|
||||
--bg-app: var(--zinc-950);
|
||||
--bg-navbar: var(--zinc-900);
|
||||
--bg-panel: var(--zinc-900);
|
||||
--bg-panel: var(--zinc-800);
|
||||
--bg-editor: var(--zinc-900);
|
||||
--bg-hover: var(--zinc-800);
|
||||
--bg-active: var(--zinc-700);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user