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:
@@ -18,6 +18,10 @@ interface AlbumDetailViewProps {
|
||||
heroImageUrl?: string;
|
||||
heroBlurDataURL?: string;
|
||||
watermark?: LightboxWatermark;
|
||||
/** Client proofing — favorite hearts, selection bar and send-off modal. */
|
||||
proofing?: boolean;
|
||||
/** Offer the "send by email" button in the proofing modal. */
|
||||
allowMailto?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,6 +60,8 @@ export function AlbumDetailView({
|
||||
heroImageUrl,
|
||||
heroBlurDataURL,
|
||||
watermark,
|
||||
proofing,
|
||||
allowMailto,
|
||||
}: AlbumDetailViewProps) {
|
||||
const metaDetail = albumMetaDetail(album);
|
||||
|
||||
@@ -70,7 +76,9 @@ export function AlbumDetailView({
|
||||
priority
|
||||
sizes="100vw"
|
||||
style={{ objectFit: 'cover' }}
|
||||
{...(heroBlurDataURL ? { placeholder: 'blur' as const, blurDataURL: heroBlurDataURL } : {})}
|
||||
{...(heroBlurDataURL
|
||||
? { placeholder: 'blur' as const, blurDataURL: heroBlurDataURL }
|
||||
: {})}
|
||||
/>
|
||||
<div className="album-hero__overlay" />
|
||||
</div>
|
||||
@@ -90,15 +98,18 @@ export function AlbumDetailView({
|
||||
<span className="album-header__meta-count">
|
||||
{images.length} {images.length === 1 ? 'photo' : 'photos'}
|
||||
</span>
|
||||
{metaDetail.date && (
|
||||
<span className="album-header__meta-date"> · {metaDetail.date}</span>
|
||||
)}
|
||||
{metaDetail.gear && (
|
||||
<span className="album-header__meta-gear">{metaDetail.gear}</span>
|
||||
)}
|
||||
{metaDetail.date && <span className="album-header__meta-date"> · {metaDetail.date}</span>}
|
||||
{metaDetail.gear && <span className="album-header__meta-gear">{metaDetail.gear}</span>}
|
||||
</p>
|
||||
</div>
|
||||
<PhotoGrid assets={images} layout={layout} gridStyle={gridStyle} watermark={watermark} />
|
||||
<PhotoGrid
|
||||
assets={images}
|
||||
layout={layout}
|
||||
gridStyle={gridStyle}
|
||||
watermark={watermark}
|
||||
proofing={proofing}
|
||||
allowMailto={allowMailto}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ interface EssayViewProps {
|
||||
* published story is not one. Album views opt in.
|
||||
*/
|
||||
proofing?: boolean;
|
||||
/** Offer the "send by email" button in the proofing modal. */
|
||||
allowMailto?: boolean;
|
||||
}
|
||||
|
||||
function EssayViewContent({
|
||||
@@ -384,7 +386,7 @@ export function EssayView(props: EssayViewProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<ProofingProvider albumTokens={albumTokens}>
|
||||
<ProofingProvider albumTokens={albumTokens} allowMailto={props.allowMailto ?? true}>
|
||||
<EssayViewContent {...props} />
|
||||
</ProofingProvider>
|
||||
);
|
||||
|
||||
@@ -35,15 +35,17 @@ export interface PhotoItem {
|
||||
interface PhotoGridProps {
|
||||
assets: PhotoItem[];
|
||||
layout?:
|
||||
| 'masonry'
|
||||
| 'uniform'
|
||||
| 'showcase'
|
||||
| 'filmstrip'
|
||||
| 'editorial-flow'
|
||||
| 'essay'
|
||||
| 'justified';
|
||||
'masonry' | 'uniform' | 'showcase' | 'filmstrip' | 'editorial-flow' | 'essay' | 'justified';
|
||||
gridStyle?: React.CSSProperties;
|
||||
watermark?: LightboxWatermark;
|
||||
/**
|
||||
* Client proofing — favorite hearts, the selection bar and the send-off
|
||||
* modal. Off unless the caller opts in, so a grid rendered outside the
|
||||
* album routes never grows a delivery workflow by accident.
|
||||
*/
|
||||
proofing?: boolean;
|
||||
/** Offer the "send by email" button in the proofing modal. */
|
||||
allowMailto?: boolean;
|
||||
}
|
||||
|
||||
/** Parse `#photo-N` from a hash string. Returns index or null. */
|
||||
@@ -120,7 +122,9 @@ function PhotoGridInner({ assets, layout = 'masonry', gridStyle, watermark }: Ph
|
||||
}, [displayedAssets.length]);
|
||||
|
||||
const goPrev = useCallback(() => {
|
||||
setLightboxIndex((prev) => (prev !== null ? (prev - 1 + displayedAssets.length) % displayedAssets.length : null));
|
||||
setLightboxIndex((prev) =>
|
||||
prev !== null ? (prev - 1 + displayedAssets.length) % displayedAssets.length : null,
|
||||
);
|
||||
}, [displayedAssets.length]);
|
||||
|
||||
// Keyboard navigation and the scroll lock live in <Lightbox/>.
|
||||
@@ -268,7 +272,9 @@ function PhotoGridInner({ assets, layout = 'masonry', gridStyle, watermark }: Ph
|
||||
type="button"
|
||||
onClick={() => proofing.setIsFilterActive((prev) => !prev)}
|
||||
style={{
|
||||
background: proofing.isFilterActive ? 'var(--accent, #e60012)' : 'rgba(255,255,255,0.15)',
|
||||
background: proofing.isFilterActive
|
||||
? 'var(--accent, #e60012)'
|
||||
: 'rgba(255,255,255,0.15)',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
padding: '6px 14px',
|
||||
@@ -298,7 +304,7 @@ function PhotoGridInner({ assets, layout = 'masonry', gridStyle, watermark }: Ph
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ProofingModal />
|
||||
{proofing && <ProofingModal />}
|
||||
|
||||
{lightboxIndex !== null && (
|
||||
<Lightbox
|
||||
@@ -317,8 +323,15 @@ function PhotoGridInner({ assets, layout = 'masonry', gridStyle, watermark }: Ph
|
||||
export function PhotoGrid(props: PhotoGridProps) {
|
||||
const albumTokens = useMemo(() => props.assets.map((a) => a.id), [props.assets]);
|
||||
|
||||
// Without the provider useProofing() returns null, and every proofing control
|
||||
// (hearts in the grid and in the lightbox, selection bar, modal) drops out on
|
||||
// its own.
|
||||
if (!props.proofing) {
|
||||
return <PhotoGridInner {...props} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ProofingProvider albumTokens={albumTokens}>
|
||||
<ProofingProvider albumTokens={albumTokens} allowMailto={props.allowMailto ?? true}>
|
||||
<PhotoGridInner {...props} />
|
||||
</ProofingProvider>
|
||||
);
|
||||
|
||||
@@ -61,7 +61,9 @@ function AlbumGrid({
|
||||
fill
|
||||
sizes="(max-width: 600px) 100vw, (max-width: 1000px) 50vw, 33vw"
|
||||
loading="lazy"
|
||||
{...(album.coverPosition ? { style: { objectPosition: album.coverPosition } } : {})}
|
||||
{...(album.coverPosition
|
||||
? { style: { objectPosition: album.coverPosition } }
|
||||
: {})}
|
||||
{...(ph ? { placeholder: 'blur' as const, blurDataURL: ph.blurDataURL } : {})}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
assetAspectRatio,
|
||||
} from '@/lib/urls';
|
||||
import { encodeAssetId } from '@/lib/tokens';
|
||||
import { getConfig, type GridConfig } from '@/lib/config';
|
||||
import { getConfig, resolveProofing, type GridConfig } from '@/lib/config';
|
||||
import { isProtected, isAuthenticated } from '@/lib/auth';
|
||||
import { isAdminAuthenticated } from '@/lib/admin/auth';
|
||||
import PasswordGate from '@/components/PasswordGate';
|
||||
@@ -182,6 +182,12 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
|
||||
const resolveLayout = (overrides?: Partial<GridConfig>) =>
|
||||
overrides?.layout ?? config.grid.layout;
|
||||
|
||||
// Client proofing — `proofing.enabled` in settings.yaml is the default and a
|
||||
// subpage's own `proofing:` flag overrides it in either direction. Albums
|
||||
// reached without a subpage follow the global setting.
|
||||
const proofingFor = (subpage?: { proofing?: boolean }) =>
|
||||
resolveProofing(subpage, config.proofing.enabled);
|
||||
|
||||
// EXPERIMENTAL: per-album grid override — merged over the subpage grid so
|
||||
// the precedence is global < subpage < album.
|
||||
const mergeAlbumGrid = (
|
||||
@@ -241,6 +247,8 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
|
||||
backLinkHref={`/${subpageSlug}`}
|
||||
backLinkLabel={`Back to ${subpageName}`}
|
||||
watermark={config.watermark}
|
||||
proofing={proofingFor(subpageData?.subpage)}
|
||||
allowMailto={config.proofing.allowMailto}
|
||||
{...heroData}
|
||||
/>
|
||||
);
|
||||
@@ -282,12 +290,12 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
|
||||
let essayParsed = result.subpage.essayText
|
||||
? parseEssayMarkdown(result.subpage.essayText)
|
||||
: result.subpage.essayFile
|
||||
? loadEssayFromFile(result.subpage.essayFile)
|
||||
: null;
|
||||
? loadEssayFromFile(result.subpage.essayFile)
|
||||
: null;
|
||||
|
||||
// Fetch assets from all subpage albums
|
||||
const allAlbums = await Promise.all(
|
||||
albums.map((a) => immich.getAlbumBySlug(a.slug, slug, forceFresh))
|
||||
albums.map((a) => immich.getAlbumBySlug(a.slug, slug, forceFresh)),
|
||||
);
|
||||
const allAssets = allAlbums.flatMap((a) => (a ? a.assets : []));
|
||||
const images = toPhotoItems(allAssets, config.exifOnHover);
|
||||
@@ -312,6 +320,11 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
|
||||
};
|
||||
}
|
||||
|
||||
// A published story is not an album handover, so an essay only gets the
|
||||
// proofing controls when its subpage asks for them explicitly — the
|
||||
// global default does not reach in here.
|
||||
const essayProofing = result.subpage.proofing === true;
|
||||
|
||||
return (
|
||||
<EssayView
|
||||
essay={essayParsed}
|
||||
@@ -319,6 +332,8 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
|
||||
title={result.subpage.title || result.subpage.name}
|
||||
subtitle={result.subpage.subtitle}
|
||||
watermark={config.watermark}
|
||||
proofing={essayProofing}
|
||||
allowMailto={config.proofing.allowMailto}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -358,6 +373,8 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
|
||||
backLinkHref="/"
|
||||
backLinkLabel="Back to Gallery"
|
||||
watermark={config.watermark}
|
||||
proofing={proofingFor(result.subpage)}
|
||||
allowMailto={config.proofing.allowMailto}
|
||||
{...heroData}
|
||||
/>
|
||||
);
|
||||
@@ -435,6 +452,8 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
|
||||
backLinkHref="/"
|
||||
backLinkLabel="Back to Gallery"
|
||||
watermark={config.watermark}
|
||||
proofing={proofingFor()}
|
||||
allowMailto={config.proofing.allowMailto}
|
||||
{...heroData}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -56,7 +56,9 @@ export default function AnalyticsView() {
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="admin-panel" style={{ padding: '2rem', textAlign: 'center' }}>
|
||||
<p style={{ color: 'var(--admin-text-muted)', marginBottom: '1rem' }}>{error || 'No analytics data'}</p>
|
||||
<p style={{ color: 'var(--admin-text-muted)', marginBottom: '1rem' }}>
|
||||
{error || 'No analytics data'}
|
||||
</p>
|
||||
<button className="admin-btn admin-btn-primary" onClick={fetchAnalytics}>
|
||||
<Icons.IconRefresh size={14} /> Retry
|
||||
</button>
|
||||
@@ -71,17 +73,22 @@ export default function AnalyticsView() {
|
||||
.slice(-14); // Last 14 days
|
||||
const maxDayViews = Math.max(...daysList.map(([, d]) => d.pageviews || 0), 1);
|
||||
|
||||
const desktopRatio = data.devices.desktop + data.devices.mobile > 0
|
||||
? Math.round((data.devices.desktop / (data.devices.desktop + data.devices.mobile)) * 100)
|
||||
: 50;
|
||||
const desktopRatio =
|
||||
data.devices.desktop + data.devices.mobile > 0
|
||||
? Math.round((data.devices.desktop / (data.devices.desktop + data.devices.mobile)) * 100)
|
||||
: 50;
|
||||
|
||||
return (
|
||||
<div className="analytics-view">
|
||||
{/* Header */}
|
||||
<div className="analytics-header">
|
||||
<div>
|
||||
<h2><Icons.IconSparkles size={20} /> Visitor Insights & Analytics</h2>
|
||||
<p className="analytics-subtitle">Privacy-first aggregate traffic stats. No personal data collected.</p>
|
||||
<h2>
|
||||
<Icons.IconSparkles size={20} /> Visitor Insights & Analytics
|
||||
</h2>
|
||||
<p className="analytics-subtitle">
|
||||
Privacy-first aggregate traffic stats. No personal data collected.
|
||||
</p>
|
||||
</div>
|
||||
<button className="admin-btn admin-btn-ghost admin-btn-sm" onClick={fetchAnalytics}>
|
||||
<Icons.IconRefresh size={14} /> Refresh
|
||||
@@ -89,10 +96,15 @@ export default function AnalyticsView() {
|
||||
</div>
|
||||
|
||||
{data.trackingEnabled === false && (
|
||||
<div className="backup-status-alert error" style={{ margin: 0, display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<div
|
||||
className="backup-status-alert error"
|
||||
style={{ margin: 0, display: 'flex', alignItems: 'center', gap: '0.5rem' }}
|
||||
>
|
||||
<Icons.IconLock size={16} />
|
||||
<span>
|
||||
<strong>Analytics Tracking is Currently Disabled.</strong> No new visitor views are being recorded. You can re-enable tracking anytime under <em>Settings → General</em>.
|
||||
<strong>Analytics Tracking is Currently Disabled.</strong> No new visitor views are
|
||||
being recorded. You can re-enable tracking anytime under{' '}
|
||||
<em>Settings → General</em>.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -111,7 +123,9 @@ export default function AnalyticsView() {
|
||||
</div>
|
||||
<div className="metric-card">
|
||||
<span className="metric-label">Desktop vs Mobile</span>
|
||||
<span className="metric-value">{desktopRatio}% / {100 - desktopRatio}%</span>
|
||||
<span className="metric-value">
|
||||
{desktopRatio}% / {100 - desktopRatio}%
|
||||
</span>
|
||||
<span className="metric-sub">Device distribution</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -119,17 +133,25 @@ export default function AnalyticsView() {
|
||||
{/* Daily Trends Chart */}
|
||||
<div className="analytics-panel">
|
||||
<div className="analytics-section-title">
|
||||
<h3><Icons.IconGrid size={16} /> Daily Traffic Trend (Last 14 Days)</h3>
|
||||
<h3>
|
||||
<Icons.IconGrid size={16} /> Daily Traffic Trend (Last 14 Days)
|
||||
</h3>
|
||||
</div>
|
||||
{daysList.length === 0 ? (
|
||||
<p className="analytics-empty">No pageviews recorded yet. Visit portfolio pages to start tracking!</p>
|
||||
<p className="analytics-empty">
|
||||
No pageviews recorded yet. Visit portfolio pages to start tracking!
|
||||
</p>
|
||||
) : (
|
||||
<div className="analytics-bar-chart">
|
||||
{daysList.map(([dateStr, dayData]) => {
|
||||
const heightPct = Math.round((dayData.pageviews / maxDayViews) * 100);
|
||||
const formattedDate = dateStr.slice(5); // MM-DD
|
||||
return (
|
||||
<div key={dateStr} className="bar-col" title={`${dateStr}: ${dayData.pageviews} views`}>
|
||||
<div
|
||||
key={dateStr}
|
||||
className="bar-col"
|
||||
title={`${dateStr}: ${dayData.pageviews} views`}
|
||||
>
|
||||
<div className="bar-wrapper">
|
||||
<div className="bar-fill" style={{ height: `${Math.max(heightPct, 8)}%` }}>
|
||||
<span className="bar-val">{dayData.pageviews}</span>
|
||||
@@ -146,7 +168,9 @@ export default function AnalyticsView() {
|
||||
{/* Top Pages List */}
|
||||
<div className="analytics-panel">
|
||||
<div className="analytics-section-title">
|
||||
<h3><Icons.IconTarget size={16} /> Top Visited Pages & Subpages</h3>
|
||||
<h3>
|
||||
<Icons.IconTarget size={16} /> Top Visited Pages & Subpages
|
||||
</h3>
|
||||
</div>
|
||||
{data.topPages.length === 0 ? (
|
||||
<p className="analytics-empty">No pages recorded yet.</p>
|
||||
|
||||
@@ -137,8 +137,8 @@ export default function BackupManagerModal({ isOpen, onClose, onRestoreSuccess }
|
||||
<div className="backup-confirm-content">
|
||||
<strong>Confirm Restoration</strong>
|
||||
<p>
|
||||
Are you sure you want to restore <code>{confirmFilename}</code>?
|
||||
A safety snapshot of your current state will be created automatically before reverting.
|
||||
Are you sure you want to restore <code>{confirmFilename}</code>? A safety snapshot
|
||||
of your current state will be created automatically before reverting.
|
||||
</p>
|
||||
<div className="backup-confirm-actions">
|
||||
<button
|
||||
@@ -170,12 +170,13 @@ export default function BackupManagerModal({ isOpen, onClose, onRestoreSuccess }
|
||||
<div className="backup-list">
|
||||
{visibleList.map((item) => {
|
||||
const dateObj = item.timestamp ? new Date(item.timestamp) : null;
|
||||
const formattedDate = dateObj && !isNaN(dateObj.getTime())
|
||||
? dateObj.toLocaleString('de-DE', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'medium',
|
||||
})
|
||||
: item.filename;
|
||||
const formattedDate =
|
||||
dateObj && !isNaN(dateObj.getTime())
|
||||
? dateObj.toLocaleString('de-DE', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'medium',
|
||||
})
|
||||
: item.filename;
|
||||
|
||||
return (
|
||||
<div key={item.filename} className="backup-item">
|
||||
@@ -183,7 +184,10 @@ export default function BackupManagerModal({ isOpen, onClose, onRestoreSuccess }
|
||||
<div className="backup-item-title">
|
||||
<span className="backup-filename">{formattedDate}</span>
|
||||
{item.isPreRestore && (
|
||||
<span className="backup-badge pre-restore" title="Safety snapshot taken before a restore">
|
||||
<span
|
||||
className="backup-badge pre-restore"
|
||||
title="Safety snapshot taken before a restore"
|
||||
>
|
||||
Pre-Restore Snapshot
|
||||
</span>
|
||||
)}
|
||||
@@ -208,7 +212,13 @@ export default function BackupManagerModal({ isOpen, onClose, onRestoreSuccess }
|
||||
className="backup-toggle-btn"
|
||||
onClick={() => setShowAllBackups(!showAllBackups)}
|
||||
>
|
||||
<Icons.IconChevronDown size={14} style={{ transform: showAllBackups ? 'rotate(180deg)' : 'none', transition: 'transform 0.2s ease' }} />
|
||||
<Icons.IconChevronDown
|
||||
size={14}
|
||||
style={{
|
||||
transform: showAllBackups ? 'rotate(180deg)' : 'none',
|
||||
transition: 'transform 0.2s ease',
|
||||
}}
|
||||
/>
|
||||
{showAllBackups
|
||||
? 'Ältere Einträge einklappen'
|
||||
: `Ältere Einträge anzeigen (${hiddenCount} weitere)`}
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,9 @@ import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import * as Icons from './Icons';
|
||||
import SaveBar from './SaveBar';
|
||||
// Direct import from the theme module, not from '@/lib/config': the config
|
||||
// index pulls in `fs` and cannot be bundled into a client component.
|
||||
import { DEFAULT_PRESET } from '@/lib/config/theme';
|
||||
|
||||
interface Settings {
|
||||
title?: string;
|
||||
@@ -73,7 +76,7 @@ interface Settings {
|
||||
about?: { enabled?: boolean };
|
||||
}
|
||||
|
||||
const PRESETS = ['studio', 'studio-modern', 'minimal', 'editorial', 'classic', 'noir', 'monograph'];
|
||||
const PRESETS = ['studio-modern', 'studio', 'minimal', 'editorial', 'classic', 'noir', 'monograph'];
|
||||
const LAYOUTS = ['masonry', 'uniform', 'showcase', 'filmstrip', 'editorial-flow', 'justified'];
|
||||
const PHOTO_FRAMES = ['none', 'passepartout', 'shadow'];
|
||||
const HERO_STYLES = ['split', 'fullbleed', 'minimal', 'stacked', 'typographic', 'mosaic', 'cover'];
|
||||
@@ -234,7 +237,8 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
if (typeof window !== 'undefined') {
|
||||
const mode = (document.documentElement.getAttribute('data-theme') as 'dark' | 'light') || 'dark';
|
||||
const mode =
|
||||
(document.documentElement.getAttribute('data-theme') as 'dark' | 'light') || 'dark';
|
||||
setCurrentMode(mode);
|
||||
}
|
||||
}, []);
|
||||
@@ -497,8 +501,12 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
{activeSection === 'general' && (
|
||||
<div className="settings-panel">
|
||||
<div className="settings-section-header">
|
||||
<h3><Icons.IconGear size={18} /> General Site Settings</h3>
|
||||
<p className="settings-section-sub">Configure basic site identity, language, and core feature toggles.</p>
|
||||
<h3>
|
||||
<Icons.IconGear size={18} /> General Site Settings
|
||||
</h3>
|
||||
<p className="settings-section-sub">
|
||||
Configure basic site identity, language, and core feature toggles.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="admin-field">
|
||||
@@ -534,8 +542,12 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
<div className="settings-section-divider" />
|
||||
|
||||
<div className="settings-section-header">
|
||||
<h3><Icons.IconSparkles size={18} /> Portfolio Features & Modules</h3>
|
||||
<p className="settings-section-sub">Enable or disable optional portfolio modules, privacy analytics, and map widgets.</p>
|
||||
<h3>
|
||||
<Icons.IconSparkles size={18} /> Portfolio Features & Modules
|
||||
</h3>
|
||||
<p className="settings-section-sub">
|
||||
Enable or disable optional portfolio modules, privacy analytics, and map widgets.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="admin-toggle-cards-grid">
|
||||
@@ -545,8 +557,12 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
onClick={() => update('exifOnHover', settings.exifOnHover === false)}
|
||||
>
|
||||
<div className="toggle-card-info">
|
||||
<span className="toggle-card-title"><Icons.IconCamera size={16} /> EXIF Data on Hover</span>
|
||||
<span className="toggle-card-desc">Display camera gear, lens, aperture & shutter speed on hover</span>
|
||||
<span className="toggle-card-title">
|
||||
<Icons.IconCamera size={16} /> EXIF Data on Hover
|
||||
</span>
|
||||
<span className="toggle-card-desc">
|
||||
Display camera gear, lens, aperture & shutter speed on hover
|
||||
</span>
|
||||
</div>
|
||||
<div className={`switch-toggle ${settings.exifOnHover !== false ? 'on' : ''}`}>
|
||||
<span className="switch-slider" />
|
||||
@@ -559,8 +575,12 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
onClick={() => update('map', !settings.map)}
|
||||
>
|
||||
<div className="toggle-card-info">
|
||||
<span className="toggle-card-title"><Icons.IconMap size={16} /> Interactive GPS Map</span>
|
||||
<span className="toggle-card-desc">Enable /map view showing photo locations on a world map</span>
|
||||
<span className="toggle-card-title">
|
||||
<Icons.IconMap size={16} /> Interactive GPS Map
|
||||
</span>
|
||||
<span className="toggle-card-desc">
|
||||
Enable /map view showing photo locations on a world map
|
||||
</span>
|
||||
</div>
|
||||
<div className={`switch-toggle ${settings.map === true ? 'on' : ''}`}>
|
||||
<span className="switch-slider" />
|
||||
@@ -573,8 +593,12 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
onClick={() => update('transitions', settings.transitions === false)}
|
||||
>
|
||||
<div className="toggle-card-info">
|
||||
<span className="toggle-card-title"><Icons.IconSparkles size={16} /> Smooth Page Transitions</span>
|
||||
<span className="toggle-card-desc">Enable subtle fade-in animations between page navigation</span>
|
||||
<span className="toggle-card-title">
|
||||
<Icons.IconSparkles size={16} /> Smooth Page Transitions
|
||||
</span>
|
||||
<span className="toggle-card-desc">
|
||||
Enable subtle fade-in animations between page navigation
|
||||
</span>
|
||||
</div>
|
||||
<div className={`switch-toggle ${settings.transitions !== false ? 'on' : ''}`}>
|
||||
<span className="switch-slider" />
|
||||
@@ -587,8 +611,12 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
onClick={() => update('scrollToTop', settings.scrollToTop === false)}
|
||||
>
|
||||
<div className="toggle-card-info">
|
||||
<span className="toggle-card-title"><Icons.IconArrowUp size={16} /> Scroll-to-Top Button</span>
|
||||
<span className="toggle-card-desc">Show a floating arrow that returns visitors to the top of long pages</span>
|
||||
<span className="toggle-card-title">
|
||||
<Icons.IconArrowUp size={16} /> Scroll-to-Top Button
|
||||
</span>
|
||||
<span className="toggle-card-desc">
|
||||
Show a floating arrow that returns visitors to the top of long pages
|
||||
</span>
|
||||
</div>
|
||||
<div className={`switch-toggle ${settings.scrollToTop !== false ? 'on' : ''}`}>
|
||||
<span className="switch-slider" />
|
||||
@@ -601,8 +629,12 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
onClick={() => update('analytics', settings.analytics === false)}
|
||||
>
|
||||
<div className="toggle-card-info">
|
||||
<span className="toggle-card-title"><Icons.IconBarChart size={16} /> Analytics Tracking</span>
|
||||
<span className="toggle-card-desc">Collect anonymous privacy-friendly visit statistics</span>
|
||||
<span className="toggle-card-title">
|
||||
<Icons.IconBarChart size={16} /> Analytics Tracking
|
||||
</span>
|
||||
<span className="toggle-card-desc">
|
||||
Collect anonymous privacy-friendly visit statistics
|
||||
</span>
|
||||
</div>
|
||||
<div className={`switch-toggle ${settings.analytics !== false ? 'on' : ''}`}>
|
||||
<span className="switch-slider" />
|
||||
@@ -615,10 +647,17 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
onClick={() => update('proofing.enabled', settings.proofing?.enabled === false)}
|
||||
>
|
||||
<div className="toggle-card-info">
|
||||
<span className="toggle-card-title"><Icons.IconHeart size={16} /> Client Proofing & Favorites</span>
|
||||
<span className="toggle-card-desc">Allow visitors & clients to heart, filter, and export favorite photo selections</span>
|
||||
<span className="toggle-card-title">
|
||||
<Icons.IconHeart size={16} /> Client Proofing & Favorites
|
||||
</span>
|
||||
<span className="toggle-card-desc">
|
||||
Allow visitors & clients to heart, filter, and export favorite photo
|
||||
selections
|
||||
</span>
|
||||
</div>
|
||||
<div className={`switch-toggle ${settings.proofing?.enabled !== false ? 'on' : ''}`}>
|
||||
<div
|
||||
className={`switch-toggle ${settings.proofing?.enabled !== false ? 'on' : ''}`}
|
||||
>
|
||||
<span className="switch-slider" />
|
||||
</div>
|
||||
</button>
|
||||
@@ -632,7 +671,9 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
<span className="toggle-card-title">
|
||||
<Icons.IconCamera size={16} /> About Page
|
||||
</span>
|
||||
<span className="toggle-card-desc">Show a portrait, bio, and gear section on your portfolio</span>
|
||||
<span className="toggle-card-desc">
|
||||
Show a portrait, bio, and gear section on your portfolio
|
||||
</span>
|
||||
</div>
|
||||
<div className={`switch-toggle ${settings.about?.enabled !== false ? 'on' : ''}`}>
|
||||
<span className="switch-slider" />
|
||||
@@ -655,7 +696,10 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
try {
|
||||
const res = await fetch('/api/admin/favicon', { method: 'PUT', body: form });
|
||||
const res = await fetch('/api/admin/favicon', {
|
||||
method: 'PUT',
|
||||
body: form,
|
||||
});
|
||||
const data = await res.json();
|
||||
setFaviconMessage(res.ok ? data.message : `Error: ${data.error}`);
|
||||
} catch {
|
||||
@@ -689,7 +733,9 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
{faviconUploading && <div className="admin-spinner" />}
|
||||
</div>
|
||||
{faviconMessage && (
|
||||
<p className={`save-message ${faviconMessage.startsWith('Error') ? 'error' : 'success'}`}>
|
||||
<p
|
||||
className={`save-message ${faviconMessage.startsWith('Error') ? 'error' : 'success'}`}
|
||||
>
|
||||
{faviconMessage}
|
||||
</p>
|
||||
)}
|
||||
@@ -704,8 +750,12 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
{activeSection === 'theme' && (
|
||||
<div className="settings-panel theme-settings-panel">
|
||||
<div className="settings-section-header">
|
||||
<h3><Icons.IconPalette size={18} /> Theme Presets & Color Mode</h3>
|
||||
<p className="settings-section-sub">Choose a typography preset and preview in Light or Dark mode.</p>
|
||||
<h3>
|
||||
<Icons.IconPalette size={18} /> Theme Presets & Color Mode
|
||||
</h3>
|
||||
<p className="settings-section-sub">
|
||||
Choose a typography preset and preview in Light or Dark mode.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="admin-field">
|
||||
@@ -724,7 +774,7 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
frame: 'none' as const,
|
||||
gap: 4,
|
||||
};
|
||||
const isActive = (settings.theme?.preset || 'studio') === p;
|
||||
const isActive = (settings.theme?.preset || DEFAULT_PRESET) === p;
|
||||
return (
|
||||
<button
|
||||
key={p}
|
||||
@@ -767,8 +817,17 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
|
||||
<div className="admin-field">
|
||||
<label>Color Mode</label>
|
||||
<p style={{ fontSize: '0.78rem', color: 'var(--admin-text-muted)', margin: '0.25rem 0 0.6rem', lineHeight: '1.4' }}>
|
||||
Presets like <strong>Editorial</strong>, <strong>Minimal</strong> & <strong>Classic</strong> feature warm light/cream backgrounds in Light Mode and charcoal in Dark Mode.
|
||||
<p
|
||||
style={{
|
||||
fontSize: '0.78rem',
|
||||
color: 'var(--admin-text-muted)',
|
||||
margin: '0.25rem 0 0.6rem',
|
||||
lineHeight: '1.4',
|
||||
}}
|
||||
>
|
||||
Presets like <strong>Editorial</strong>, <strong>Minimal</strong> &{' '}
|
||||
<strong>Classic</strong> feature warm light/cream backgrounds in Light Mode and
|
||||
charcoal in Dark Mode.
|
||||
</p>
|
||||
<div className="segmented-control">
|
||||
<button
|
||||
@@ -791,8 +850,12 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
<div className="settings-section-divider" />
|
||||
|
||||
<div className="settings-section-header">
|
||||
<h3><Icons.IconTarget size={18} /> Accent Color</h3>
|
||||
<p className="settings-section-sub">Pick a primary accent color for links, buttons, and highlights.</p>
|
||||
<h3>
|
||||
<Icons.IconTarget size={18} /> Accent Color
|
||||
</h3>
|
||||
<p className="settings-section-sub">
|
||||
Pick a primary accent color for links, buttons, and highlights.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="admin-field">
|
||||
@@ -807,7 +870,9 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
{ hex: '#ffffff', name: 'Monochrome White' },
|
||||
{ hex: '#000000', name: 'Obsidian Black' },
|
||||
].map((swatch) => {
|
||||
const isSelected = (settings.theme?.accent || '#e60012').toLowerCase() === swatch.hex.toLowerCase();
|
||||
const isSelected =
|
||||
(settings.theme?.accent || '#e60012').toLowerCase() ===
|
||||
swatch.hex.toLowerCase();
|
||||
return (
|
||||
<button
|
||||
key={swatch.hex}
|
||||
@@ -839,8 +904,12 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
<div className="settings-section-divider" />
|
||||
|
||||
<div className="settings-section-header">
|
||||
<h3><Icons.IconFrame size={18} /> Photo Frame & Layout</h3>
|
||||
<p className="settings-section-sub">Customize image presentation borders and hero layouts.</p>
|
||||
<h3>
|
||||
<Icons.IconFrame size={18} /> Photo Frame & Layout
|
||||
</h3>
|
||||
<p className="settings-section-sub">
|
||||
Customize image presentation borders and hero layouts.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="admin-field">
|
||||
@@ -849,7 +918,10 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
{PHOTO_FRAMES.map((f) => {
|
||||
const info = {
|
||||
none: { label: 'None', desc: 'Flush image with crisp edges' },
|
||||
passepartout: { label: 'Passepartout', desc: 'Classic gallery matting border' },
|
||||
passepartout: {
|
||||
label: 'Passepartout',
|
||||
desc: 'Classic gallery matting border',
|
||||
},
|
||||
shadow: { label: 'Shadow', desc: 'Soft floating drop shadow' },
|
||||
}[f] || { label: f, desc: '' };
|
||||
const isActive = (settings.theme?.photoFrame || 'none') === f;
|
||||
@@ -887,7 +959,10 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
stacked: { label: 'Stacked', desc: 'Title stacked directly over photo' },
|
||||
typographic: { label: 'Typographic', desc: 'Oversized magazine masthead' },
|
||||
mosaic: { label: 'Mosaic', desc: 'Dynamic photo collage layout' },
|
||||
cover: { label: 'Cover (Experimental)', desc: 'Fullscreen splash with a single Enter link' },
|
||||
cover: {
|
||||
label: 'Cover (Experimental)',
|
||||
desc: 'Fullscreen splash with a single Enter link',
|
||||
},
|
||||
}[s] || { label: s, desc: '' };
|
||||
const isActive = (settings.theme?.heroStyle || 'split') === s;
|
||||
|
||||
@@ -963,8 +1038,12 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
<div className="settings-section-divider" />
|
||||
|
||||
<div className="settings-section-header">
|
||||
<h3><Icons.IconSparkles size={18} /> Finishing Touches</h3>
|
||||
<p className="settings-section-sub">Enable optional visual effects and indicators.</p>
|
||||
<h3>
|
||||
<Icons.IconSparkles size={18} /> Finishing Touches
|
||||
</h3>
|
||||
<p className="settings-section-sub">
|
||||
Enable optional visual effects and indicators.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="admin-toggle-cards-grid">
|
||||
@@ -974,8 +1053,12 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
onClick={() => update('theme.grain', !settings.theme?.grain)}
|
||||
>
|
||||
<div className="toggle-card-info">
|
||||
<span className="toggle-card-title"><Icons.IconFilm size={16} /> Film Grain Texture</span>
|
||||
<span className="toggle-card-desc">Adds analog noise overlay across portfolio background</span>
|
||||
<span className="toggle-card-title">
|
||||
<Icons.IconFilm size={16} /> Film Grain Texture
|
||||
</span>
|
||||
<span className="toggle-card-desc">
|
||||
Adds analog noise overlay across portfolio background
|
||||
</span>
|
||||
</div>
|
||||
<div className={`switch-toggle ${settings.theme?.grain === true ? 'on' : ''}`}>
|
||||
<span className="switch-slider" />
|
||||
@@ -988,10 +1071,16 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
onClick={() => update('theme.headerDot', settings.theme?.headerDot === false)}
|
||||
>
|
||||
<div className="toggle-card-info">
|
||||
<span className="toggle-card-title"><Icons.IconTarget size={16} /> Header Accent Dot</span>
|
||||
<span className="toggle-card-desc">Displays accent dot next to active section header</span>
|
||||
<span className="toggle-card-title">
|
||||
<Icons.IconTarget size={16} /> Header Accent Dot
|
||||
</span>
|
||||
<span className="toggle-card-desc">
|
||||
Displays accent dot next to active section header
|
||||
</span>
|
||||
</div>
|
||||
<div className={`switch-toggle ${settings.theme?.headerDot !== false ? 'on' : ''}`}>
|
||||
<div
|
||||
className={`switch-toggle ${settings.theme?.headerDot !== false ? 'on' : ''}`}
|
||||
>
|
||||
<span className="switch-slider" />
|
||||
</div>
|
||||
</button>
|
||||
@@ -1002,8 +1091,12 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
{activeSection === 'grid' && (
|
||||
<div className="settings-panel">
|
||||
<div className="settings-section-header">
|
||||
<h3><Icons.IconGrid size={18} /> Grid & Layout Engine</h3>
|
||||
<p className="settings-section-sub">Configure photography gallery column structures and thumbnail aspect ratios.</p>
|
||||
<h3>
|
||||
<Icons.IconGrid size={18} /> Grid & Layout Engine
|
||||
</h3>
|
||||
<p className="settings-section-sub">
|
||||
Configure photography gallery column structures and thumbnail aspect ratios.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="admin-field">
|
||||
@@ -1011,12 +1104,27 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
<div className="preset-card-grid">
|
||||
{LAYOUTS.map((l) => {
|
||||
const info = {
|
||||
masonry: { label: 'Masonry', desc: 'Dynamic pinterest-style staggered columns' },
|
||||
masonry: {
|
||||
label: 'Masonry',
|
||||
desc: 'Dynamic pinterest-style staggered columns',
|
||||
},
|
||||
uniform: { label: 'Uniform Grid', desc: 'Clean equal aspect ratio grid' },
|
||||
showcase: { label: 'Showcase', desc: 'Featured hero photos mixed with smaller tiles' },
|
||||
filmstrip: { label: 'Filmstrip', desc: 'Horizontal scrollable film strip timeline' },
|
||||
'editorial-flow': { label: 'Editorial Flow', desc: 'Magazine story layout with varying photo sizes' },
|
||||
justified: { label: 'Justified (Experimental)', desc: 'Equal-height rows that fill the full width' },
|
||||
showcase: {
|
||||
label: 'Showcase',
|
||||
desc: 'Featured hero photos mixed with smaller tiles',
|
||||
},
|
||||
filmstrip: {
|
||||
label: 'Filmstrip',
|
||||
desc: 'Horizontal scrollable film strip timeline',
|
||||
},
|
||||
'editorial-flow': {
|
||||
label: 'Editorial Flow',
|
||||
desc: 'Magazine story layout with varying photo sizes',
|
||||
},
|
||||
justified: {
|
||||
label: 'Justified (Experimental)',
|
||||
desc: 'Equal-height rows that fill the full width',
|
||||
},
|
||||
}[l] || { label: l, desc: '' };
|
||||
const isActive = (settings.grid?.layout || 'masonry') === l;
|
||||
|
||||
@@ -1031,38 +1139,65 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
<div className={`mini-layout-demo layout-demo-${l}`}>
|
||||
{l === 'masonry' && (
|
||||
<div className="demo-masonry-col-group">
|
||||
<div className="demo-col"><div className="demo-tile h-high" /><div className="demo-tile h-low" /></div>
|
||||
<div className="demo-col"><div className="demo-tile h-low" /><div className="demo-tile h-high" /></div>
|
||||
<div className="demo-col"><div className="demo-tile h-med" /><div className="demo-tile h-med" /></div>
|
||||
<div className="demo-col">
|
||||
<div className="demo-tile h-high" />
|
||||
<div className="demo-tile h-low" />
|
||||
</div>
|
||||
<div className="demo-col">
|
||||
<div className="demo-tile h-low" />
|
||||
<div className="demo-tile h-high" />
|
||||
</div>
|
||||
<div className="demo-col">
|
||||
<div className="demo-tile h-med" />
|
||||
<div className="demo-tile h-med" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{l === 'uniform' && (
|
||||
<div className="demo-uniform-grid">
|
||||
<div className="demo-tile" /><div className="demo-tile" /><div className="demo-tile" />
|
||||
<div className="demo-tile" /><div className="demo-tile" /><div className="demo-tile" />
|
||||
<div className="demo-tile" />
|
||||
<div className="demo-tile" />
|
||||
<div className="demo-tile" />
|
||||
<div className="demo-tile" />
|
||||
<div className="demo-tile" />
|
||||
<div className="demo-tile" />
|
||||
</div>
|
||||
)}
|
||||
{l === 'showcase' && (
|
||||
<div className="demo-showcase-grid">
|
||||
<div className="demo-tile demo-hero" />
|
||||
<div className="demo-col"><div className="demo-tile" /><div className="demo-tile" /></div>
|
||||
<div className="demo-col">
|
||||
<div className="demo-tile" />
|
||||
<div className="demo-tile" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{l === 'filmstrip' && (
|
||||
<div className="demo-filmstrip-row">
|
||||
<div className="demo-tile strip" /><div className="demo-tile strip" /><div className="demo-tile strip" />
|
||||
<div className="demo-tile strip" />
|
||||
<div className="demo-tile strip" />
|
||||
<div className="demo-tile strip" />
|
||||
</div>
|
||||
)}
|
||||
{l === 'editorial-flow' && (
|
||||
<div className="demo-editorial-flow">
|
||||
<div className="demo-tile wide" />
|
||||
<div className="demo-row"><div className="demo-tile" /><div className="demo-tile" /></div>
|
||||
<div className="demo-row">
|
||||
<div className="demo-tile" />
|
||||
<div className="demo-tile" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{l === 'justified' && (
|
||||
<div className="demo-editorial-flow">
|
||||
<div className="demo-row"><div className="demo-tile wide" /><div className="demo-tile" /></div>
|
||||
<div className="demo-row"><div className="demo-tile" /><div className="demo-tile wide" /></div>
|
||||
<div className="demo-row">
|
||||
<div className="demo-tile wide" />
|
||||
<div className="demo-tile" />
|
||||
</div>
|
||||
<div className="demo-row">
|
||||
<div className="demo-tile" />
|
||||
<div className="demo-tile wide" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1086,7 +1221,10 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
'3/2': { label: 'Landscape (3:2)', desc: 'Standard 35mm DSLR landscape' },
|
||||
'2/3': { label: 'Portrait (2:3)', desc: 'Vertical portrait orientation' },
|
||||
'16/9': { label: 'Cinema (16:9)', desc: 'Widescreen 16:9 cinematic ratio' },
|
||||
auto: { label: 'Original Auto', desc: 'Uncropped original image proportions' },
|
||||
auto: {
|
||||
label: 'Original Auto',
|
||||
desc: 'Uncropped original image proportions',
|
||||
},
|
||||
}[r] || { label: r, desc: '' };
|
||||
const isActive = (settings.grid?.aspectRatio || '1') === r;
|
||||
|
||||
@@ -1115,7 +1253,9 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
<div className="settings-section-divider" />
|
||||
|
||||
<div className="settings-section-header">
|
||||
<h3><Icons.IconColumns size={18} /> Spacing & Columns</h3>
|
||||
<h3>
|
||||
<Icons.IconColumns size={18} /> Spacing & Columns
|
||||
</h3>
|
||||
<p className="settings-section-sub">Adjust column counts and grid gap spacing.</p>
|
||||
</div>
|
||||
|
||||
@@ -1147,8 +1287,12 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
{activeSection === 'footer' && (
|
||||
<div className="settings-panel">
|
||||
<div className="settings-section-header">
|
||||
<h3><Icons.IconLink size={18} /> Footer & Social Links</h3>
|
||||
<p className="settings-section-sub">Display branding, Instagram, email and website links in portfolio footer.</p>
|
||||
<h3>
|
||||
<Icons.IconLink size={18} /> Footer & Social Links
|
||||
</h3>
|
||||
<p className="settings-section-sub">
|
||||
Display branding, Instagram, email and website links in portfolio footer.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="admin-field">
|
||||
@@ -1187,8 +1331,13 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
</div>
|
||||
|
||||
<div className="settings-section-header" style={{ marginTop: '2rem' }}>
|
||||
<h3><Icons.IconLink size={18} /> Header Navigation Links (Experimental)</h3>
|
||||
<p className="settings-section-sub">External links shown after your pages in the header menu. Only http(s) URLs are allowed; they open in a new tab.</p>
|
||||
<h3>
|
||||
<Icons.IconLink size={18} /> Header Navigation Links (Experimental)
|
||||
</h3>
|
||||
<p className="settings-section-sub">
|
||||
External links shown after your pages in the header menu. Only http(s) URLs are
|
||||
allowed; they open in a new tab.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{(settings.navLinks || []).map((link, i) => (
|
||||
@@ -1234,7 +1383,9 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-sm"
|
||||
onClick={() => update('navLinks', [...(settings.navLinks || []), { label: '', url: '' }])}
|
||||
onClick={() =>
|
||||
update('navLinks', [...(settings.navLinks || []), { label: '', url: '' }])
|
||||
}
|
||||
>
|
||||
+ Add external link
|
||||
</button>
|
||||
@@ -1244,8 +1395,13 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
{activeSection === 'legal' && (
|
||||
<div className="settings-panel">
|
||||
<div className="settings-section-header">
|
||||
<h3><Icons.IconScale size={18} /> Legal Notice & Impressum</h3>
|
||||
<p className="settings-section-sub">Configure required legal disclosure page for EU / German Telemediengesetz compliance.</p>
|
||||
<h3>
|
||||
<Icons.IconScale size={18} /> Legal Notice & Impressum
|
||||
</h3>
|
||||
<p className="settings-section-sub">
|
||||
Configure required legal disclosure page for EU / German Telemediengesetz
|
||||
compliance.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="admin-toggle-cards-grid" style={{ marginBottom: '1.25rem' }}>
|
||||
@@ -1255,8 +1411,12 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
onClick={() => update('legal.enabled', !settings.legal?.enabled)}
|
||||
>
|
||||
<div className="toggle-card-info">
|
||||
<span className="toggle-card-title"><Icons.IconFileText size={16} /> Enable Impressum Page (/impressum)</span>
|
||||
<span className="toggle-card-desc">Automatically generates and links /impressum in footer</span>
|
||||
<span className="toggle-card-title">
|
||||
<Icons.IconFileText size={16} /> Enable Impressum Page (/impressum)
|
||||
</span>
|
||||
<span className="toggle-card-desc">
|
||||
Automatically generates and links /impressum in footer
|
||||
</span>
|
||||
</div>
|
||||
<div className={`switch-toggle ${settings.legal?.enabled === true ? 'on' : ''}`}>
|
||||
<span className="switch-slider" />
|
||||
@@ -1337,14 +1497,20 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
{activeSection === 'seo' && (
|
||||
<div className="settings-panel">
|
||||
<div className="settings-section-header">
|
||||
<h3><Icons.IconSearch size={18} /> Search Engine Optimization (SEO)</h3>
|
||||
<p className="settings-section-sub">Customize search engine metadata, OpenGraph tags, and indexing rules.</p>
|
||||
<h3>
|
||||
<Icons.IconSearch size={18} /> Search Engine Optimization (SEO)
|
||||
</h3>
|
||||
<p className="settings-section-sub">
|
||||
Customize search engine metadata, OpenGraph tags, and indexing rules.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Live Google Search Result Snippet Card */}
|
||||
<div className="google-snippet-preview">
|
||||
<div className="google-snippet-header">
|
||||
<span><Icons.IconGlobe size={14} /> Google Search Result Preview</span>
|
||||
<span>
|
||||
<Icons.IconGlobe size={14} /> Google Search Result Preview
|
||||
</span>
|
||||
</div>
|
||||
<div className="google-snippet-card">
|
||||
<div className="google-snippet-url">
|
||||
@@ -1354,7 +1520,9 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
{settings.seo?.title || settings.title || 'My Photography Portfolio'}
|
||||
</div>
|
||||
<div className="google-snippet-desc">
|
||||
{settings.seo?.description || settings.subtitle || 'A curated selection of photography work.'}
|
||||
{settings.seo?.description ||
|
||||
settings.subtitle ||
|
||||
'A curated selection of photography work.'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1376,7 +1544,8 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
placeholder={`%s | ${settings.seo?.title || settings.title || 'My Portfolio'}`}
|
||||
/>
|
||||
<p style={{ fontSize: '0.8rem', opacity: 0.7, marginTop: '4px' }}>
|
||||
Template for subpages & albums. Use <code>%s</code> as placeholder for the page title.
|
||||
Template for subpages & albums. Use <code>%s</code> as placeholder for the
|
||||
page title.
|
||||
<br />
|
||||
<strong>Preview:</strong>{' '}
|
||||
{(
|
||||
@@ -1399,8 +1568,12 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
<div className="settings-section-divider" />
|
||||
|
||||
<div className="settings-section-header">
|
||||
<h3><Icons.IconGlobe size={18} /> Search Crawler Directives</h3>
|
||||
<p className="settings-section-sub">Control how Googlebot and other web crawlers index your site.</p>
|
||||
<h3>
|
||||
<Icons.IconGlobe size={18} /> Search Crawler Directives
|
||||
</h3>
|
||||
<p className="settings-section-sub">
|
||||
Control how Googlebot and other web crawlers index your site.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="admin-toggle-cards-grid">
|
||||
@@ -1410,8 +1583,12 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
onClick={() => update('seo.noIndex', !settings.seo?.noIndex)}
|
||||
>
|
||||
<div className="toggle-card-info">
|
||||
<span className="toggle-card-title"><Icons.IconBan size={16} /> noindex (Hide from Google)</span>
|
||||
<span className="toggle-card-desc">Instructs search engines NOT to index this site in search results</span>
|
||||
<span className="toggle-card-title">
|
||||
<Icons.IconBan size={16} /> noindex (Hide from Google)
|
||||
</span>
|
||||
<span className="toggle-card-desc">
|
||||
Instructs search engines NOT to index this site in search results
|
||||
</span>
|
||||
</div>
|
||||
<div className={`switch-toggle ${settings.seo?.noIndex === true ? 'on' : ''}`}>
|
||||
<span className="switch-slider" />
|
||||
@@ -1424,8 +1601,12 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
onClick={() => update('seo.noFollow', !settings.seo?.noFollow)}
|
||||
>
|
||||
<div className="toggle-card-info">
|
||||
<span className="toggle-card-title"><Icons.IconLink size={16} /> nofollow (Block Link Following)</span>
|
||||
<span className="toggle-card-desc">Instructs search engine crawlers not to follow outgoing links</span>
|
||||
<span className="toggle-card-title">
|
||||
<Icons.IconLink size={16} /> nofollow (Block Link Following)
|
||||
</span>
|
||||
<span className="toggle-card-desc">
|
||||
Instructs search engine crawlers not to follow outgoing links
|
||||
</span>
|
||||
</div>
|
||||
<div className={`switch-toggle ${settings.seo?.noFollow === true ? 'on' : ''}`}>
|
||||
<span className="switch-slider" />
|
||||
@@ -1438,21 +1619,33 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
{activeSection === 'security' && (
|
||||
<div className="settings-panel">
|
||||
<div className="settings-section-header">
|
||||
<h3><Icons.IconShieldCheck size={18} /> Asset Protection & Watermark</h3>
|
||||
<p className="settings-section-sub">Configure image protection rules, right-click prevention, and watermark overlay.</p>
|
||||
<h3>
|
||||
<Icons.IconShieldCheck size={18} /> Asset Protection & Watermark
|
||||
</h3>
|
||||
<p className="settings-section-sub">
|
||||
Configure image protection rules, right-click prevention, and watermark overlay.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="admin-toggle-cards-grid">
|
||||
<button
|
||||
type="button"
|
||||
className={`admin-toggle-card ${settings.protection?.disableRightClick === true ? 'active' : ''}`}
|
||||
onClick={() => update('protection.disableRightClick', !settings.protection?.disableRightClick)}
|
||||
onClick={() =>
|
||||
update('protection.disableRightClick', !settings.protection?.disableRightClick)
|
||||
}
|
||||
>
|
||||
<div className="toggle-card-info">
|
||||
<span className="toggle-card-title"><Icons.IconLock size={16} /> Disable Right-Click Menu</span>
|
||||
<span className="toggle-card-desc">Prevents context menu on portfolio images to hinder unauthorized downloads</span>
|
||||
<span className="toggle-card-title">
|
||||
<Icons.IconLock size={16} /> Disable Right-Click Menu
|
||||
</span>
|
||||
<span className="toggle-card-desc">
|
||||
Prevents context menu on portfolio images to hinder unauthorized downloads
|
||||
</span>
|
||||
</div>
|
||||
<div className={`switch-toggle ${settings.protection?.disableRightClick === true ? 'on' : ''}`}>
|
||||
<div
|
||||
className={`switch-toggle ${settings.protection?.disableRightClick === true ? 'on' : ''}`}
|
||||
>
|
||||
<span className="switch-slider" />
|
||||
</div>
|
||||
</button>
|
||||
@@ -1460,13 +1653,21 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
<button
|
||||
type="button"
|
||||
className={`admin-toggle-card ${settings.protection?.disableImageDrag === true ? 'active' : ''}`}
|
||||
onClick={() => update('protection.disableImageDrag', !settings.protection?.disableImageDrag)}
|
||||
onClick={() =>
|
||||
update('protection.disableImageDrag', !settings.protection?.disableImageDrag)
|
||||
}
|
||||
>
|
||||
<div className="toggle-card-info">
|
||||
<span className="toggle-card-title"><Icons.IconBan size={16} /> Disable Image Dragging</span>
|
||||
<span className="toggle-card-desc">Prevents visitors from dragging images off the portfolio page</span>
|
||||
<span className="toggle-card-title">
|
||||
<Icons.IconBan size={16} /> Disable Image Dragging
|
||||
</span>
|
||||
<span className="toggle-card-desc">
|
||||
Prevents visitors from dragging images off the portfolio page
|
||||
</span>
|
||||
</div>
|
||||
<div className={`switch-toggle ${settings.protection?.disableImageDrag === true ? 'on' : ''}`}>
|
||||
<div
|
||||
className={`switch-toggle ${settings.protection?.disableImageDrag === true ? 'on' : ''}`}
|
||||
>
|
||||
<span className="switch-slider" />
|
||||
</div>
|
||||
</button>
|
||||
@@ -1475,8 +1676,12 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
<div className="settings-section-divider" />
|
||||
|
||||
<div className="settings-section-header">
|
||||
<h3><Icons.IconSparkles size={18} /> Dynamic Watermark Overlay</h3>
|
||||
<p className="settings-section-sub">Overlay copyright branding text on Lightbox images.</p>
|
||||
<h3>
|
||||
<Icons.IconSparkles size={18} /> Dynamic Watermark Overlay
|
||||
</h3>
|
||||
<p className="settings-section-sub">
|
||||
Overlay copyright branding text on Lightbox images.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="admin-toggle-cards-grid" style={{ marginBottom: '1.25rem' }}>
|
||||
@@ -1486,10 +1691,16 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
onClick={() => update('watermark.enabled', !settings.watermark?.enabled)}
|
||||
>
|
||||
<div className="toggle-card-info">
|
||||
<span className="toggle-card-title"><Icons.IconFrame size={16} /> Enable Watermark</span>
|
||||
<span className="toggle-card-desc">Overlay copyright text on portfolio image views</span>
|
||||
<span className="toggle-card-title">
|
||||
<Icons.IconFrame size={16} /> Enable Watermark
|
||||
</span>
|
||||
<span className="toggle-card-desc">
|
||||
Overlay copyright text on portfolio image views
|
||||
</span>
|
||||
</div>
|
||||
<div className={`switch-toggle ${settings.watermark?.enabled === true ? 'on' : ''}`}>
|
||||
<div
|
||||
className={`switch-toggle ${settings.watermark?.enabled === true ? 'on' : ''}`}
|
||||
>
|
||||
<span className="switch-slider" />
|
||||
</div>
|
||||
</button>
|
||||
@@ -1519,7 +1730,9 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
</select>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label>Opacity ({Math.round((settings.watermark?.opacity ?? 0.3) * 100)}%)</label>
|
||||
<label>
|
||||
Opacity ({Math.round((settings.watermark?.opacity ?? 0.3) * 100)}%)
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0.1"
|
||||
|
||||
@@ -83,7 +83,11 @@ export async function POST(req: Request) {
|
||||
}
|
||||
|
||||
// Security check: prevent directory traversal
|
||||
if (backupFilename.includes('..') || backupFilename.includes('/') || backupFilename.includes('\\')) {
|
||||
if (
|
||||
backupFilename.includes('..') ||
|
||||
backupFilename.includes('/') ||
|
||||
backupFilename.includes('\\')
|
||||
) {
|
||||
return NextResponse.json({ error: 'Invalid backup filename' }, { status: 400 });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { isAdminAuthenticated, isAdminEnabled } from '@/lib/admin/auth';
|
||||
import {
|
||||
isValidSlug,
|
||||
sanitizeSlug,
|
||||
parseJournalMarkdown,
|
||||
} from '@/lib/journal';
|
||||
import { isValidSlug, sanitizeSlug, parseJournalMarkdown } from '@/lib/journal';
|
||||
import {
|
||||
readJournalEntry,
|
||||
writeJournalEntry,
|
||||
@@ -65,9 +61,7 @@ export async function PUT(request: Request, context: RouteContext) {
|
||||
parseJournalMarkdown(rawMarkdown);
|
||||
|
||||
const targetSlug =
|
||||
typeof body.newSlug === 'string' && body.newSlug
|
||||
? sanitizeSlug(body.newSlug)
|
||||
: slug;
|
||||
typeof body.newSlug === 'string' && body.newSlug ? sanitizeSlug(body.newSlug) : slug;
|
||||
|
||||
if (!isValidSlug(targetSlug)) {
|
||||
return NextResponse.json({ error: 'Invalid target slug' }, { status: 400 });
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { isAdminAuthenticated, isAdminEnabled } from '@/lib/admin/auth';
|
||||
import {
|
||||
sanitizeSlug,
|
||||
isValidSlug,
|
||||
type JournalFrontmatter,
|
||||
} from '@/lib/journal';
|
||||
import { sanitizeSlug, isValidSlug, type JournalFrontmatter } from '@/lib/journal';
|
||||
import {
|
||||
listJournalEntries,
|
||||
writeJournalEntry,
|
||||
@@ -47,7 +43,10 @@ export async function POST(request: Request) {
|
||||
|
||||
const existing = await readJournalEntry(slug);
|
||||
if (existing) {
|
||||
return NextResponse.json({ error: 'A journal entry with this slug already exists' }, { status: 409 });
|
||||
return NextResponse.json(
|
||||
{ error: 'A journal entry with this slug already exists' },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
const frontmatter: JournalFrontmatter = {
|
||||
@@ -56,7 +55,9 @@ export async function POST(request: Request) {
|
||||
draft: true,
|
||||
...(typeof body.subtitle === 'string' && body.subtitle ? { subtitle: body.subtitle } : {}),
|
||||
...(typeof body.author === 'string' && body.author ? { author: body.author } : {}),
|
||||
...(typeof body.coverAssetId === 'string' && body.coverAssetId ? { coverAssetId: body.coverAssetId } : {}),
|
||||
...(typeof body.coverAssetId === 'string' && body.coverAssetId
|
||||
? { coverAssetId: body.coverAssetId }
|
||||
: {}),
|
||||
};
|
||||
|
||||
let initialMarkdown = body.content;
|
||||
|
||||
@@ -40,7 +40,10 @@ export async function PUT(request: Request) {
|
||||
invalidateConfigCache();
|
||||
immich.invalidateAll();
|
||||
revalidatePath('/', 'layout');
|
||||
return NextResponse.json({ success: true, message: 'Saved successfully. Backup of previous version created.' });
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Saved successfully. Backup of previous version created.',
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[Admin] Failed to write settings.yaml:', err);
|
||||
return NextResponse.json({ error: 'Failed to save settings' }, { status: 500 });
|
||||
|
||||
@@ -69,7 +69,7 @@ export async function POST(req: Request) {
|
||||
|
||||
await saveAnalytics(data);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (err) {
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Failed to record tracking' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +51,8 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
if (!isProtected(slug, type)) {
|
||||
const typeLabel = type === 'journal' ? 'Journal entry' : type === 'subpage' ? 'Subpage' : 'Album';
|
||||
const typeLabel =
|
||||
type === 'journal' ? 'Journal entry' : type === 'subpage' ? 'Subpage' : 'Album';
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `${typeLabel} is not password-protected`,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { completeInstall, isInstalled, normalizeApiBase, validateSetupToken } from '@/lib/install';
|
||||
import { invalidateConfigCache } from '@/lib/config';
|
||||
import { DEFAULT_PRESET } from '@/lib/config/theme';
|
||||
import { immich } from '@/lib/immich';
|
||||
import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit';
|
||||
|
||||
@@ -9,8 +10,8 @@ import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit
|
||||
const INSTALL_RPM = 10;
|
||||
|
||||
const THEME_PRESETS = [
|
||||
'studio',
|
||||
'studio-modern',
|
||||
'studio',
|
||||
'minimal',
|
||||
'editorial',
|
||||
'classic',
|
||||
@@ -58,7 +59,7 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Invalid Immich URL' }, { status: 400 });
|
||||
}
|
||||
|
||||
const theme = THEME_PRESETS.includes(body.theme) ? body.theme : 'studio';
|
||||
const theme = THEME_PRESETS.includes(body.theme) ? body.theme : DEFAULT_PRESET;
|
||||
|
||||
// Album selection is optional: an empty gallery is a valid install.
|
||||
const rawAlbums: unknown[] = Array.isArray(body.albums) ? (body.albums as unknown[]) : [];
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useMemo, useState } from 'react';
|
||||
// Direct import from the theme module, not from '@/lib/config': the config
|
||||
// index pulls in `fs` and cannot be bundled into a client component.
|
||||
import { DEFAULT_PRESET } from '@/lib/config/theme';
|
||||
import '../admin/admin.css';
|
||||
import './install.css';
|
||||
|
||||
@@ -20,8 +23,12 @@ interface Props {
|
||||
const STEPS = ['Connect', 'Albums', 'Site', 'Finish'] as const;
|
||||
|
||||
const THEME_OPTIONS = [
|
||||
{
|
||||
value: 'studio-modern',
|
||||
label: 'Studio Modern',
|
||||
description: 'Precision grotesque, hairline rules',
|
||||
},
|
||||
{ value: 'studio', label: 'Studio', description: 'Classic portfolio, red accent' },
|
||||
{ value: 'studio-modern', label: 'Studio Modern', description: 'Clean with soft corners' },
|
||||
{ value: 'minimal', label: 'Minimal', description: 'Bare-bones, monochrome' },
|
||||
{ value: 'editorial', label: 'Editorial', description: 'Magazine-style serif' },
|
||||
{ value: 'classic', label: 'Classic', description: 'Warm gold, traditional' },
|
||||
@@ -47,7 +54,7 @@ export function InstallWizard({ initialApiUrl, setupToken }: Props) {
|
||||
// Step 3 — site settings
|
||||
const [siteTitle, setSiteTitle] = useState('');
|
||||
const [siteSubtitle, setSiteSubtitle] = useState('');
|
||||
const [theme, setTheme] = useState('studio');
|
||||
const [theme, setTheme] = useState(DEFAULT_PRESET);
|
||||
const [adminPassword, setAdminPassword] = useState('');
|
||||
const [adminConfirm, setAdminConfirm] = useState('');
|
||||
const [passwordError, setPasswordError] = useState('');
|
||||
|
||||
@@ -19,7 +19,6 @@ import type { PhotoItem } from '@/app/[...path]/PhotoGrid';
|
||||
import type { ImmichAsset } from '@/lib/immich';
|
||||
import PasswordGate from '@/components/PasswordGate';
|
||||
import { BackLink } from '@/components/BackLink';
|
||||
import { verifyScrypt, isScryptHash } from '@/lib/password';
|
||||
import crypto from 'crypto';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
Reference in New Issue
Block a user