Files
immich-folio/app/admin/components/journal/JournalStudio.tsx
Ralksta ad8b09700b 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>
2026-09-29 22:50:30 +02:00

388 lines
13 KiB
TypeScript

'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import type { JournalEntrySummary } from '@/lib/journal';
import { serializeJournalMarkdown, sanitizeSlug, slugFieldValue } from '@/lib/journal';
import { JOURNAL_TEMPLATES } from '@/lib/journalTemplates';
import {
IconTrash,
IconLink,
IconPlus,
IconBook,
IconClock,
IconCalendar,
IconLock,
} from '../Icons';
import './journal-studio.css';
import { JournalEditor } from './JournalEditor';
import { useNotify } from '../Notifications';
import { useAdminFetch } from '../useAdminFetch';
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. */
slug?: string;
/** `config.map`, passed by the server route: a map block renders only when the site has a map. */
mapEnabled?: boolean;
}
export function JournalStudio({ slug: activeSlug, mapEnabled }: JournalStudioProps) {
const confirm = useConfirm();
const notify = useNotify();
const router = useRouter();
const list = useAdminFetch<{ entries?: JournalEntrySummary[] }>('/api/admin/journal');
const entries = list.data?.entries ?? [];
const fetchEntries = list.reload;
// A restored entry may be one that was deleted, or have a new title.
useContentRestored(({ target }) => {
if (target === 'journal') fetchEntries();
});
// New Entry Modal
const [showCreateModal, setShowCreateModal] = useState(false);
const [newTitle, setNewTitle] = useState('');
const [newSlug, setNewSlug] = useState('');
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();
if (!newTitle.trim()) return;
setCreating(true);
try {
const slug = sanitizeSlug(newSlug || newTitle);
const template = selectedTemplateId
? JOURNAL_TEMPLATES.find((t) => t.id === selectedTemplateId)
: undefined;
const content = template
? serializeJournalMarkdown({
frontmatter: {
title: newTitle.trim(),
date: new Date().toISOString().slice(0, 10),
draft: true,
},
blocks: template.blocks,
referencedAssetIds: [],
})
: undefined;
const res = await fetch('/api/admin/journal', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: newTitle.trim(),
slug,
...(content ? { content } : {}),
}),
});
if (res.ok) {
const data = await res.json();
setShowCreateModal(false);
setNewTitle('');
setNewSlug('');
setSelectedTemplateId(null);
fetchEntries();
if (data.entry?.slug) {
router.push(`/admin/journal/${data.entry.slug}`);
}
} else {
const data = await res.json();
notify('error', data.error || 'Failed to create journal entry');
}
} catch {
notify('error', 'Could not create the journal entry. Check the connection and try again.');
} finally {
setCreating(false);
}
};
const handleDelete = async (slug: string) => {
const ok = await confirm({
title: `Delete “${slug}”?`,
message: 'A copy is kept and can be restored under Backups.',
confirmLabel: 'Delete',
danger: true,
});
if (!ok) return;
try {
const res = await fetch(`/api/admin/journal/${slug}`, {
method: 'DELETE',
});
if (res.ok) {
fetchEntries();
if (activeSlug === slug) router.push('/admin/journal');
} else {
notify('error', 'Failed to delete journal entry');
}
} catch {
notify('error', 'Could not delete the journal entry. Check the connection and try again.');
}
};
if (activeSlug) {
return (
<JournalEditor
slug={activeSlug}
mapEnabled={mapEnabled}
onBack={() => router.push('/admin/journal')}
/>
);
}
return (
<div className="journal-studio">
<PageHeader
kicker="Content"
title="Journal"
description="Stories, field notes and longform photo essays, written with a live preview."
actions={
<button
type="button"
className="admin-btn admin-btn-primary"
onClick={() => setShowCreateModal(true)}
>
<IconPlus size={16} /> New Journal Entry
</button>
}
/>
{(list.loading && !list.data) || list.error ? (
<AdminLoadState
loading={list.loading}
error={list.error}
onRetry={list.reload}
hasData={!!list.data}
/>
) : entries.length === 0 ? (
<div style={{ padding: '5rem 2rem', textAlign: 'center', opacity: 0.6 }}>
<h3>No journal entries yet</h3>
<p style={{ margin: '8px 0 1.5rem', fontSize: '0.9rem' }}>
Create your first story to share travel journals or wedding reportages.
</p>
<button
type="button"
className="admin-btn admin-btn-primary"
onClick={() => setShowCreateModal(true)}
>
<IconPlus size={16} /> Create First Entry
</button>
</div>
) : (
<div className="journal-list-grid">
{entries.map((entry) => (
<div key={entry.slug} className="journal-admin-card">
<div className="journal-admin-card-cover">
{entry.frontmatter.coverAssetId ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`/api/admin/thumbnail/${entry.frontmatter.coverAssetId}`}
alt={entry.frontmatter.title || entry.slug}
/>
) : (
<IconBook size={36} className="svg-icon journal-card-cover-placeholder" />
)}
</div>
<div className="journal-admin-card-body">
<h3 className="journal-admin-card-title">
{entry.frontmatter.title || entry.slug}
</h3>
<div className="journal-admin-card-slug">/journal/{entry.slug}</div>
<div className="journal-admin-card-meta">
{entry.frontmatter.date && (
<span>
<IconCalendar size={12} /> {entry.frontmatter.date}
</span>
)}
<span>
<IconClock size={12} /> {entry.readingTimeMinutes} min
</span>
{entry.frontmatter.draft ? (
<span className="journal-status-pill is-draft">Draft</span>
) : (
<span className="journal-status-pill is-published">Published</span>
)}
{entry.frontmatter.password && (
<span>
<IconLock size={12} /> Password
</span>
)}
</div>
<div className="journal-admin-card-actions">
<button
type="button"
className="admin-btn admin-btn-sm admin-btn-primary"
onClick={() => router.push(`/admin/journal/${entry.slug}`)}
>
Edit in Studio
</button>
<a
href={`/journal/${entry.slug}`}
target="_blank"
rel="noopener noreferrer"
className="admin-btn admin-btn-sm admin-btn-secondary"
title="View live page"
>
<IconLink size={14} />
</a>
<button
type="button"
className="admin-btn admin-btn-sm admin-btn-danger"
onClick={() => handleDelete(entry.slug)}
title="Delete entry"
style={{ marginLeft: 'auto' }}
>
<IconTrash size={14} />
</button>
</div>
</div>
</div>
))}
</div>
)}
{/* Create Modal */}
{showCreateModal && (
<div className="journal-modal-overlay">
<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
htmlFor="journal-new-title"
style={{
display: 'block',
fontSize: '0.85rem',
marginBottom: '4px',
opacity: 0.8,
}}
>
Title
</label>
<input
id="journal-new-title"
type="text"
className="admin-input"
placeholder="e.g. Expedition Nordkap"
value={newTitle}
onChange={(e) => {
setNewTitle(e.target.value);
if (!newSlug || newSlug === sanitizeSlug(newTitle)) {
setNewSlug(sanitizeSlug(e.target.value));
}
}}
autoFocus
required
/>
</div>
<div style={{ marginBottom: '1.5rem' }}>
<label
htmlFor="journal-new-url-slug"
style={{
display: 'block',
fontSize: '0.85rem',
marginBottom: '4px',
opacity: 0.8,
}}
>
URL Slug
</label>
<input
id="journal-new-url-slug"
type="text"
className="admin-input"
placeholder="e.g. expedition-nordkap"
value={newSlug}
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 ? sanitizeSlug(newSlug) : 'slug'}
</span>
</div>
<div style={{ marginBottom: '1.5rem' }}>
<label
style={{
display: 'block',
fontSize: '0.85rem',
marginBottom: '6px',
opacity: 0.8,
}}
>
Start from
</label>
<div className="journal-template-grid">
<button
type="button"
className={`journal-template-card${selectedTemplateId === null ? ' is-selected' : ''}`}
onClick={() => setSelectedTemplateId(null)}
>
<span className="journal-template-card-name">Blank</span>
<span className="journal-template-card-desc">Start with an empty editor.</span>
</button>
{JOURNAL_TEMPLATES.map((template) => (
<button
key={template.id}
type="button"
className={`journal-template-card${selectedTemplateId === template.id ? ' is-selected' : ''}`}
onClick={() => setSelectedTemplateId(template.id)}
>
<span className="journal-template-card-name">{template.name}</span>
<span className="journal-template-card-desc">{template.description}</span>
</button>
))}
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
<button
type="button"
className="admin-btn admin-btn-secondary"
onClick={closeCreateModal}
>
Cancel
</button>
<button
type="submit"
className="admin-btn admin-btn-primary"
disabled={creating || !newTitle.trim()}
>
{creating ? 'Creating...' : 'Create & Open Studio'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}