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

@@ -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&apos;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) => {

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)}
/>
)}

View File

@@ -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>

View 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>
);
}

View File

@@ -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 &amp; 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 &amp; 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' }}>

View File

@@ -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) {

View File

@@ -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);
}