release: v0.11.0 (#459)
Ships `dev` as **v0.11.0**. `main` contains nothing that `dev` does not, so this is a clean sync. ## Headline changes since v0.10.0 - **Journal & Journal Studio** — photo essays at `/journal`, split-screen block editor with live preview, drafts, per-entry passwords, cover images - **First-run setup wizard at `/install`** — configure a fresh deployment from the browser; credentials are verified against Immich before anything is written, and the wizard is gated by a one-time token - **About page editor and toggle** in the admin panel - **Per-album photo order** independent of Immich, with a drag & drop editor - **Custom favicon upload** - **Experimental portfolio features** — justified grid, `cover` hero, unlisted subpages, per-album grid overrides, cover focal points, external nav links - **Security** — asset tokens length-capped before decoding (#423), journal author markdown escaped rather than filtered, journal file paths contained, admin session key derived with scrypt (#432), Next.js 16.3.0 for CVE-2025-13465 - **Fixed** — client proofing honours its own configuration again (#454); journal photo rendering (#432) Full detail in [CHANGELOG.md](CHANGELOG.md#0110--2026-08-15). ## Verified `npx tsc --noEmit` clean · `npx vitest run` 435 tests pass · `npm run build` succeeds · lint 0 errors · `prettier --check` clean · local `docker build` of the release tree. ## After merge Tag `v0.11.0` on `main` and publish the release — that triggers `docker-publish.yml`, which pushes the image and moves `latest`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: ralksta <ralksta@users.noreply.github.com> Co-authored-by: lancetm714 <108419425+lancetm714@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useCallback } from 'react';
|
||||
import {
|
||||
parseEssayMarkdown,
|
||||
serializeEssayMarkdown,
|
||||
@@ -28,16 +28,19 @@ interface EssayBlockEditorProps {
|
||||
|
||||
export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlockEditorProps) {
|
||||
const [essay, setEssay] = useState<ParsedEssay>(() =>
|
||||
parseEssayMarkdown(markdown || '# Title\n\nWrite your story here...')
|
||||
parseEssayMarkdown(markdown || '# Title\n\nWrite your story here...'),
|
||||
);
|
||||
|
||||
// Sync internal state when prop changes from outside (e.g. initial load)
|
||||
useEffect(() => {
|
||||
const currentSerialized = serializeEssayMarkdown(essay);
|
||||
if (markdown !== currentSerialized && markdown) {
|
||||
// Sync internal state when the prop changes from outside (e.g. initial load).
|
||||
// Adjusting during render rather than in an effect: an effect would commit the
|
||||
// stale essay first and re-render, which flashes the previous story's blocks.
|
||||
const [lastMarkdown, setLastMarkdown] = useState(markdown);
|
||||
if (markdown !== lastMarkdown) {
|
||||
setLastMarkdown(markdown);
|
||||
if (markdown && markdown !== serializeEssayMarkdown(essay)) {
|
||||
setEssay(parseEssayMarkdown(markdown));
|
||||
}
|
||||
}, [markdown]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}
|
||||
|
||||
const updateBlocks = useCallback(
|
||||
(newBlocks: EssayBlock[]) => {
|
||||
@@ -50,15 +53,15 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
|
||||
if (b.type === 'photo') return [b.assetId];
|
||||
if (b.type === 'photo-pair') return b.assetIds;
|
||||
return [];
|
||||
})
|
||||
)
|
||||
}),
|
||||
),
|
||||
),
|
||||
};
|
||||
|
||||
setEssay(updatedEssay);
|
||||
onChange(serializeEssayMarkdown(updatedEssay));
|
||||
},
|
||||
[essay, onChange]
|
||||
[essay, onChange],
|
||||
);
|
||||
|
||||
// Block manipulation helpers
|
||||
@@ -171,7 +174,10 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
|
||||
{/* Card Header */}
|
||||
<div className="essay-block-card-header">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<IconGripVertical size={14} style={{ color: 'var(--admin-text-muted)', cursor: 'grab' }} />
|
||||
<IconGripVertical
|
||||
size={14}
|
||||
style={{ color: 'var(--admin-text-muted)', cursor: 'grab' }}
|
||||
/>
|
||||
<BlockBadge type={block.type} index={idx + 1} />
|
||||
</div>
|
||||
|
||||
@@ -227,7 +233,10 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
|
||||
value={block.text}
|
||||
onChange={(e) => handleUpdateBlock(idx, { ...block, text: e.target.value })}
|
||||
placeholder="Section heading..."
|
||||
style={{ flex: 1, fontWeight: block.level === 1 ? 700 : block.level === 2 ? 600 : 500 }}
|
||||
style={{
|
||||
flex: 1,
|
||||
fontWeight: block.level === 1 ? 700 : block.level === 2 ? 600 : 500,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -237,7 +246,7 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
|
||||
<textarea
|
||||
rows={3}
|
||||
value={block.html.replace(/<[^>]+>/g, (t) =>
|
||||
t.startsWith('<strong>') ? '**' : t.startsWith('</strong>') ? '**' : ''
|
||||
t.startsWith('<strong>') ? '**' : t.startsWith('</strong>') ? '**' : '',
|
||||
)}
|
||||
onChange={(e) => handleUpdateBlock(idx, { ...block, html: e.target.value })}
|
||||
placeholder="Write text paragraph... (**bold**, *italic* markdown supported)"
|
||||
@@ -246,7 +255,15 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
|
||||
|
||||
{/* 3. PULLQUOTE BLOCK */}
|
||||
{block.type === 'quote' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', borderLeft: '3px solid var(--admin-accent)', paddingLeft: '10px' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
borderLeft: '3px solid var(--admin-accent)',
|
||||
paddingLeft: '10px',
|
||||
}}
|
||||
>
|
||||
<textarea
|
||||
rows={2}
|
||||
value={block.text}
|
||||
@@ -293,7 +310,7 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
|
||||
className="admin-btn admin-btn-sm admin-btn-primary"
|
||||
onClick={() =>
|
||||
onSelectPhoto((pickedId) =>
|
||||
handleUpdateBlock(idx, { ...block, assetId: pickedId })
|
||||
handleUpdateBlock(idx, { ...block, assetId: pickedId }),
|
||||
)
|
||||
}
|
||||
>
|
||||
@@ -354,7 +371,13 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
|
||||
{/* Photo 1 */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
<span style={{ fontSize: '0.75rem', fontWeight: 600, color: 'var(--admin-text-secondary)' }}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 600,
|
||||
color: 'var(--admin-text-secondary)',
|
||||
}}
|
||||
>
|
||||
Photo 1
|
||||
</span>
|
||||
<div className="essay-photo-preview-container">
|
||||
@@ -380,7 +403,7 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
|
||||
handleUpdateBlock(idx, {
|
||||
...block,
|
||||
assetIds: [pickedId, block.assetIds[1]],
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
>
|
||||
@@ -405,8 +428,20 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
|
||||
|
||||
{/* Photo 2 */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: '0.75rem', fontWeight: 600, color: 'var(--admin-text-secondary)' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 600,
|
||||
color: 'var(--admin-text-secondary)',
|
||||
}}
|
||||
>
|
||||
Photo 2
|
||||
</span>
|
||||
<button
|
||||
@@ -447,7 +482,7 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
|
||||
handleUpdateBlock(idx, {
|
||||
...block,
|
||||
assetIds: [block.assetIds[0], pickedId],
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user