7 Commits

Author SHA1 Message Date
Scott Idem
1ef9080cda Hide journal entry AI tools unless admin edit mode is active
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 17:24:10 -04:00
Scott Idem
66c0be65c4 Show AI tools only in edit mode; open existing summary without regenerating
- Hide AI tools panel when entry is not in edit mode
- Clicking AI button when a summary already exists opens it directly
  instead of triggering a new API call; Re-run still available in modal

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 17:22:16 -04:00
Scott Idem
bdba092de0 Polish journal entry save buttons and header controls
- Add hover titles to all save buttons
- Match warning color scheme across floating, inline, and header save buttons
- Fix floating save button visibility (Tailwind v4 hidden/md:inline-flex conflict)
- Hide floating save when no unsaved changes using {#if}
- Hide Config button when not in admin edit mode
- Remove the mobile/backup explicit Save button from header (redundant)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 17:15:52 -04:00
Scott Idem
0fa93d7ee5 Fix auto-save stopping after the first save in journal entry editor
The auto-save $effect wrapped has_unsaved_changes in untrack(), which meant it
was never tracked when save_status was 'saved' (JS short-circuit in the else-if
branch). After every successful save the effect lost its reactive dependency on
user edits and never re-fired until something else changed save_status first.

Fix: track tmp_entry_obj.content and tmp_entry_obj.name directly (void reads)
so the effect re-runs on every keystroke and the debounce timer resets correctly
(fires 2 s after the last change, not the first). has_unsaved_changes is also
tracked directly so the status indicator resets cleanly when changes are cleared.
All side-effects remain in untrack() to prevent reactive loops.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 16:21:22 -04:00
Scott Idem
847d653b5e Truncate journal and entry names in browser tab titles
Entry: 50 chars for entry name, 30 for journal name
Journal: 60 chars for journal name
Appends ellipsis (…) when truncated.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 16:08:41 -04:00
Scott Idem
cd01a87143 Fix browser tab titles for journal and journal entry pages
Journal: [Journal Name] - OSIT's AE Journals
Entry:   [Entry Name] - [Journal Name] - OSIT's AE Journals

Entry page title was previously commented out; now active with correct format.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 16:00:31 -04:00
Scott Idem
60ecd221b4 Add tab indentation and line number toggle to CodeMirror editor
- Wire indentWithTab into keymap (Tab=indent, Shift-Tab=dedent, 4 spaces)
- Set indentUnit to 4 spaces
- Wrap lineNumbers() in a Compartment for live toggle without editor rebuild
- Add Hash toolbar button to toggle line numbers; respects show_line_numbers prop as initial value

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 15:55:42 -04:00
8 changed files with 155 additions and 69 deletions

View File

@@ -157,15 +157,23 @@ function handle_save() {
<!-- Trigger Button --> <!-- Trigger Button -->
<button <button
type="button" type="button"
onclick={generate_ai_result} onclick={() => {
if (summary) {
tmp_summary = summary;
active_tab = 'result';
show_modal = true;
} else {
generate_ai_result();
}
}}
class={buttonClass} class={buttonClass}
title="Generate AI summary/analysis"> title={summary ? 'View existing AI summary' : 'Generate AI summary/analysis'}>
{#await ae_promises} {#await ae_promises}
<Loader class="mr-1 inline-block animate-spin" size="1.2em" /> <Loader class="mr-1 inline-block animate-spin" size="1.2em" />
<span class="text-sm">Processing...</span> <span class="text-sm">Processing...</span>
{:then} {:then}
<BotMessageSquare class="mr-1 inline-block" size="1.2em" /> <BotMessageSquare class="mr-1 inline-block" size="1.2em" />
<span class="text-sm">Summarize</span> <span class="text-sm hidden">Summarize</span>
{:catch} {:catch}
<span class="text-sm text-red-500">Error</span> <span class="text-sm text-red-500">Error</span>
{/await} {/await}
@@ -181,6 +189,7 @@ function handle_save() {
class="btn btn-sm preset-tonal-surface shadow-md" class="btn btn-sm preset-tonal-surface shadow-md"
title="AI Settings"> title="AI Settings">
<Settings size="1.2em" /> <Settings size="1.2em" />
<span class="text-sm hidden">Settings</span>
</button> </button>
<!-- Unified AI Modal --> <!-- Unified AI Modal -->

View File

@@ -81,6 +81,7 @@ type CMCache = {
languages?: any; languages?: any;
oneDark?: any; oneDark?: any;
placeholderExt?: any; placeholderExt?: any;
Compartment?: any;
} | null; } | null;
const GLOBAL_KEY = '__cm_singleton_modules_v1'; const GLOBAL_KEY = '__cm_singleton_modules_v1';
@@ -135,6 +136,7 @@ export async function ensure_CodeMirror_modules(): Promise<CMCache> {
EditorState_allowMultipleSelections: EditorState_allowMultipleSelections:
stateMod.EditorState.allowMultipleSelections, stateMod.EditorState.allowMultipleSelections,
EditorState_readOnly: stateMod.EditorState.readOnly, EditorState_readOnly: stateMod.EditorState.readOnly,
Compartment: stateMod.Compartment,
markdown: markdownMod?.markdown, markdown: markdownMod?.markdown,
markdownLanguage: markdownMod?.markdownLanguage, markdownLanguage: markdownMod?.markdownLanguage,

View File

@@ -11,7 +11,7 @@ import { ensure_CodeMirror_modules } from './codemirror_modules';
// import type { key_val } from '$lib/stores/ae_stores'; // import type { key_val } from '$lib/stores/ae_stores';
// Icons (Standardized to Lucide where possible, or FontAwesome placeholders) // Icons (Standardized to Lucide where possible, or FontAwesome placeholders)
import { Bold, Code, Italic, List } from '@lucide/svelte'; import { Bold, Code, Hash, Italic, List } from '@lucide/svelte';
interface Props { interface Props {
content?: string | null; content?: string | null;
new_content?: string; new_content?: string;
@@ -42,6 +42,8 @@ let {
let editor_container: HTMLDivElement | undefined = $state(); let editor_container: HTMLDivElement | undefined = $state();
let cm: any = $state(); // CodeMirror modules cache let cm: any = $state(); // CodeMirror modules cache
let show_line_nums = $state(untrack(() => show_line_numbers));
let ln_compartment: any = null;
async function create_editor() { async function create_editor() {
if (!browser) return; if (!browser) return;
@@ -55,6 +57,8 @@ async function create_editor() {
editor_view = null; editor_view = null;
} }
ln_compartment = new cm.Compartment();
const extensions = [ const extensions = [
cm.highlightSpecialChars(), cm.highlightSpecialChars(),
cm.history(), cm.history(),
@@ -70,8 +74,9 @@ async function create_editor() {
cm.highlightActiveLine(), cm.highlightActiveLine(),
cm.highlightActiveLineGutter(), cm.highlightActiveLineGutter(),
// Keymaps // Keymaps — indentWithTab must come before defaultKeymap
cm.keymap.of([ cm.keymap.of([
cm.indentWithTab,
...cm.defaultKeymap, ...cm.defaultKeymap,
...cm.searchKeymap, ...cm.searchKeymap,
...cm.historyKeymap, ...cm.historyKeymap,
@@ -80,6 +85,9 @@ async function create_editor() {
...cm.lintKeymap ...cm.lintKeymap
]), ]),
// 4-space indentation unit
cm.indentUnit.of(' '),
// Language Support // Language Support
language === 'markdown' language === 'markdown'
? cm.markdown({ base: cm.markdownLanguage }) ? cm.markdown({ base: cm.markdownLanguage })
@@ -94,7 +102,7 @@ async function create_editor() {
readonly readonly
? cm.EditorState.readOnly.of(true) ? cm.EditorState.readOnly.of(true)
: cm.EditorView.editable.of(true), : cm.EditorView.editable.of(true),
show_line_numbers ? cm.lineNumbers() : null, ln_compartment.of(show_line_nums ? cm.lineNumbers() : []),
wrap_lines ? cm.EditorView_lineWrapping : null, wrap_lines ? cm.EditorView_lineWrapping : null,
placeholder ? cm.placeholderExt(placeholder) : null, placeholder ? cm.placeholderExt(placeholder) : null,
@@ -144,6 +152,14 @@ $effect(() => {
} }
}); });
// Toggle line numbers without rebuilding the editor
$effect(() => {
if (!editor_view || !ln_compartment || !cm) return;
editor_view.dispatch({
effects: ln_compartment.reconfigure(show_line_nums ? cm.lineNumbers() : [])
});
});
// *** Toolbar Helpers // *** Toolbar Helpers
const wrap_selection = (before: string, after: string = before) => { const wrap_selection = (before: string, after: string = before) => {
if (!editor_view) return; if (!editor_view) return;
@@ -244,6 +260,15 @@ const toggle_list = () => {
</button> </button>
<div class="ml-auto flex gap-1"> <div class="ml-auto flex gap-1">
<button
type="button"
class="btn btn-sm {show_line_nums
? 'variant-filled-primary'
: 'variant-soft'} hover:variant-filled-primary"
onclick={() => (show_line_nums = !show_line_nums)}
title="Toggle Line Numbers">
<Hash size="14" />
</button>
<span <span
class="mr-2 self-center text-[10px] font-bold uppercase opacity-50" class="mr-2 self-center text-[10px] font-bold uppercase opacity-50"
>{language}</span> >{language}</span>

View File

@@ -303,12 +303,18 @@ if (browser) {
} }
import { LoaderCircle } from '@lucide/svelte'; import { LoaderCircle } from '@lucide/svelte';
const title_journal = $derived(
$lq__journal_obj?.name
? $lq__journal_obj.name.length > 60
? $lq__journal_obj.name.slice(0, 60) + '…'
: $lq__journal_obj.name
: 'Journal'
);
</script> </script>
<svelte:head> <svelte:head>
<title> <title>{title_journal} - OSIT's AE Journals</title>
Æ Journals: {$lq__journal_obj?.name ?? ''} - {$ae_loc?.title}
</title>
</svelte:head> </svelte:head>
{#if $lq__journal_obj === undefined} {#if $lq__journal_obj === undefined}

View File

@@ -258,15 +258,26 @@ $effect(() => {
// log_lvl = 1; // log_lvl = 1;
} }
}); });
const title_entry = $derived(
$lq__journal_entry_obj?.name
? $lq__journal_entry_obj.name.length > 50
? $lq__journal_entry_obj.name.slice(0, 50) + '…'
: $lq__journal_entry_obj.name
: 'Entry'
);
const title_journal = $derived(
$lq__journal_obj?.name
? $lq__journal_obj.name.length > 30
? $lq__journal_obj.name.slice(0, 30) + '…'
: $lq__journal_obj.name
: 'Journal'
);
</script> </script>
<!-- <svelte:head> <svelte:head>
<title> <title>{title_entry} - {title_journal} - OSIT's AE Journals</title>
&AElig; Journals: </svelte:head>
{$lq__journal_entry_obj?.name ? ae_util.shorten_string({ string: $lq__journal_entry_obj?.name, max_length: 20, begin_length: 10, end_length: 4 }) : ''}
- {$ae_loc?.title}
</title>
</svelte:head> -->
{#if $ae_loc.person_id == $lq__journal_obj?.person_id || $lq__journal_entry_obj?.public} {#if $ae_loc.person_id == $lq__journal_obj?.person_id || $lq__journal_entry_obj?.public}
<section <section

View File

@@ -4,7 +4,9 @@
* Extracted 2026-01-08 to modularize the massive Journal Entry view. * Extracted 2026-01-08 to modularize the massive Journal Entry view.
* Handles: CodeMirror vs Plain vs Rendered HTML for both View and Edit modes. * Handles: CodeMirror vs Plain vs Rendered HTML for both View and Edit modes.
*/ */
import { LockKeyhole, RefreshCcw, Save } from '@lucide/svelte'; import {
LockKeyhole, RefreshCcw, Save
} from '@lucide/svelte';
import { ae_loc } from '$lib/stores/ae_stores'; import { ae_loc } from '$lib/stores/ae_stores';
import { journals_loc } from '$lib/ae_journals/ae_journals_stores'; import { journals_loc } from '$lib/ae_journals/ae_journals_stores';
import AE_Comp_Editor_CodeMirror from '$lib/elements/element_editor_codemirror.svelte'; import AE_Comp_Editor_CodeMirror from '$lib/elements/element_editor_codemirror.svelte';
@@ -67,6 +69,7 @@ const preferred_viewer = $derived(
<div <div
class="prose dark:prose-invert w-full max-w-none overflow-x-auto rounded-lg border border-gray-200 bg-white p-4 shadow-lg dark:border-gray-700 dark:bg-gray-900"> class="prose dark:prose-invert w-full max-w-none overflow-x-auto rounded-lg border border-gray-200 bg-white p-4 shadow-lg dark:border-gray-700 dark:bg-gray-900">
<!-- The rendered HTML branch is intentionally trusted input from the journal editor pipeline. --> <!-- The rendered HTML branch is intentionally trusted input from the journal editor pipeline. -->
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html tmp_entry_obj?.content_md_html || ''} {@html tmp_entry_obj?.content_md_html || ''}
</div> </div>
{/if} {/if}
@@ -120,21 +123,30 @@ const preferred_viewer = $derived(
placeholder="Edit content..."></textarea> placeholder="Edit content..."></textarea>
{/if} {/if}
<!-- Floating Save Button --> <!-- Floating Save Button (desktop only) -->
<button {#if has_changed}
type="button" <button
onclick={on_save} type="button"
disabled={!has_changed} onclick={on_save}
class="btn btn-sm md:btn-md lg:btn-lg preset-filled-success fixed top-84 right-6 z-20 hidden min-w-32 shadow-xl transition-all md:inline-flex"
class:hidden={!has_changed}> class="
<Save size="1.2em" class="mr-2" /> Save btn btn-sm md:btn-md lg:btn-lg preset-tonal-warning hover:preset-filled-warning-500
</button> fixed top-84 right-6 z-20
min-w-32 shadow-xl
max-sm:hidden
"
title="Save changes"
>
<Save size="1.2em" class="" />Save
</button>
{/if}
<!-- Inline Save Button (Mobile/Context) --> <!-- Inline Save Button (Mobile/Context) -->
<button <button
type="button" type="button"
onclick={on_save} onclick={on_save}
disabled={!has_changed} disabled={!has_changed}
title="Save changes"
class="btn preset-tonal-warning hover:preset-filled-warning-500 mt-4 w-full max-w-96" class="btn preset-tonal-warning hover:preset-filled-warning-500 mt-4 w-full max-w-96"
class:invisible={!has_changed}> class:invisible={!has_changed}>
<Save size="1.2em" class="mr-2" /> Save Changes <Save size="1.2em" class="mr-2" /> Save Changes

View File

@@ -9,7 +9,7 @@ import {
CircleCheck, CircleCheck,
CircleX, CircleX,
Eye, Eye,
Fingerprint, // Fingerprint,
LoaderCircle, LoaderCircle,
LockKeyhole, LockKeyhole,
LockKeyholeOpen, LockKeyholeOpen,
@@ -19,6 +19,7 @@ import {
Settings Settings
} from '@lucide/svelte'; } from '@lucide/svelte';
import { ae_util } from '$lib/ae_utils/ae_utils'; import { ae_util } from '$lib/ae_utils/ae_utils';
import { ae_loc } from '$lib/stores/ae_stores';
import { import {
journals_loc, journals_loc,
journals_sess journals_sess
@@ -83,10 +84,13 @@ function toggle_edit_mode() {
<button <button
type="button" type="button"
onclick={toggle_edit_mode} onclick={toggle_edit_mode}
title={$journals_loc.entry.edit_kv[entry.journal_entry_id] === 'current'
? (has_changed ? 'Save & exit edit mode' : 'Exit edit mode')
: 'Edit entry'}
class="btn-icon btn-icon-sm transition-colors duration-150 {has_changed && class="btn-icon btn-icon-sm transition-colors duration-150 {has_changed &&
$journals_loc.entry.edit_kv[entry.journal_entry_id] === $journals_loc.entry.edit_kv[entry.journal_entry_id] ===
'current' 'current'
? 'preset-filled-success' ? 'preset-tonal-warning hover:preset-filled-warning-500'
: 'preset-tonal-surface'}"> : 'preset-tonal-surface'}">
{#if $journals_loc.entry.edit_kv[entry.journal_entry_id] === 'current'} {#if $journals_loc.entry.edit_kv[entry.journal_entry_id] === 'current'}
{#if has_changed}<Save size="1.2em" />{:else}<Eye {#if has_changed}<Save size="1.2em" />{:else}<Eye
@@ -160,24 +164,34 @@ function toggle_edit_mode() {
</button> </button>
{/if} {/if}
<div class="bg-surface-500/20 mx-1 h-6 w-px"></div>
<!-- Unified Config Button --> <!-- Unified Config Button -->
<button <button
type="button" type="button"
class="btn btn-sm preset-tonal-primary font-bold transition-colors duration-150" onclick={on_show_config}
onclick={on_show_config}> class:hidden={!$ae_loc.edit_mode}
<Settings size="1.1em" class="mr-2" /> Config class="
btn btn-sm preset-tonal-primary font-bold transition-colors duration-150
"
title="Entry Configuration"
>
<Settings size="1.1em" />Config
</button> </button>
<!-- Explicit Save (Mobile/Backup) --> <!-- Explicit Save (Mobile/Backup) -->
{#if has_changed && save_status !== 'saving'} <!-- {#if has_changed && save_status !== 'saving'}
<button <button
type="button" type="button"
class="btn btn-sm preset-filled-primary transition-colors duration-150" onclick={on_save}
onclick={on_save}> class="
<Save size="1.1em" class="mr-2" /> Save btn btn-sm
preset-filled-primary
transition-colors duration-150
md:hidden
"
title="Save changes"
>
<Save size="1.1em" class="" />Save
</button> </button>
{/if} {/if} -->
</div> </div>
</header> </header>

View File

@@ -154,33 +154,38 @@ $effect(() => {
// 2. Auto-Save Debounce // 2. Auto-Save Debounce
$effect(() => { $effect(() => {
// Isolate logic from secondary dependencies // Track content and name directly so this effect re-runs on every keystroke,
const should_save = untrack( // resetting the debounce timer each time (fires 2 s after the LAST change).
() => has_unsaved_changes && !is_processing && save_status !== 'saving' // Tracking has_unsaved_changes ensures the effect also wakes up when changes
); // are cleared (e.g. after a save) so the status indicator resets correctly.
// All side-effects (save_status writes, $journals_loc reads) stay in untrack
// to avoid creating reactive loops.
void tmp_entry_obj.content;
void tmp_entry_obj.name;
const changed = has_unsaved_changes;
if (should_save) { clearTimeout(auto_save_timer);
if (save_status !== 'saving') save_status = 'unsaved';
const auto_save_enabled = untrack(() => $journals_loc.entry.auto_save); if (!changed) {
if (auto_save_enabled) { untrack(() => {
clearTimeout(auto_save_timer); if (save_status === 'unsaved') save_status = 'saved';
auto_save_timer = setTimeout(() => { });
if ( return;
untrack(
() =>
has_unsaved_changes &&
!is_processing &&
save_status !== 'saving'
)
) {
update_journal_entry();
}
}, 2000);
}
} else if (save_status === 'unsaved' && !has_unsaved_changes) {
save_status = 'saved';
} }
untrack(() => {
if (save_status !== 'saving') save_status = 'unsaved';
});
if (!untrack(() => $journals_loc.entry.auto_save)) return;
auto_save_timer = setTimeout(() => {
untrack(() => {
if (has_unsaved_changes && !is_processing && save_status !== 'saving') {
update_journal_entry();
}
});
}, 2000);
}); });
// 3. Auto-Decryption Workflow // 3. Auto-Decryption Workflow
@@ -482,13 +487,15 @@ let modal_mode: 'append' | 'prepend' | 'auto' = $state('auto');
] == 'current' ] == 'current'
? 'ring-primary-500/40 ring-2 ring-inset' ? 'ring-primary-500/40 ring-2 ring-inset'
: ''}"> : ''}">
<AE_Comp_Journal_Entry_AiTools {#if $ae_loc.edit_mode && $journals_loc.entry.edit_kv[$lq__journal_entry_obj?.journal_entry_id] === 'current'}
content={typeof tmp_entry_obj.content === 'string' <AE_Comp_Journal_Entry_AiTools
? tmp_entry_obj.content content={typeof tmp_entry_obj.content === 'string'
: ''} ? tmp_entry_obj.content
bind:summary={tmp_entry_obj.summary} : ''}
on_save={() => update_journal_entry()} bind:summary={tmp_entry_obj.summary}
{log_lvl} /> on_save={() => update_journal_entry()}
{log_lvl} />
{/if}
<AE_Comp_Journal_Entry_Editor <AE_Comp_Journal_Entry_Editor
entry={$lq__journal_entry_obj} entry={$lq__journal_entry_obj}