feat(admin): add asset picker with favorites, drag-and-drop reordering, preview links, and keyboard shortcuts
- Asset Picker: Visual browser for hero images with Favorites tab (shown first) and All Photos tab. Includes infinite scroll and UUID fallback input for assets not easily found in the grid. - Drag & Drop: All lists (hero images, standalone albums, subpage albums, subpage tiles) are now reorderable via drag handles using @dnd-kit. - Preview Links: 'Preview Site' button in save bar, '↗ Site' in header, and per-subpage preview links with URL slug preview shown inline. - Keyboard Shortcuts: ⌘+S / Ctrl+S to save in both Pages and Settings tabs. - Unsaved Changes Guard: beforeunload warning prevents accidental data loss. - New API route /api/admin/assets for browsing Immich assets with pagination and favorites filter via search/metadata endpoint.
This commit is contained in:
@@ -1120,6 +1120,245 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Asset Picker (Hero) ───────────────────────────────────── */
|
||||||
|
|
||||||
|
.asset-picker-modal {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 720px;
|
||||||
|
max-height: 85vh;
|
||||||
|
background: var(--admin-bg-raised);
|
||||||
|
border: 1px solid var(--admin-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-picker-tabs {
|
||||||
|
display: flex;
|
||||||
|
border-bottom: 1px solid var(--admin-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-picker-tab {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.7rem 1rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--admin-text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-picker-tab:hover {
|
||||||
|
color: var(--admin-text-secondary);
|
||||||
|
background: var(--admin-bg-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-picker-tab.active {
|
||||||
|
color: var(--admin-text);
|
||||||
|
border-bottom-color: var(--admin-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-picker-grid {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.75rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-picker-loading {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-picker-loading-more {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-picker-tile {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--admin-bg-elevated);
|
||||||
|
border: 2px solid transparent;
|
||||||
|
transition: border-color 0.15s, transform 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-picker-tile:hover:not(.used) {
|
||||||
|
border-color: var(--admin-accent);
|
||||||
|
transform: scale(1.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-picker-tile.used {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-picker-tile img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-picker-used-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
right: 4px;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--admin-accent);
|
||||||
|
color: #000;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-picker-fav-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
left: 4px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-picker-uuid-section {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-top: 1px solid var(--admin-border);
|
||||||
|
background: var(--admin-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-picker-uuid-form {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-picker-uuid-input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: var(--admin-bg-input);
|
||||||
|
border: 1px solid var(--admin-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--admin-text);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-picker-uuid-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--admin-border-focus);
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-picker-uuid-error {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--admin-error);
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Drag Handles ──────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.hero-tile-drag,
|
||||||
|
.album-tile-drag,
|
||||||
|
.subpage-tile-drag {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 5;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: grab;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
user-select: none;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-tile-drag:active,
|
||||||
|
.album-tile-drag:active,
|
||||||
|
.subpage-tile-drag:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-tile-drag {
|
||||||
|
top: 6px;
|
||||||
|
left: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-tile-drag {
|
||||||
|
top: 6px;
|
||||||
|
left: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subpage-tile-drag {
|
||||||
|
top: 6px;
|
||||||
|
left: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-tile:hover .hero-tile-drag,
|
||||||
|
.album-tile:hover .album-tile-drag,
|
||||||
|
.subpage-tile:hover .subpage-tile-drag {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Preview & Slug ───────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.subpage-slug-preview {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--admin-text-muted);
|
||||||
|
font-family: monospace;
|
||||||
|
padding: 0.15rem 0.4rem;
|
||||||
|
background: var(--admin-bg);
|
||||||
|
border-radius: 3px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-bar-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-btn-preview {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0.4rem 0.8rem;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-hint {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--admin-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Danger icon button ───────────────────────────────────────── */
|
||||||
|
|
||||||
|
.admin-btn-icon-danger:hover {
|
||||||
|
color: var(--admin-error) !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Responsive ──────────────────────────────────────────────── */
|
/* ── Responsive ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
|||||||
@@ -49,6 +49,15 @@ export default function AdminDashboard({ onLogout }: Props) {
|
|||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-header-right">
|
<div className="admin-header-right">
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="admin-btn admin-btn-ghost"
|
||||||
|
title="Open site in new tab"
|
||||||
|
>
|
||||||
|
↗ Site
|
||||||
|
</a>
|
||||||
<button
|
<button
|
||||||
className="admin-btn admin-btn-ghost"
|
className="admin-btn admin-btn-ghost"
|
||||||
onClick={handleReload}
|
onClick={handleReload}
|
||||||
|
|||||||
192
app/admin/components/AssetPicker.tsx
Normal file
192
app/admin/components/AssetPicker.tsx
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
|
||||||
|
interface AssetInfo {
|
||||||
|
id: string;
|
||||||
|
originalFileName: string;
|
||||||
|
fileCreatedAt: string;
|
||||||
|
isFavorite: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onSelect: (assetId: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
currentAssetIds?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type Tab = 'favorites' | 'all';
|
||||||
|
|
||||||
|
export default function AssetPicker({ onSelect, onClose, currentAssetIds = [] }: Props) {
|
||||||
|
const [tab, setTab] = useState<Tab>('favorites');
|
||||||
|
const [assets, setAssets] = useState<AssetInfo[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [hasMore, setHasMore] = useState(false);
|
||||||
|
const [uuidInput, setUuidInput] = useState('');
|
||||||
|
const [uuidError, setUuidError] = useState('');
|
||||||
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
|
const currentIds = new Set(currentAssetIds);
|
||||||
|
|
||||||
|
const loadAssets = useCallback(
|
||||||
|
async (pageNum: number, append: boolean = false) => {
|
||||||
|
if (pageNum === 1) setLoading(true);
|
||||||
|
else setLoadingMore(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
page: pageNum.toString(),
|
||||||
|
favorites: tab === 'favorites' ? 'true' : 'false',
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await fetch(`/api/admin/assets?${params}`);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setAssets((prev) => (append ? [...prev, ...data.assets] : data.assets));
|
||||||
|
setHasMore(data.nextPage !== null);
|
||||||
|
setPage(pageNum);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load assets:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setLoadingMore(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[tab],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setAssets([]);
|
||||||
|
setPage(1);
|
||||||
|
setHasMore(false);
|
||||||
|
loadAssets(1);
|
||||||
|
}, [tab, loadAssets]);
|
||||||
|
|
||||||
|
// Infinite scroll
|
||||||
|
function handleScroll() {
|
||||||
|
if (!listRef.current || loadingMore || !hasMore) return;
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = listRef.current;
|
||||||
|
if (scrollHeight - scrollTop - clientHeight < 200) {
|
||||||
|
loadAssets(page + 1, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUuidSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
const uuid = uuidInput.trim();
|
||||||
|
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
|
if (!uuidRegex.test(uuid)) {
|
||||||
|
setUuidError('Invalid UUID format');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setUuidError('');
|
||||||
|
onSelect(uuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr: string): string {
|
||||||
|
try {
|
||||||
|
return new Date(dateStr).toLocaleDateString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="picker-overlay" onClick={onClose}>
|
||||||
|
<div className="asset-picker-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="picker-header">
|
||||||
|
<h3>Select Hero Image</h3>
|
||||||
|
<button className="admin-btn-icon" onClick={onClose}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="asset-picker-tabs">
|
||||||
|
<button
|
||||||
|
className={`asset-picker-tab ${tab === 'favorites' ? 'active' : ''}`}
|
||||||
|
onClick={() => setTab('favorites')}
|
||||||
|
>
|
||||||
|
⭐ Favorites
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`asset-picker-tab ${tab === 'all' ? 'active' : ''}`}
|
||||||
|
onClick={() => setTab('all')}
|
||||||
|
>
|
||||||
|
🖼️ All Photos
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Asset Grid */}
|
||||||
|
<div className="asset-picker-grid" ref={listRef} onScroll={handleScroll}>
|
||||||
|
{loading ? (
|
||||||
|
<div className="asset-picker-loading">
|
||||||
|
<div className="admin-spinner" />
|
||||||
|
</div>
|
||||||
|
) : assets.length === 0 ? (
|
||||||
|
<p className="empty-hint">
|
||||||
|
{tab === 'favorites'
|
||||||
|
? 'No favorite photos found. Star photos in Immich first, or switch to "All Photos".'
|
||||||
|
: 'No photos found.'}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{assets.map((asset) => {
|
||||||
|
const isUsed = currentIds.has(asset.id);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={asset.id}
|
||||||
|
className={`asset-picker-tile ${isUsed ? 'used' : ''}`}
|
||||||
|
onClick={() => !isUsed && onSelect(asset.id)}
|
||||||
|
title={`${asset.originalFileName}\n${formatDate(asset.fileCreatedAt)}`}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={`/api/admin/thumbnail/${asset.id}`}
|
||||||
|
alt={asset.originalFileName}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
{isUsed && <div className="asset-picker-used-badge">✓</div>}
|
||||||
|
{asset.isFavorite && !isUsed && (
|
||||||
|
<div className="asset-picker-fav-badge">⭐</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{loadingMore && (
|
||||||
|
<div className="asset-picker-loading-more">
|
||||||
|
<div className="admin-spinner" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* UUID Fallback */}
|
||||||
|
<div className="asset-picker-uuid-section">
|
||||||
|
<form onSubmit={handleUuidSubmit} className="asset-picker-uuid-form">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={uuidInput}
|
||||||
|
onChange={(e) => {
|
||||||
|
setUuidInput(e.target.value);
|
||||||
|
setUuidError('');
|
||||||
|
}}
|
||||||
|
placeholder="Or paste asset UUID directly..."
|
||||||
|
className="asset-picker-uuid-input"
|
||||||
|
/>
|
||||||
|
<button type="submit" className="admin-btn admin-btn-sm" disabled={!uuidInput.trim()}>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{uuidError && <span className="asset-picker-uuid-error">{uuidError}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,26 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
closestCenter,
|
||||||
|
KeyboardSensor,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
DragEndEvent,
|
||||||
|
} from '@dnd-kit/core';
|
||||||
|
import {
|
||||||
|
arrayMove,
|
||||||
|
SortableContext,
|
||||||
|
sortableKeyboardCoordinates,
|
||||||
|
useSortable,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
horizontalListSortingStrategy,
|
||||||
|
} from '@dnd-kit/sortable';
|
||||||
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
import AlbumPicker from './AlbumPicker';
|
import AlbumPicker from './AlbumPicker';
|
||||||
|
import AssetPicker from './AssetPicker';
|
||||||
|
|
||||||
// ── Types ──────────────────────────────────────────────────────
|
// ── Types ──────────────────────────────────────────────────────
|
||||||
interface AlbumEntry {
|
interface AlbumEntry {
|
||||||
@@ -79,6 +98,145 @@ function serializeAlbumEntries(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Sortable Hero Tile ─────────────────────────────────────────
|
||||||
|
|
||||||
|
function SortableHeroTile({
|
||||||
|
id,
|
||||||
|
index,
|
||||||
|
onRemove,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
index: number;
|
||||||
|
onRemove: () => void;
|
||||||
|
}) {
|
||||||
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
|
id: `hero-${index}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
opacity: isDragging ? 0.5 : 1,
|
||||||
|
zIndex: isDragging ? 10 : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={setNodeRef} style={style} className="hero-tile" {...attributes}>
|
||||||
|
<div className="hero-tile-drag" {...listeners} title="Drag to reorder">
|
||||||
|
⠿
|
||||||
|
</div>
|
||||||
|
<img src={`/api/admin/thumbnail/${id}`} alt="" loading="lazy" />
|
||||||
|
<button className="hero-tile-remove" onClick={onRemove} title="Remove">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
<span className="hero-tile-index">{index + 1}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sortable Album Card ────────────────────────────────────────
|
||||||
|
|
||||||
|
function SortableAlbumCard({
|
||||||
|
album,
|
||||||
|
index,
|
||||||
|
name,
|
||||||
|
count,
|
||||||
|
thumbnailId,
|
||||||
|
onRemove,
|
||||||
|
onUpdate,
|
||||||
|
}: {
|
||||||
|
album: AlbumEntry;
|
||||||
|
index: number;
|
||||||
|
name: string;
|
||||||
|
count: number;
|
||||||
|
thumbnailId: string | null;
|
||||||
|
onRemove: () => void;
|
||||||
|
onUpdate: (updates: Partial<AlbumEntry>) => void;
|
||||||
|
}) {
|
||||||
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
|
id: `album-${album.id}-${index}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
opacity: isDragging ? 0.5 : 1,
|
||||||
|
zIndex: isDragging ? 10 : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={setNodeRef} style={style} {...attributes}>
|
||||||
|
<AlbumCard
|
||||||
|
album={album}
|
||||||
|
name={name}
|
||||||
|
count={count}
|
||||||
|
thumbnailId={thumbnailId}
|
||||||
|
onRemove={onRemove}
|
||||||
|
onUpdate={onUpdate}
|
||||||
|
dragListeners={listeners}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sortable Subpage Tile ──────────────────────────────────────
|
||||||
|
|
||||||
|
function SortableSubpageTile({
|
||||||
|
sp,
|
||||||
|
spIndex,
|
||||||
|
isActive,
|
||||||
|
onClick,
|
||||||
|
getFirstThumb,
|
||||||
|
}: {
|
||||||
|
sp: Subpage;
|
||||||
|
spIndex: number;
|
||||||
|
isActive: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
getFirstThumb: (sp: Subpage) => string | null;
|
||||||
|
}) {
|
||||||
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
|
id: `subpage-${spIndex}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
opacity: isDragging ? 0.5 : 1,
|
||||||
|
zIndex: isDragging ? 10 : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalAlbums =
|
||||||
|
sp.albums.length + (sp.sections?.reduce((sum, sec) => sum + sec.albums.length, 0) || 0);
|
||||||
|
const firstThumb = getFirstThumb(sp);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className={`subpage-tile ${isActive ? 'active' : ''}`}
|
||||||
|
onClick={onClick}
|
||||||
|
{...attributes}
|
||||||
|
>
|
||||||
|
<div className="subpage-tile-drag" {...listeners} title="Drag to reorder">
|
||||||
|
⠿
|
||||||
|
</div>
|
||||||
|
<div className="subpage-tile-cover">
|
||||||
|
{firstThumb ? (
|
||||||
|
<img src={`/api/admin/thumbnail/${firstThumb}`} alt="" loading="lazy" />
|
||||||
|
) : (
|
||||||
|
<div className="subpage-tile-placeholder">📂</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="subpage-tile-info">
|
||||||
|
<span className="subpage-tile-name">{sp.title || sp.name}</span>
|
||||||
|
<span className="subpage-tile-meta">
|
||||||
|
{totalAlbums} album{totalAlbums !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Component ──────────────────────────────────────────────────
|
// ── Component ──────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function PageBuilder() {
|
export default function PageBuilder() {
|
||||||
@@ -94,6 +252,13 @@ export default function PageBuilder() {
|
|||||||
subpageIndex?: number;
|
subpageIndex?: number;
|
||||||
sectionIndex?: number;
|
sectionIndex?: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
const [showHeroPicker, setShowHeroPicker] = useState(false);
|
||||||
|
|
||||||
|
// DnD sensors
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||||
|
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||||
|
);
|
||||||
|
|
||||||
// Load data
|
// Load data
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -101,6 +266,33 @@ export default function PageBuilder() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// ── Keyboard shortcut: ⌘+S / Ctrl+S ─────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (dirty && !saving) {
|
||||||
|
handleSave();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [dirty, saving, gallery]);
|
||||||
|
|
||||||
|
// ── Unsaved changes guard ────────────────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
function handleBeforeUnload(e: BeforeUnloadEvent) {
|
||||||
|
if (dirty) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.returnValue = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||||
|
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||||
|
}, [dirty]);
|
||||||
|
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -181,7 +373,6 @@ export default function PageBuilder() {
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
setSaveMessage('');
|
setSaveMessage('');
|
||||||
|
|
||||||
// Serialize back to YAML-compatible format
|
|
||||||
const yamlData: Record<string, unknown> = {};
|
const yamlData: Record<string, unknown> = {};
|
||||||
|
|
||||||
if (gallery.hero.length > 0) {
|
if (gallery.hero.length > 0) {
|
||||||
@@ -207,7 +398,6 @@ export default function PageBuilder() {
|
|||||||
if (sec.description) s.description = sec.description;
|
if (sec.description) s.description = sec.description;
|
||||||
return s;
|
return s;
|
||||||
});
|
});
|
||||||
// Also include top-level albums if they exist alongside sections
|
|
||||||
if (sp.albums.length > 0) {
|
if (sp.albums.length > 0) {
|
||||||
entry.albums = serializeAlbumEntries(sp.albums);
|
entry.albums = serializeAlbumEntries(sp.albums);
|
||||||
}
|
}
|
||||||
@@ -242,6 +432,13 @@ export default function PageBuilder() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Hero Picker ──────────────────────────────────────────────
|
||||||
|
function handleHeroSelect(assetId: string) {
|
||||||
|
setGallery((g) => ({ ...g, hero: [...g.hero, assetId] }));
|
||||||
|
setShowHeroPicker(false);
|
||||||
|
markDirty();
|
||||||
|
}
|
||||||
|
|
||||||
// ── Album Picker Handlers ────────────────────────────────────
|
// ── Album Picker Handlers ────────────────────────────────────
|
||||||
function handlePickAlbum(albumId: string) {
|
function handlePickAlbum(albumId: string) {
|
||||||
if (!pickerTarget) return;
|
if (!pickerTarget) return;
|
||||||
@@ -280,6 +477,75 @@ export default function PageBuilder() {
|
|||||||
markDirty();
|
markDirty();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Drag & Drop Handlers ─────────────────────────────────────
|
||||||
|
function handleHeroDragEnd(event: DragEndEvent) {
|
||||||
|
const { active, over } = event;
|
||||||
|
if (!over || active.id === over.id) return;
|
||||||
|
|
||||||
|
const oldIndex = gallery.hero.findIndex((_, i) => `hero-${i}` === active.id);
|
||||||
|
const newIndex = gallery.hero.findIndex((_, i) => `hero-${i}` === over.id);
|
||||||
|
|
||||||
|
if (oldIndex !== -1 && newIndex !== -1) {
|
||||||
|
setGallery((g) => ({ ...g, hero: arrayMove(g.hero, oldIndex, newIndex) }));
|
||||||
|
markDirty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAlbumDragEnd(event: DragEndEvent) {
|
||||||
|
const { active, over } = event;
|
||||||
|
if (!over || active.id === over.id) return;
|
||||||
|
|
||||||
|
const oldIndex = gallery.albums.findIndex((a, i) => `album-${a.id}-${i}` === active.id);
|
||||||
|
const newIndex = gallery.albums.findIndex((a, i) => `album-${a.id}-${i}` === over.id);
|
||||||
|
|
||||||
|
if (oldIndex !== -1 && newIndex !== -1) {
|
||||||
|
setGallery((g) => ({ ...g, albums: arrayMove(g.albums, oldIndex, newIndex) }));
|
||||||
|
markDirty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubpageDragEnd(event: DragEndEvent) {
|
||||||
|
const { active, over } = event;
|
||||||
|
if (!over || active.id === over.id) return;
|
||||||
|
|
||||||
|
const oldIndex = gallery.subpages.findIndex((_, i) => `subpage-${i}` === active.id);
|
||||||
|
const newIndex = gallery.subpages.findIndex((_, i) => `subpage-${i}` === over.id);
|
||||||
|
|
||||||
|
if (oldIndex !== -1 && newIndex !== -1) {
|
||||||
|
setGallery((g) => ({ ...g, subpages: arrayMove(g.subpages, oldIndex, newIndex) }));
|
||||||
|
if (expandedSubpage === oldIndex) setExpandedSubpage(newIndex);
|
||||||
|
else if (expandedSubpage !== null) {
|
||||||
|
if (oldIndex < expandedSubpage && newIndex >= expandedSubpage)
|
||||||
|
setExpandedSubpage(expandedSubpage - 1);
|
||||||
|
else if (oldIndex > expandedSubpage && newIndex <= expandedSubpage)
|
||||||
|
setExpandedSubpage(expandedSubpage + 1);
|
||||||
|
}
|
||||||
|
markDirty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubpageAlbumDragEnd(spIndex: number) {
|
||||||
|
return (event: DragEndEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
if (!over || active.id === over.id) return;
|
||||||
|
|
||||||
|
const sp = gallery.subpages[spIndex];
|
||||||
|
const oldIndex = sp.albums.findIndex((a, i) => `album-${a.id}-${i}` === active.id);
|
||||||
|
const newIndex = sp.albums.findIndex((a, i) => `album-${a.id}-${i}` === over.id);
|
||||||
|
|
||||||
|
if (oldIndex !== -1 && newIndex !== -1) {
|
||||||
|
setGallery((g) => {
|
||||||
|
const subpages = [...g.subpages];
|
||||||
|
const sp2 = { ...subpages[spIndex] };
|
||||||
|
sp2.albums = arrayMove(sp2.albums, oldIndex, newIndex);
|
||||||
|
subpages[spIndex] = sp2;
|
||||||
|
return { ...g, subpages };
|
||||||
|
});
|
||||||
|
markDirty();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ── Subpage Management ────────────────────────────────────────
|
// ── Subpage Management ────────────────────────────────────────
|
||||||
function addSubpage() {
|
function addSubpage() {
|
||||||
setGallery((g) => ({
|
setGallery((g) => ({
|
||||||
@@ -390,26 +656,6 @@ export default function PageBuilder() {
|
|||||||
markDirty();
|
markDirty();
|
||||||
}
|
}
|
||||||
|
|
||||||
function addHero() {
|
|
||||||
const uuid = prompt('Enter Immich asset UUID for hero image:');
|
|
||||||
if (uuid?.trim()) {
|
|
||||||
setGallery((g) => ({ ...g, hero: [...g.hero, uuid.trim()] }));
|
|
||||||
markDirty();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Move helpers ──────────────────────────────────────────────
|
|
||||||
function moveSubpage(index: number, direction: -1 | 1) {
|
|
||||||
const newIndex = index + direction;
|
|
||||||
if (newIndex < 0 || newIndex >= gallery.subpages.length) return;
|
|
||||||
setGallery((g) => {
|
|
||||||
const subpages = [...g.subpages];
|
|
||||||
[subpages[index], subpages[newIndex]] = [subpages[newIndex], subpages[index]];
|
|
||||||
return { ...g, subpages };
|
|
||||||
});
|
|
||||||
markDirty();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────
|
||||||
function getAlbumName(id: string): string {
|
function getAlbumName(id: string): string {
|
||||||
const found = immichAlbums.find((a) => a.id === id);
|
const found = immichAlbums.find((a) => a.id === id);
|
||||||
@@ -427,12 +673,10 @@ export default function PageBuilder() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getFirstSubpageThumb(sp: Subpage): string | null {
|
function getFirstSubpageThumb(sp: Subpage): string | null {
|
||||||
// Try albums first
|
|
||||||
for (const album of sp.albums) {
|
for (const album of sp.albums) {
|
||||||
const thumb = getAlbumThumbnailId(album.id);
|
const thumb = getAlbumThumbnailId(album.id);
|
||||||
if (thumb) return thumb;
|
if (thumb) return thumb;
|
||||||
}
|
}
|
||||||
// Try sections
|
|
||||||
if (sp.sections) {
|
if (sp.sections) {
|
||||||
for (const sec of sp.sections) {
|
for (const sec of sp.sections) {
|
||||||
for (const album of sec.albums) {
|
for (const album of sec.albums) {
|
||||||
@@ -444,6 +688,13 @@ export default function PageBuilder() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function slugify(str: string): string {
|
||||||
|
return str
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
// ── Render ───────────────────────────────────────────────────
|
// ── Render ───────────────────────────────────────────────────
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -466,48 +717,62 @@ export default function PageBuilder() {
|
|||||||
{saveMessage}
|
{saveMessage}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{!dirty && !saveMessage && <span className="save-hint">⌘S to save</span>}
|
||||||
|
</div>
|
||||||
|
<div className="save-bar-right">
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="admin-btn admin-btn-ghost admin-btn-preview"
|
||||||
|
title="Open site in new tab"
|
||||||
|
>
|
||||||
|
↗ Preview Site
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
className="admin-btn admin-btn-primary"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!dirty || saving}
|
||||||
|
>
|
||||||
|
{saving ? 'Saving...' : 'Save Changes'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
className="admin-btn admin-btn-primary"
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={!dirty || saving}
|
|
||||||
>
|
|
||||||
{saving ? 'Saving...' : 'Save Changes'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
<section className="builder-section">
|
<section className="builder-section">
|
||||||
<div className="builder-section-header">
|
<div className="builder-section-header">
|
||||||
<h2>🏠 Homepage Hero</h2>
|
<h2>🏠 Homepage Hero</h2>
|
||||||
<button className="admin-btn admin-btn-sm" onClick={addHero}>
|
<button className="admin-btn admin-btn-sm" onClick={() => setShowHeroPicker(true)}>
|
||||||
+ Add Hero
|
+ Add Hero
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="hero-grid">
|
<DndContext
|
||||||
{gallery.hero.length === 0 && (
|
sensors={sensors}
|
||||||
<p className="empty-hint">
|
collisionDetection={closestCenter}
|
||||||
No hero images configured. Add asset UUIDs to show a hero carousel.
|
onDragEnd={handleHeroDragEnd}
|
||||||
</p>
|
>
|
||||||
)}
|
<SortableContext
|
||||||
{gallery.hero.map((id, i) => (
|
items={gallery.hero.map((_, i) => `hero-${i}`)}
|
||||||
<div key={i} className="hero-tile">
|
strategy={horizontalListSortingStrategy}
|
||||||
<img
|
>
|
||||||
src={`/api/admin/thumbnail/${id}`}
|
<div className="hero-grid">
|
||||||
alt=""
|
{gallery.hero.length === 0 && (
|
||||||
loading="lazy"
|
<p className="empty-hint">
|
||||||
/>
|
No hero images configured. Add photos to show a hero carousel on the homepage.
|
||||||
<button
|
</p>
|
||||||
className="hero-tile-remove"
|
)}
|
||||||
onClick={() => removeHero(i)}
|
{gallery.hero.map((id, i) => (
|
||||||
title="Remove"
|
<SortableHeroTile
|
||||||
>
|
key={`hero-${i}`}
|
||||||
×
|
id={id}
|
||||||
</button>
|
index={i}
|
||||||
<span className="hero-tile-index">{i + 1}</span>
|
onRemove={() => removeHero(i)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
</SortableContext>
|
||||||
</div>
|
</DndContext>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Standalone Albums */}
|
{/* Standalone Albums */}
|
||||||
@@ -521,29 +786,43 @@ export default function PageBuilder() {
|
|||||||
+ Add Album
|
+ Add Album
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="album-list">
|
<DndContext
|
||||||
{gallery.albums.length === 0 && (
|
sensors={sensors}
|
||||||
<p className="empty-hint">No standalone albums. These show directly on the homepage.</p>
|
collisionDetection={closestCenter}
|
||||||
)}
|
onDragEnd={handleAlbumDragEnd}
|
||||||
{gallery.albums.map((album, i) => (
|
>
|
||||||
<AlbumCard
|
<SortableContext
|
||||||
key={`${album.id}-${i}`}
|
items={gallery.albums.map((a, i) => `album-${a.id}-${i}`)}
|
||||||
album={album}
|
strategy={verticalListSortingStrategy}
|
||||||
name={getAlbumName(album.id)}
|
>
|
||||||
count={getAlbumCount(album.id)}
|
<div className="album-list">
|
||||||
thumbnailId={getAlbumThumbnailId(album.id)}
|
{gallery.albums.length === 0 && (
|
||||||
onRemove={() => removeStandaloneAlbum(i)}
|
<p className="empty-hint">
|
||||||
onUpdate={(updates) => {
|
No standalone albums. These show directly on the homepage.
|
||||||
setGallery((g) => {
|
</p>
|
||||||
const albums = [...g.albums];
|
)}
|
||||||
albums[i] = { ...albums[i], ...updates };
|
{gallery.albums.map((album, i) => (
|
||||||
return { ...g, albums };
|
<SortableAlbumCard
|
||||||
});
|
key={`${album.id}-${i}`}
|
||||||
markDirty();
|
album={album}
|
||||||
}}
|
index={i}
|
||||||
/>
|
name={getAlbumName(album.id)}
|
||||||
))}
|
count={getAlbumCount(album.id)}
|
||||||
</div>
|
thumbnailId={getAlbumThumbnailId(album.id)}
|
||||||
|
onRemove={() => removeStandaloneAlbum(i)}
|
||||||
|
onUpdate={(updates) => {
|
||||||
|
setGallery((g) => {
|
||||||
|
const albums = [...g.albums];
|
||||||
|
albums[i] = { ...albums[i], ...updates };
|
||||||
|
return { ...g, albums };
|
||||||
|
});
|
||||||
|
markDirty();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Subpages */}
|
{/* Subpages */}
|
||||||
@@ -561,38 +840,30 @@ export default function PageBuilder() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Collapsed overview grid */}
|
{/* Collapsed overview grid with DnD */}
|
||||||
<div className="subpage-grid">
|
<DndContext
|
||||||
{gallery.subpages.map((sp, spIndex) => {
|
sensors={sensors}
|
||||||
const totalAlbums =
|
collisionDetection={closestCenter}
|
||||||
sp.albums.length +
|
onDragEnd={handleSubpageDragEnd}
|
||||||
(sp.sections?.reduce((sum, sec) => sum + sec.albums.length, 0) || 0);
|
>
|
||||||
const firstThumb = getFirstSubpageThumb(sp);
|
<SortableContext
|
||||||
return (
|
items={gallery.subpages.map((_, i) => `subpage-${i}`)}
|
||||||
<div
|
strategy={horizontalListSortingStrategy}
|
||||||
key={spIndex}
|
>
|
||||||
className={`subpage-tile ${expandedSubpage === spIndex ? 'active' : ''}`}
|
<div className="subpage-grid">
|
||||||
onClick={() =>
|
{gallery.subpages.map((sp, spIndex) => (
|
||||||
setExpandedSubpage(expandedSubpage === spIndex ? null : spIndex)
|
<SortableSubpageTile
|
||||||
}
|
key={`subpage-${spIndex}`}
|
||||||
>
|
sp={sp}
|
||||||
<div className="subpage-tile-cover">
|
spIndex={spIndex}
|
||||||
{firstThumb ? (
|
isActive={expandedSubpage === spIndex}
|
||||||
<img src={`/api/admin/thumbnail/${firstThumb}`} alt="" loading="lazy" />
|
onClick={() => setExpandedSubpage(expandedSubpage === spIndex ? null : spIndex)}
|
||||||
) : (
|
getFirstThumb={getFirstSubpageThumb}
|
||||||
<div className="subpage-tile-placeholder">📂</div>
|
/>
|
||||||
)}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="subpage-tile-info">
|
</SortableContext>
|
||||||
<span className="subpage-tile-name">{sp.title || sp.name}</span>
|
</DndContext>
|
||||||
<span className="subpage-tile-meta">
|
|
||||||
{totalAlbums} album{totalAlbums !== 1 ? 's' : ''}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Expanded subpage detail */}
|
{/* Expanded subpage detail */}
|
||||||
{expandedSubpage !== null && gallery.subpages[expandedSubpage] && (
|
{expandedSubpage !== null && gallery.subpages[expandedSubpage] && (
|
||||||
@@ -604,30 +875,6 @@ export default function PageBuilder() {
|
|||||||
<>
|
<>
|
||||||
<div className="subpage-detail-header">
|
<div className="subpage-detail-header">
|
||||||
<div className="subpage-detail-header-left">
|
<div className="subpage-detail-header-left">
|
||||||
<div className="subpage-move-btns">
|
|
||||||
<button
|
|
||||||
className="admin-btn-icon"
|
|
||||||
onClick={() => {
|
|
||||||
moveSubpage(spIndex, -1);
|
|
||||||
setExpandedSubpage(spIndex - 1);
|
|
||||||
}}
|
|
||||||
disabled={spIndex === 0}
|
|
||||||
title="Move up"
|
|
||||||
>
|
|
||||||
←
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="admin-btn-icon"
|
|
||||||
onClick={() => {
|
|
||||||
moveSubpage(spIndex, 1);
|
|
||||||
setExpandedSubpage(spIndex + 1);
|
|
||||||
}}
|
|
||||||
disabled={spIndex === gallery.subpages.length - 1}
|
|
||||||
title="Move down"
|
|
||||||
>
|
|
||||||
→
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<input
|
<input
|
||||||
className="subpage-name-input"
|
className="subpage-name-input"
|
||||||
value={sp.name}
|
value={sp.name}
|
||||||
@@ -635,8 +882,19 @@ export default function PageBuilder() {
|
|||||||
placeholder="Page name (used for URL)"
|
placeholder="Page name (used for URL)"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
/>
|
/>
|
||||||
|
<span className="subpage-slug-preview">/{slugify(sp.name)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="subpage-detail-header-right">
|
<div className="subpage-detail-header-right">
|
||||||
|
<a
|
||||||
|
href={`/${slugify(sp.name)}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="admin-btn-icon"
|
||||||
|
title="Preview this page"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
↗
|
||||||
|
</a>
|
||||||
<button
|
<button
|
||||||
className="admin-btn-icon"
|
className="admin-btn-icon"
|
||||||
onClick={() => setExpandedSubpage(null)}
|
onClick={() => setExpandedSubpage(null)}
|
||||||
@@ -645,7 +903,7 @@ export default function PageBuilder() {
|
|||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="admin-btn-icon"
|
className="admin-btn-icon admin-btn-icon-danger"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
removeSubpage(spIndex);
|
removeSubpage(spIndex);
|
||||||
setExpandedSubpage(null);
|
setExpandedSubpage(null);
|
||||||
@@ -696,7 +954,7 @@ export default function PageBuilder() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Albums (if no sections) */}
|
{/* Albums (if no sections) with DnD */}
|
||||||
{(!sp.sections || sp.sections.length === 0) && (
|
{(!sp.sections || sp.sections.length === 0) && (
|
||||||
<div className="subpage-albums">
|
<div className="subpage-albums">
|
||||||
<div className="subpage-albums-header">
|
<div className="subpage-albums-header">
|
||||||
@@ -710,30 +968,42 @@ export default function PageBuilder() {
|
|||||||
+ Add
|
+ Add
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="album-list">
|
<DndContext
|
||||||
{sp.albums.map((album, aIndex) => (
|
sensors={sensors}
|
||||||
<AlbumCard
|
collisionDetection={closestCenter}
|
||||||
key={`${album.id}-${aIndex}`}
|
onDragEnd={handleSubpageAlbumDragEnd(spIndex)}
|
||||||
album={album}
|
>
|
||||||
name={getAlbumName(album.id)}
|
<SortableContext
|
||||||
count={getAlbumCount(album.id)}
|
items={sp.albums.map((a, i) => `album-${a.id}-${i}`)}
|
||||||
thumbnailId={getAlbumThumbnailId(album.id)}
|
strategy={verticalListSortingStrategy}
|
||||||
onRemove={() => removeSubpageAlbum(spIndex, aIndex)}
|
>
|
||||||
onUpdate={(updates) => {
|
<div className="album-list">
|
||||||
setGallery((g) => {
|
{sp.albums.map((album, aIndex) => (
|
||||||
const subpages = [...g.subpages];
|
<SortableAlbumCard
|
||||||
const sp2 = { ...subpages[spIndex] };
|
key={`${album.id}-${aIndex}`}
|
||||||
const albums = [...sp2.albums];
|
album={album}
|
||||||
albums[aIndex] = { ...albums[aIndex], ...updates };
|
index={aIndex}
|
||||||
sp2.albums = albums;
|
name={getAlbumName(album.id)}
|
||||||
subpages[spIndex] = sp2;
|
count={getAlbumCount(album.id)}
|
||||||
return { ...g, subpages };
|
thumbnailId={getAlbumThumbnailId(album.id)}
|
||||||
});
|
onRemove={() => removeSubpageAlbum(spIndex, aIndex)}
|
||||||
markDirty();
|
onUpdate={(updates) => {
|
||||||
}}
|
setGallery((g) => {
|
||||||
/>
|
const subpages = [...g.subpages];
|
||||||
))}
|
const sp2 = { ...subpages[spIndex] };
|
||||||
</div>
|
const albums = [...sp2.albums];
|
||||||
|
albums[aIndex] = { ...albums[aIndex], ...updates };
|
||||||
|
sp2.albums = albums;
|
||||||
|
subpages[spIndex] = sp2;
|
||||||
|
return { ...g, subpages };
|
||||||
|
});
|
||||||
|
markDirty();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -817,18 +1087,13 @@ export default function PageBuilder() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="subpage-actions">
|
<div className="subpage-actions">
|
||||||
<button
|
<button className="admin-btn admin-btn-xs" onClick={() => addSection(spIndex)}>
|
||||||
className="admin-btn admin-btn-xs"
|
|
||||||
onClick={() => addSection(spIndex)}
|
|
||||||
>
|
|
||||||
+ Add Section
|
+ Add Section
|
||||||
</button>
|
</button>
|
||||||
{sp.sections && sp.sections.length > 0 && (
|
{sp.sections && sp.sections.length > 0 && (
|
||||||
<button
|
<button
|
||||||
className="admin-btn admin-btn-xs"
|
className="admin-btn admin-btn-xs"
|
||||||
onClick={() =>
|
onClick={() => setPickerTarget({ type: 'subpage', subpageIndex: spIndex })}
|
||||||
setPickerTarget({ type: 'subpage', subpageIndex: spIndex })
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
+ Add Album (outside sections)
|
+ Add Album (outside sections)
|
||||||
</button>
|
</button>
|
||||||
@@ -850,6 +1115,15 @@ export default function PageBuilder() {
|
|||||||
usedAlbumIds={getAllUsedAlbumIds()}
|
usedAlbumIds={getAllUsedAlbumIds()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Hero Asset Picker Modal */}
|
||||||
|
{showHeroPicker && (
|
||||||
|
<AssetPicker
|
||||||
|
onSelect={handleHeroSelect}
|
||||||
|
onClose={() => setShowHeroPicker(false)}
|
||||||
|
currentAssetIds={gallery.hero}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -873,20 +1147,30 @@ interface AlbumCardProps {
|
|||||||
thumbnailId: string | null;
|
thumbnailId: string | null;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
onUpdate: (updates: Partial<AlbumEntry>) => void;
|
onUpdate: (updates: Partial<AlbumEntry>) => void;
|
||||||
|
dragListeners?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function AlbumCard({ album, name, count, thumbnailId, onRemove, onUpdate }: AlbumCardProps) {
|
function AlbumCard({
|
||||||
|
album,
|
||||||
|
name,
|
||||||
|
count,
|
||||||
|
thumbnailId,
|
||||||
|
onRemove,
|
||||||
|
onUpdate,
|
||||||
|
dragListeners,
|
||||||
|
}: AlbumCardProps) {
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="album-tile">
|
<div className="album-tile">
|
||||||
<div className="album-tile-cover">
|
<div className="album-tile-cover">
|
||||||
|
{dragListeners && (
|
||||||
|
<div className="album-tile-drag" {...dragListeners} title="Drag to reorder">
|
||||||
|
⠿
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{thumbnailId ? (
|
{thumbnailId ? (
|
||||||
<img
|
<img src={`/api/admin/thumbnail/${thumbnailId}`} alt="" loading="lazy" />
|
||||||
src={`/api/admin/thumbnail/${thumbnailId}`}
|
|
||||||
alt=""
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="album-tile-placeholder">📷</div>
|
<div className="album-tile-placeholder">📷</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
|
||||||
interface Settings {
|
interface Settings {
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -67,6 +67,37 @@ export default function SettingsEditor() {
|
|||||||
loadSettings();
|
loadSettings();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// ── Keyboard shortcut: ⌘+S / Ctrl+S ─────────────────────────
|
||||||
|
const handleSaveRef = useCallback(() => {
|
||||||
|
if (dirty && !saving) {
|
||||||
|
handleSave();
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [dirty, saving, settings]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSaveRef();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [handleSaveRef]);
|
||||||
|
|
||||||
|
// ── Unsaved changes guard ────────────────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
function handleBeforeUnload(e: BeforeUnloadEvent) {
|
||||||
|
if (dirty) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.returnValue = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||||
|
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||||
|
}, [dirty]);
|
||||||
|
|
||||||
async function loadSettings() {
|
async function loadSettings() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
|
|||||||
87
app/api/admin/assets/route.ts
Normal file
87
app/api/admin/assets/route.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* Admin API: Browse Immich assets for the hero picker.
|
||||||
|
* Supports pagination and fetching favorites first.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { isAdminAuthenticated, isAdminEnabled } from '@/lib/admin/auth';
|
||||||
|
import { getConfig } from '@/lib/config';
|
||||||
|
|
||||||
|
interface ImmichSearchResult {
|
||||||
|
assets: {
|
||||||
|
items: Array<{
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
originalFileName: string;
|
||||||
|
thumbhash: string | null;
|
||||||
|
fileCreatedAt: string;
|
||||||
|
isFavorite: boolean;
|
||||||
|
}>;
|
||||||
|
nextPage: string | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET: Search/browse assets from Immich for hero image selection. */
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
if (!isAdminEnabled()) {
|
||||||
|
return NextResponse.json({ error: 'Admin not enabled' }, { status: 403 });
|
||||||
|
}
|
||||||
|
if (!(await isAdminAuthenticated())) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = getConfig();
|
||||||
|
if (config.needsSetup) {
|
||||||
|
return NextResponse.json({ error: 'Immich not configured' }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const page = parseInt(searchParams.get('page') || '1', 10);
|
||||||
|
const favoritesOnly = searchParams.get('favorites') === 'true';
|
||||||
|
const pageSize = 50;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use Immich search/metadata endpoint to query assets
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
type: 'IMAGE',
|
||||||
|
size: pageSize,
|
||||||
|
page,
|
||||||
|
order: 'desc',
|
||||||
|
isNotInAlbum: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (favoritesOnly) {
|
||||||
|
body.isFavorite = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`${config.immich.apiUrl}/search/metadata`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'x-api-key': config.immich.apiKey,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json({ error: `Immich API returned ${res.status}` }, { status: 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await res.json()) as ImmichSearchResult;
|
||||||
|
const assets = data.assets.items.map((asset) => ({
|
||||||
|
id: asset.id,
|
||||||
|
originalFileName: asset.originalFileName,
|
||||||
|
fileCreatedAt: asset.fileCreatedAt,
|
||||||
|
isFavorite: asset.isFavorite,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
assets,
|
||||||
|
nextPage: data.assets.nextPage ? page + 1 : null,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Admin] Failed to fetch assets from Immich:', err);
|
||||||
|
return NextResponse.json({ error: 'Failed to connect to Immich' }, { status: 502 });
|
||||||
|
}
|
||||||
|
}
|
||||||
56
package-lock.json
generated
56
package-lock.json
generated
@@ -9,6 +9,9 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"js-yaml": "^4.1.1",
|
"js-yaml": "^4.1.1",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"next": "16.2.4",
|
"next": "16.2.4",
|
||||||
@@ -283,6 +286,59 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@dnd-kit/accessibility": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/core": {
|
||||||
|
"version": "6.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||||
|
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/accessibility": "^3.1.1",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0",
|
||||||
|
"react-dom": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/sortable": {
|
||||||
|
"version": "10.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
|
||||||
|
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.0",
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/utilities": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@emnapi/core": {
|
"node_modules/@emnapi/core": {
|
||||||
"version": "1.8.1",
|
"version": "1.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz",
|
||||||
|
|||||||
@@ -15,6 +15,9 @@
|
|||||||
"test:e2e": "playwright test"
|
"test:e2e": "playwright test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"js-yaml": "^4.1.1",
|
"js-yaml": "^4.1.1",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"next": "16.2.4",
|
"next": "16.2.4",
|
||||||
|
|||||||
Reference in New Issue
Block a user