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:
Ralksta
2026-08-15 09:09:18 +02:00
committed by GitHub
parent 6eda34cc50
commit 2f546f440d
61 changed files with 3953 additions and 1115 deletions

View File

@@ -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],
})
}),
)
}
>