release: v0.19.0

Release v0.19.0. See the `## [0.19.0] — 2026-09-29` section of
[CHANGELOG.md](https://github.com/ralksta/immich-folio/blob/dev/CHANGELOG.md)
for the full list.

Highlights: client proofing links (#683), custom content pages (#738),
asset picker search (#740), concurrent-edit detection (#742), env-locked
settings (#739), an accessibility baseline (#741), three security fixes
with advisories (#737, #755, #759), and a pre-release quality pass
(#780–#797).

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: ralksta <ralksta@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Ralf <rohlenbo79@gmail.com>
Co-authored-by: Paul Thies <110185070+ImScheinox@users.noreply.github.com>
Co-authored-by: lancetm714 <108419425+lancetm714@users.noreply.github.com>
Co-authored-by: Lance Martin <lancetaylor95@gmail.com>
This commit is contained in:
Ralksta
2026-09-29 22:50:30 +02:00
committed by GitHub
parent 5f251923f0
commit ad8b09700b
297 changed files with 19398 additions and 1800 deletions

View File

@@ -28,14 +28,17 @@ import type { AlbumAssetRef } from '@/lib/journalAlbum';
import { BlockBadge } from '../BlockBadge';
import { useUnsavedGuard } from '../useUnsavedGuard';
import { useDraft } from '../useDraft';
import { useLatest } from '../useLatest';
import DraftNotice from '../DraftNotice';
import { reportIfSessionExpired } from '../sessionExpiry';
import { useContentRestored } from '../contentRestored';
import { useVersionedSave } from '../useVersionedSave';
import './journal-studio.css';
import { BlockFields, type AssetPickTarget } from './BlockFields';
import { StorySettingsModal } from './StorySettingsModal';
import { PageSettingsPanel } from './PageSettingsPanel';
import { JournalPreview } from './JournalPreview';
import { createBlock, moveBlock } from './blockOps';
import { createBlock, createPhotoBlocks, moveBlock } from './blockOps';
import { useSplitPane, SPLIT_MIN, SPLIT_MAX } from './splitPane';
import { useNotify } from '../Notifications';
@@ -43,9 +46,18 @@ interface JournalEditorProps {
slug: string;
mapEnabled?: boolean;
onBack: () => void;
/**
* `page` edits a content page (#722) instead of a journal entry: its own
* API and live URL, no map block, a settings side panel instead of the
* story settings, and a Publish button that saves the Draft flag at once.
*/
kind?: 'journal' | 'page';
}
export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps) {
export function JournalEditor({ slug, mapEnabled, onBack, kind = 'journal' }: JournalEditorProps) {
const isPage = kind === 'page';
const apiUrl = isPage ? `/api/admin/pages/${slug}` : `/api/admin/journal/${slug}`;
const liveUrl = isPage ? `/${slug}` : `/journal/${slug}`;
const notify = useNotify();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
@@ -63,6 +75,8 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
} = useSplitPane();
const [rawMarkdown, setRawMarkdown] = useState('');
/** The markdown as last rendered: tells a finished save whether typing went on meanwhile. */
const latestMarkdown = useLatest(rawMarkdown);
const [parsed, setParsed] = useState<ParsedJournal>(() => ({
frontmatter: {},
blocks: [],
@@ -81,14 +95,17 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
// Unsaved edits survive leaving the editor — the entry list, a tab link,
// back, Reload (#592). The markdown is the whole entry; blocks and
// frontmatter are parsed from it. `serverMarkdown` is what a discard restores.
const draft = useDraft<string>(`journal-${slug}`, rawMarkdown, dirty);
const draft = useDraft<string>(`${isPage ? 'page' : 'journal'}-${slug}`, rawMarkdown, dirty);
const loadDraft = draft.load;
const serverMarkdown = useRef('');
/** The file as loaded, sent back on save so a change elsewhere is caught (#601). */
const versionRef = useRef<string | null>(null);
const versionedSave = useVersionedSave();
// Bumped when this entry is restored from a backup, to load it again.
const [reloadKey, setReloadKey] = useState(0);
useContentRestored(({ target, slug: restored }) => {
if (target === 'journal' && restored === slug) setReloadKey((k) => k + 1);
if (target === (isPage ? 'pages' : 'journal') && restored === slug) setReloadKey((k) => k + 1);
});
// Load entry
@@ -97,7 +114,7 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
setLoading(true);
setLoadError(null);
try {
const res = await fetch(`/api/admin/journal/${slug}`);
const res = await fetch(apiUrl);
if (!res.ok) {
throw new Error(
res.status === 401
@@ -106,7 +123,8 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
);
}
const data = await res.json();
const md: string = data.entry.rawMarkdown;
versionRef.current = typeof data.version === 'string' ? data.version : null;
const md: string = (isPage ? data.page : data.entry).rawMarkdown;
serverMarkdown.current = md;
const restored = loadDraft(md);
setRawMarkdown(restored ?? md);
@@ -122,7 +140,7 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
}
}
load();
}, [slug, loadDraft, reloadKey]);
}, [slug, apiUrl, isPage, loadDraft, reloadKey]);
// Update markdown and sync blocks
const handleMarkdownChange = (newMd: string) => {
@@ -159,44 +177,61 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
setDirty(true);
};
// Save
const handleSave = async () => {
// Save. `override` is markdown to save instead of the editor's — the page
// editor's Publish button changes the Draft flag and saves in one go.
const handleSave = async (override?: string) => {
// The editor is not rendered in this state, but Cmd+S still reaches here.
if (loadError) return;
// Nothing to save: every save rotates a backup, so repeated Cmd+S on an
// unchanged entry pushed real history out of the ten kept per file.
if (!dirty || saving) return;
if ((!dirty && override === undefined) || saving) return;
const toSave = override ?? rawMarkdown;
setSaving(true);
try {
const res = await fetch(`/api/admin/journal/${slug}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
rawMarkdown,
}),
});
const result = await versionedSave(
apiUrl,
{ rawMarkdown: toSave },
versionRef,
isPage ? 'This page' : 'This entry',
);
if (res.ok) {
if (result.kind === 'reload') {
draft.discard();
setReloadKey((k) => k + 1);
} else if (result.kind === 'keep') {
notify('error', 'Not saved — it changed elsewhere. Your edits are still here.');
} else if (result.kind === 'saved') {
// The file as written, not as sent: a password line is hashed on the
// way to disk (#690). Taking the sent text as the base would make the
// next draft look like a conflicting edit from elsewhere.
const data = await res.json().catch(() => null);
const data = result.data as {
page?: { rawMarkdown?: unknown };
entry?: { rawMarkdown?: unknown };
};
const record = isPage ? data.page : data.entry;
const written: string =
typeof data?.entry?.rawMarkdown === 'string' ? data.entry.rawMarkdown : rawMarkdown;
typeof record?.rawMarkdown === 'string' ? record.rawMarkdown : toSave;
serverMarkdown.current = written;
draft.saved(written);
if (written !== rawMarkdown) {
setRawMarkdown(written);
setParsed(parseJournalMarkdown(written));
// Edited while the request was out: those edits are not saved yet, so
// they stay dirty and are not replaced by the file as written.
const editedMeanwhile = latestMarkdown.current !== toSave;
draft.saved(written, editedMeanwhile);
if (!editedMeanwhile) {
if (written !== toSave) {
setRawMarkdown(written);
setParsed(parseJournalMarkdown(written));
}
setDirty(false);
}
setDirty(false);
} else if (!reportIfSessionExpired(res)) {
const data = await res.json();
notify('error', data.error || 'Failed to save');
} else if (!reportIfSessionExpired(result.res)) {
notify('error', result.data?.error || 'Failed to save');
}
} catch {
notify('error', 'Could not save the entry. Check the connection and try again.');
notify(
'error',
`Could not save the ${isPage ? 'page' : 'entry'}. Check the connection and try again.`,
);
} finally {
setSaving(false);
}
@@ -278,6 +313,22 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
};
// Block manipulation
/** Page mode: publish or unpublish, saved at once. Same flag as the Draft pill. */
const handleTogglePublish = () => {
const updated: ParsedJournal = {
...parsed,
frontmatter: { ...parsed.frontmatter, draft: !parsed.frontmatter.draft },
};
const serialized = serializeJournalMarkdown(updated);
setParsed(updated);
setRawMarkdown(serialized);
// An edit like any other until the save lands: a failed or refused save
// must leave the flipped flag unsaved, not shown as "Saved" (a success
// clears it again).
setDirty(true);
handleSave(serialized);
};
const handleAddBlock = (type: JournalBlock['type']) => {
handleBlocksChange([...parsed.blocks, createBlock(type)]);
};
@@ -304,7 +355,7 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
if (loading) {
return (
<div style={{ padding: '4rem', textAlign: 'center', opacity: 0.6 }}>
Opening Journal Studio...
{isPage ? 'Opening page editor...' : 'Opening Journal Studio...'}
</div>
);
}
@@ -316,13 +367,13 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
if (loadError) {
return (
<div className="admin-error" role="alert">
<strong>This entry could not be loaded.</strong> {loadError}
<strong>This {isPage ? 'page' : 'entry'} could not be loaded.</strong> {loadError}
<p>
Nothing has been changed. Saving stays disabled until it loads, so an empty editor cannot
overwrite the entry.
overwrite it.
</p>
<button className="admin-btn admin-btn-secondary" onClick={onBack}>
Back to entries
{isPage ? 'Back to pages' : 'Back to entries'}
</button>
</div>
);
@@ -338,15 +389,15 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
className="admin-btn admin-btn-sm admin-btn-secondary"
onClick={onBack}
>
← All Entries
{isPage ? '← Pages' : '← All Entries'}
</button>
<input
type="text"
className="journal-editor-title-input"
value={parsed.frontmatter.title || ''}
placeholder="Story Title..."
aria-label="Story title"
placeholder={isPage ? 'Page title...' : 'Story Title...'}
aria-label={isPage ? 'Page title' : 'Story title'}
onChange={(e) => handleFrontmatterChange({ title: e.target.value })}
/>
@@ -382,11 +433,27 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
className="admin-btn admin-btn-sm admin-btn-secondary"
onClick={() => setShowMetaModal(true)}
>
<IconGear size={14} /> Story Settings
<IconGear size={14} /> {isPage ? 'Page Settings' : 'Story Settings'}
</button>
{isPage && (
<button
type="button"
className="admin-btn admin-btn-sm admin-btn-secondary"
onClick={handleTogglePublish}
disabled={saving}
title={
parsed.frontmatter.draft
? 'Publish the page and save it now'
: 'Turn the page back into a draft and save it now'
}
>
{parsed.frontmatter.draft ? 'Publish' : 'Unpublish'}
</button>
)}
<a
href={`/journal/${slug}`}
href={liveUrl}
target="_blank"
rel="noopener noreferrer"
className="admin-btn admin-btn-sm admin-btn-secondary"
@@ -397,7 +464,7 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
<button
type="button"
className="admin-btn admin-btn-sm admin-btn-primary"
onClick={handleSave}
onClick={() => handleSave()}
disabled={saving || !dirty}
>
{saving ? (
@@ -415,7 +482,7 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
<DraftNotice
status={draft.status}
subject="entry"
subject={isPage ? 'page' : 'entry'}
onDiscard={discardDraft}
onRestore={restoreConflictingDraft}
onDismiss={draft.dismiss}
@@ -477,6 +544,19 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
>
<IconCamera size={13} /> + Photo
</button>
<button
type="button"
className="admin-btn admin-btn-xs admin-btn-primary"
onClick={() =>
setAssetPickerTarget({
title: 'Add Photos (one block each)',
onSelectMany: (ids) =>
handleBlocksChange([...parsed.blocks, ...createPhotoBlocks(ids)]),
})
}
>
<IconCamera size={13} /> + Photos
</button>
<button
type="button"
className="admin-btn admin-btn-xs admin-btn-primary"
@@ -498,13 +578,16 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
>
<IconFolder size={13} /> + Album
</button>
<button
type="button"
className="admin-btn admin-btn-xs"
onClick={() => handleAddBlock('map')}
>
<IconMap size={13} /> + Map
</button>
{/* No map block on content pages in v1 (#722). */}
{!isPage && (
<button
type="button"
className="admin-btn admin-btn-xs"
onClick={() => handleAddBlock('map')}
>
<IconMap size={13} /> + Map
</button>
)}
</div>
{/* Blocks List */}
@@ -591,8 +674,17 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
<JournalPreview parsed={parsed} albumAssets={albumAssets} />
</div>
{/* Page settings: a side panel, so editing never means going back (#722). */}
{showMetaModal && isPage && (
<PageSettingsPanel
frontmatter={parsed.frontmatter}
onChange={handleFrontmatterChange}
onClose={() => setShowMetaModal(false)}
/>
)}
{/* Metadata Modal */}
{showMetaModal && (
{showMetaModal && !isPage && (
<StorySettingsModal
frontmatter={parsed.frontmatter}
onChange={handleFrontmatterChange}
@@ -618,10 +710,18 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
{assetPickerTarget && (
<AssetPicker
title={assetPickerTarget.title}
max={assetPickerTarget.max}
onSelect={(id) => {
assetPickerTarget.onSelect(id);
assetPickerTarget.onSelect?.(id);
setAssetPickerTarget(null);
}}
onSelectMany={
assetPickerTarget.onSelectMany &&
((ids) => {
assetPickerTarget.onSelectMany?.(ids);
setAssetPickerTarget(null);
})
}
onClose={() => setAssetPickerTarget(null)}
/>
)}