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:
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user