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:
@@ -13,12 +13,17 @@ import {
|
||||
IconFolder,
|
||||
IconX,
|
||||
} from '../Icons';
|
||||
import { isLegacyAssetRef } from './blockOps';
|
||||
import { InlineMarkdownField } from '../InlineMarkdownField';
|
||||
import { fillAssetSlots, isLegacyAssetRef } from './blockOps';
|
||||
|
||||
/** What the asset picker is opened for: its title, and where the pick goes. */
|
||||
export interface AssetPickTarget {
|
||||
title: string;
|
||||
onSelect: (assetId: string) => void;
|
||||
onSelect?: (assetId: string) => void;
|
||||
/** Multi-select: several picks confirmed at once (#602). */
|
||||
onSelectMany?: (assetIds: string[]) => void;
|
||||
/** Most assets `onSelectMany` takes. */
|
||||
max?: number;
|
||||
}
|
||||
|
||||
interface BlockFieldsProps {
|
||||
@@ -71,22 +76,26 @@ export function BlockFields({
|
||||
)}
|
||||
|
||||
{block.type === 'paragraph' && (
|
||||
<textarea
|
||||
<InlineMarkdownField
|
||||
multiline
|
||||
aria-label="Text"
|
||||
className="admin-input"
|
||||
rows={3}
|
||||
value={block.html}
|
||||
onChange={(e) => onChange({ ...block, html: e.target.value })}
|
||||
placeholder="Text (**bold**, *italic*, [link](https://…))"
|
||||
html={block.html}
|
||||
onChange={(html) => onChange({ ...block, html })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{block.type === 'quote' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
<textarea
|
||||
<InlineMarkdownField
|
||||
multiline
|
||||
className="admin-input"
|
||||
rows={2}
|
||||
value={block.text}
|
||||
html={block.text}
|
||||
placeholder="Quote text..."
|
||||
onChange={(e) => onChange({ ...block, text: e.target.value })}
|
||||
onChange={(text) => onChange({ ...block, text })}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
@@ -176,12 +185,11 @@ export function BlockFields({
|
||||
Change Photo
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
<InlineMarkdownField
|
||||
className="admin-input"
|
||||
placeholder="Caption (optional)"
|
||||
value={block.caption || ''}
|
||||
onChange={(e) => onChange({ ...block, caption: e.target.value })}
|
||||
html={block.caption}
|
||||
onChange={(caption) => onChange({ ...block, caption })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -242,12 +250,31 @@ export function BlockFields({
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-xs"
|
||||
onClick={() =>
|
||||
onPickAsset({
|
||||
title: 'Select Photos for Pair',
|
||||
max: 2,
|
||||
onSelectMany: (ids) => {
|
||||
// Two picks replace the pair; a single pick fills a gap.
|
||||
const base = ids.length >= 2 ? ['', ''] : [...block.assetIds];
|
||||
const [a, b] = fillAssetSlots(base, ids, 2);
|
||||
onChange({ ...block, assetIds: [a ?? '', b ?? ''] });
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<IconCamera size={12} /> Pick both photos
|
||||
</button>
|
||||
</div>
|
||||
<InlineMarkdownField
|
||||
className="admin-input"
|
||||
placeholder="Shared caption for pair (optional)"
|
||||
value={block.caption || ''}
|
||||
onChange={(e) => onChange({ ...block, caption: e.target.value })}
|
||||
html={block.caption}
|
||||
onChange={(caption) => onChange({ ...block, caption })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -316,16 +343,28 @@ export function BlockFields({
|
||||
>
|
||||
<IconPlus size={12} /> Add photo
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-xs"
|
||||
onClick={() =>
|
||||
onPickAsset({
|
||||
title: 'Select Photos for Grid',
|
||||
onSelectMany: (ids) =>
|
||||
onChange({ ...block, assetIds: fillAssetSlots(block.assetIds, ids) }),
|
||||
})
|
||||
}
|
||||
>
|
||||
<IconCamera size={12} /> Pick several
|
||||
</button>
|
||||
<span style={{ fontSize: '0.75rem', opacity: 0.6 }}>
|
||||
Three or more photos, laid out in rows of three.
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
<InlineMarkdownField
|
||||
className="admin-input"
|
||||
placeholder="Shared caption for grid (optional)"
|
||||
value={block.caption || ''}
|
||||
onChange={(e) => onChange({ ...block, caption: e.target.value })}
|
||||
html={block.caption}
|
||||
onChange={(caption) => onChange({ ...block, caption })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -348,17 +387,14 @@ export function BlockFields({
|
||||
})
|
||||
}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
<InlineMarkdownField
|
||||
className="admin-input"
|
||||
placeholder="Value, e.g. 21 km"
|
||||
value={item.value}
|
||||
onChange={(e) =>
|
||||
html={item.value}
|
||||
onChange={(value) =>
|
||||
onChange({
|
||||
...block,
|
||||
items: block.items.map((it, i) =>
|
||||
i === fIdx ? { ...it, value: e.target.value } : it,
|
||||
),
|
||||
items: block.items.map((it, i) => (i === fIdx ? { ...it, value } : it)),
|
||||
})
|
||||
}
|
||||
/>
|
||||
@@ -467,17 +503,11 @@ export function BlockFields({
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
<InlineMarkdownField
|
||||
className="admin-input"
|
||||
placeholder="Caption for the set (optional)"
|
||||
value={block.caption || ''}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...block,
|
||||
caption: e.target.value || undefined,
|
||||
})
|
||||
}
|
||||
html={block.caption}
|
||||
onChange={(caption) => onChange({ ...block, caption: caption || undefined })}
|
||||
/>
|
||||
<span style={{ fontSize: '0.75rem', opacity: 0.6 }}>
|
||||
Photos follow the album's order (a manual order in the gallery comes first). Count
|
||||
@@ -494,12 +524,11 @@ export function BlockFields({
|
||||
enabled.
|
||||
</p>
|
||||
)}
|
||||
<input
|
||||
type="text"
|
||||
<InlineMarkdownField
|
||||
className="admin-input"
|
||||
placeholder="Caption, e.g. Busan → Seoul (optional)"
|
||||
value={block.caption || ''}
|
||||
onChange={(e) => onChange({ ...block, caption: e.target.value })}
|
||||
html={block.caption}
|
||||
onChange={(caption) => onChange({ ...block, caption })}
|
||||
/>
|
||||
|
||||
{block.items.map((item, mIdx) => {
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import type { JournalEntrySummary } from '@/lib/journal';
|
||||
import { serializeJournalMarkdown, sanitizeSlug } from '@/lib/journal';
|
||||
import { serializeJournalMarkdown, sanitizeSlug, slugFieldValue } from '@/lib/journal';
|
||||
import { JOURNAL_TEMPLATES } from '@/lib/journalTemplates';
|
||||
import {
|
||||
IconTrash,
|
||||
@@ -22,6 +22,7 @@ import { useContentRestored } from '../contentRestored';
|
||||
import AdminLoadState from '../AdminLoadState';
|
||||
import PageHeader from '../PageHeader';
|
||||
import { useConfirm } from '../ConfirmDialog';
|
||||
import { useModalDialog } from '@/hooks/useModalDialog';
|
||||
|
||||
interface JournalStudioProps {
|
||||
/** Entry to open, taken from the /admin/journal/[slug] route. */
|
||||
@@ -50,6 +51,11 @@ export function JournalStudio({ slug: activeSlug, mapEnabled }: JournalStudioPro
|
||||
const [creating, setCreating] = useState(false);
|
||||
/** null = start blank, matching the previous (only) behavior. */
|
||||
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null);
|
||||
const closeCreateModal = () => {
|
||||
setShowCreateModal(false);
|
||||
setSelectedTemplateId(null);
|
||||
};
|
||||
const createCardRef = useModalDialog(closeCreateModal, showCreateModal);
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -254,8 +260,16 @@ export function JournalStudio({ slug: activeSlug, mapEnabled }: JournalStudioPro
|
||||
{/* Create Modal */}
|
||||
{showCreateModal && (
|
||||
<div className="journal-modal-overlay">
|
||||
<div className="journal-modal-card">
|
||||
<h3 style={{ margin: '0 0 1rem' }}>Create New Journal Entry</h3>
|
||||
<div
|
||||
className="journal-modal-card"
|
||||
ref={createCardRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="journal-new-dialog-title"
|
||||
>
|
||||
<h3 id="journal-new-dialog-title" style={{ margin: '0 0 1rem' }}>
|
||||
Create New Journal Entry
|
||||
</h3>
|
||||
<form onSubmit={handleCreate}>
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<label
|
||||
@@ -304,13 +318,13 @@ export function JournalStudio({ slug: activeSlug, mapEnabled }: JournalStudioPro
|
||||
className="admin-input"
|
||||
placeholder="e.g. expedition-nordkap"
|
||||
value={newSlug}
|
||||
onChange={(e) => setNewSlug(sanitizeSlug(e.target.value))}
|
||||
onChange={(e) => setNewSlug(slugFieldValue(e.target.value))}
|
||||
required
|
||||
/>
|
||||
<span
|
||||
style={{ fontSize: '0.75rem', opacity: 0.6, marginTop: '2px', display: 'block' }}
|
||||
>
|
||||
Will be accessible at /journal/{newSlug || 'slug'}
|
||||
Will be accessible at /journal/{newSlug ? sanitizeSlug(newSlug) : 'slug'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -352,10 +366,7 @@ export function JournalStudio({ slug: activeSlug, mapEnabled }: JournalStudioPro
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-secondary"
|
||||
onClick={() => {
|
||||
setShowCreateModal(false);
|
||||
setSelectedTemplateId(null);
|
||||
}}
|
||||
onClick={closeCreateModal}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
82
app/admin/components/journal/PageSettingsPanel.tsx
Normal file
82
app/admin/components/journal/PageSettingsPanel.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import type { ParsedJournal } from '@/lib/journal';
|
||||
import PasswordField from '../fields/PasswordField';
|
||||
import { IconX } from '../Icons';
|
||||
|
||||
type Frontmatter = ParsedJournal['frontmatter'];
|
||||
|
||||
interface PageSettingsPanelProps {
|
||||
frontmatter: Frontmatter;
|
||||
onChange: (updates: Partial<Frontmatter>) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A content page's settings inside the editor (#722): a side panel rather than
|
||||
* the journal's modal, so the preview stays in view. The slug and the menu
|
||||
* place are edited in Pages, where the collision check and the menu live.
|
||||
*/
|
||||
export function PageSettingsPanel({ frontmatter, onChange, onClose }: PageSettingsPanelProps) {
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<aside className="page-settings-panel" aria-label="Page settings">
|
||||
<div className="page-settings-panel-head">
|
||||
<h3>Page settings</h3>
|
||||
<button type="button" className="admin-btn-icon" onClick={onClose} title="Close">
|
||||
<IconX size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="admin-field">
|
||||
<label htmlFor="page-settings-description">SEO description</label>
|
||||
<textarea
|
||||
id="page-settings-description"
|
||||
rows={3}
|
||||
value={frontmatter.description || ''}
|
||||
placeholder="One or two sentences for search engines and link previews"
|
||||
// One line in the frontmatter: a line break would end the value.
|
||||
onChange={(e) => onChange({ description: e.target.value.replace(/\s*\n\s*/g, ' ') })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="setting-row"
|
||||
onClick={() => onChange({ draft: !frontmatter.draft })}
|
||||
aria-pressed={!!frontmatter.draft}
|
||||
>
|
||||
<span className="setting-row-info">
|
||||
<span className="setting-row-title">Draft</span>
|
||||
<span className="setting-row-desc">
|
||||
Only you can see a draft, and it stays out of the menu and the sitemap.
|
||||
</span>
|
||||
</span>
|
||||
<span className={`switch-toggle switch-toggle--sm ${frontmatter.draft ? 'on' : ''}`}>
|
||||
<span className="switch-slider" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div className="admin-field">
|
||||
<span className="admin-field-label">Password (optional)</span>
|
||||
<PasswordField
|
||||
value={frontmatter.password || undefined}
|
||||
onChange={(password) => onChange({ password: password ?? '' })}
|
||||
label="Page password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="page-settings-panel-note">
|
||||
Settings are saved with the page. The URL and the menu place are set in Pages.
|
||||
</p>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import type { ParsedJournal } from '@/lib/journal';
|
||||
import type { AssetPickTarget } from './BlockFields';
|
||||
import PasswordField from '../fields/PasswordField';
|
||||
import { useModalDialog } from '@/hooks/useModalDialog';
|
||||
|
||||
type Frontmatter = ParsedJournal['frontmatter'];
|
||||
|
||||
@@ -20,10 +21,20 @@ export function StorySettingsModal({
|
||||
onPickAsset,
|
||||
onClose,
|
||||
}: StorySettingsModalProps) {
|
||||
const cardRef = useModalDialog(onClose);
|
||||
|
||||
return (
|
||||
<div className="journal-modal-overlay">
|
||||
<div className="journal-modal-card">
|
||||
<h3 style={{ margin: '0 0 1.25rem' }}>Story Settings & Metadata</h3>
|
||||
<div
|
||||
className="journal-modal-card"
|
||||
ref={cardRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="story-settings-title"
|
||||
>
|
||||
<h3 id="story-settings-title" style={{ margin: '0 0 1.25rem' }}>
|
||||
Story Settings & Metadata
|
||||
</h3>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
<div>
|
||||
@@ -153,6 +164,7 @@ export function StorySettingsModal({
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="story-password"
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: '0.8rem',
|
||||
@@ -162,11 +174,15 @@ export function StorySettingsModal({
|
||||
>
|
||||
Password Protection (Optional)
|
||||
</label>
|
||||
<PasswordField
|
||||
value={frontmatter.password || undefined}
|
||||
onChange={(password) => onChange({ password: password ?? '' })}
|
||||
label="Entry password"
|
||||
/>
|
||||
{/* The input takes its styles from .admin-field, as everywhere else
|
||||
PasswordField is used; without it the lock icon sat on the text. */}
|
||||
<div className="admin-field" style={{ marginBottom: 0 }}>
|
||||
<PasswordField
|
||||
id="story-password"
|
||||
value={frontmatter.password || undefined}
|
||||
onChange={(password) => onChange({ password: password ?? '' })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', paddingTop: '4px' }}>
|
||||
|
||||
@@ -17,6 +17,26 @@ export function isLegacyAssetRef(assetId: string): boolean {
|
||||
return assetId.length > 0 && !ASSET_UUID.test(assetId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Slots after a multi-pick (#602): picked photos fill the empty slots first,
|
||||
* in pick order, and the rest are appended. With `capacity` (a pair holds two)
|
||||
* the list never grows past it; overflow picks are dropped.
|
||||
*/
|
||||
export function fillAssetSlots(slots: string[], picked: string[], capacity?: number): string[] {
|
||||
const next = [...slots];
|
||||
const queue = [...picked];
|
||||
for (let i = 0; i < next.length && queue.length > 0; i++) {
|
||||
if (!next[i]) next[i] = queue.shift()!;
|
||||
}
|
||||
next.push(...queue);
|
||||
return capacity === undefined ? next : next.slice(0, capacity);
|
||||
}
|
||||
|
||||
/** One photo block per picked asset, for "+ Photos" (#602). */
|
||||
export function createPhotoBlocks(assetIds: string[]): JournalBlock[] {
|
||||
return assetIds.map((assetId) => ({ type: 'photo', assetId, caption: '', layout: 'contained' }));
|
||||
}
|
||||
|
||||
/** The block the "Add Block" toolbar inserts for each type. */
|
||||
export function createBlock(type: JournalBlock['type']): JournalBlock {
|
||||
switch (type) {
|
||||
|
||||
@@ -104,7 +104,9 @@
|
||||
|
||||
.journal-list-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
/* min(): on a phone the column is narrower than 320px, and a fixed minimum
|
||||
pushed every card past the right edge. */
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(320px, 100%), 1fr));
|
||||
gap: 1.5rem;
|
||||
padding: 2rem;
|
||||
}
|
||||
@@ -680,6 +682,12 @@
|
||||
Below 700px it wraps: back button and mode toggle, the title on a line of
|
||||
its own, then the actions with Save taking the remaining width. */
|
||||
@media (max-width: 700px) {
|
||||
/* The workspace already keeps a gutter; a second 2rem one left the cards
|
||||
under 300px wide. */
|
||||
.journal-list-grid {
|
||||
padding: 1.25rem 0;
|
||||
}
|
||||
|
||||
.journal-editor-topbar {
|
||||
flex-wrap: wrap;
|
||||
padding: 0.75rem 1rem;
|
||||
@@ -710,3 +718,43 @@
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Content page settings: a side panel, not a modal (#722) ── */
|
||||
.page-settings-panel {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: min(360px, 100vw);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 1.25rem;
|
||||
overflow-y: auto;
|
||||
background: var(--admin-bg-raised);
|
||||
border-left: 1px solid var(--admin-border);
|
||||
box-shadow: -12px 0 32px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.page-settings-panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.page-settings-panel-head h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.page-settings-panel textarea {
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.page-settings-panel-note {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--admin-text-muted);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user