release: v0.13.0 (#535)
Twenty-nine commits since v0.12.0. See CHANGELOG.md for the full list and the upgrade notes. Ten additions — the SEO package (site URL, robots.txt, sitemap.xml, JSON-LD), slideshow mode, opt-in original downloads, copy-link, previous/next album navigation, alt text from the Immich description, per-album location precision, the update notice, a Help tab, and a subpage cover grid separate from its photo grid. Two fixes: CJK album slugs, and the lightbox control bar. Photo of the Day (#476) was built on `dev` and reverted as not planned; it never appears in a release. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01DmsHVRL9yQvckUB1ZpVWre --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: ralksta <ralksta@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,9 @@ import Image from 'next/image';
|
||||
import React from 'react';
|
||||
import { getServerDictionary } from '@/lib/i18n/server';
|
||||
import { albumMetaDetail } from '@/lib/albumMeta';
|
||||
import { AlbumNav } from '@/components/AlbumNav';
|
||||
import { StructuredData } from '@/components/StructuredData';
|
||||
import type { AlbumNavPair } from '@/lib/albumNav';
|
||||
|
||||
interface AlbumDetailViewProps {
|
||||
album: ImmichAlbum;
|
||||
@@ -28,6 +31,10 @@ interface AlbumDetailViewProps {
|
||||
proofing?: boolean;
|
||||
/** Offer the "send by email" button in the proofing modal. */
|
||||
allowMailto?: boolean;
|
||||
/** Neighbouring albums in the surrounding list, for the foot navigation. */
|
||||
nav?: AlbumNavPair;
|
||||
/** JSON-LD for this album, or null when no site URL is configured (#472). */
|
||||
structuredData?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export function AlbumDetailView({
|
||||
@@ -45,12 +52,15 @@ export function AlbumDetailView({
|
||||
showGear = true,
|
||||
proofing,
|
||||
allowMailto,
|
||||
nav,
|
||||
structuredData,
|
||||
}: AlbumDetailViewProps) {
|
||||
const metaDetail = albumMetaDetail(album, showGear);
|
||||
const t = getServerDictionary();
|
||||
|
||||
return (
|
||||
<>
|
||||
{structuredData && <StructuredData data={structuredData} />}
|
||||
{heroImageUrl && (
|
||||
<div className="album-hero">
|
||||
<Image
|
||||
@@ -93,6 +103,7 @@ export function AlbumDetailView({
|
||||
proofing={proofing}
|
||||
allowMailto={allowMailto}
|
||||
/>
|
||||
{nav && <AlbumNav {...nav} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { FadeIn } from '@/components/FadeIn';
|
||||
import { ProofingProvider, useProofing } from '@/components/ProofingContext';
|
||||
import { ProofingModal } from '@/components/ProofingModal';
|
||||
import { useDictionary } from '@/components/I18nProvider';
|
||||
import { parsePhotoHash, buildPhotoHash } from '@/lib/photoHash';
|
||||
|
||||
export interface PhotoItem {
|
||||
id: string;
|
||||
@@ -31,6 +32,10 @@ export interface PhotoItem {
|
||||
focalLength?: string;
|
||||
/** Natural image aspect ratio (width/height) for masonry layout */
|
||||
aspectRatio?: number;
|
||||
/** Immich asset description, used as alt text. Absent when captions are off. */
|
||||
caption?: string;
|
||||
/** Present only when the surrounding album offers its originals (#475). */
|
||||
downloadUrl?: string;
|
||||
}
|
||||
|
||||
interface PhotoGridProps {
|
||||
@@ -54,19 +59,6 @@ interface PhotoGridProps {
|
||||
allowMailto?: boolean;
|
||||
}
|
||||
|
||||
/** Parse `#photo-N` from a hash string. Returns index or null. */
|
||||
function parsePhotoHash(hash: string): number | null {
|
||||
const match = hash.match(/^#photo-(\d+)$/);
|
||||
if (!match) return null;
|
||||
const idx = parseInt(match[1], 10) - 1; // 1-indexed in URL, 0-indexed internally
|
||||
return idx >= 0 ? idx : null;
|
||||
}
|
||||
|
||||
/** Build the hash string for a given 0-based index. */
|
||||
function buildPhotoHash(index: number): string {
|
||||
return `#photo-${index + 1}`; // 1-indexed for user-friendliness
|
||||
}
|
||||
|
||||
function PhotoGridInner({
|
||||
assets,
|
||||
layout = 'masonry',
|
||||
@@ -186,7 +178,7 @@ function PhotoGridInner({
|
||||
>
|
||||
<Image
|
||||
src={asset.thumbUrl}
|
||||
alt=""
|
||||
alt={asset.caption ?? ''}
|
||||
fill
|
||||
sizes="(max-width: 600px) 50vw, (max-width: 1000px) 33vw, 25vw"
|
||||
loading={index < 6 ? 'eager' : 'lazy'}
|
||||
|
||||
@@ -20,7 +20,9 @@ import {
|
||||
exifUrl,
|
||||
videoUrl,
|
||||
assetPlaceholder,
|
||||
assetCaption,
|
||||
assetExifSummary,
|
||||
downloadUrl,
|
||||
assetAspectRatio,
|
||||
} from '@/lib/urls';
|
||||
import { encodeAssetId } from '@/lib/tokens';
|
||||
@@ -28,6 +30,7 @@ import {
|
||||
buildCoverGridVars,
|
||||
getConfig,
|
||||
hasExifPanelContent,
|
||||
normalizeSlug,
|
||||
resolveProofing,
|
||||
type GridConfig,
|
||||
} from '@/lib/config';
|
||||
@@ -36,6 +39,9 @@ import { isAdminAuthenticated } from '@/lib/admin/auth';
|
||||
import PasswordGate from '@/components/PasswordGate';
|
||||
import { AdminDiagnosticBanner } from '@/components/AdminDiagnosticBanner';
|
||||
import { AlbumDetailView } from './AlbumDetailView';
|
||||
import { albumNeighbours } from '@/lib/albumNav';
|
||||
import { albumStructuredData } from '@/lib/structuredData';
|
||||
import { absoluteUrl } from '@/lib/siteUrl';
|
||||
import { SubpageGridView } from './SubpageGridView';
|
||||
import { EssayView } from './EssayView';
|
||||
import { parseEssayMarkdown } from '@/lib/essay';
|
||||
@@ -51,7 +57,11 @@ interface PathPageProps {
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: PathPageProps): Promise<Metadata> {
|
||||
const { path } = await params;
|
||||
// Next hands catch-all segments over percent-encoded, so a non-ASCII slug
|
||||
// ("/家族相册") would never match a stored one. Decode once, here, and every
|
||||
// comparison downstream works on the same form (#522).
|
||||
const { path: rawPath } = await params;
|
||||
const path = rawPath?.map(normalizeSlug);
|
||||
if (!path || path.length === 0) return {};
|
||||
|
||||
const slug = path[0];
|
||||
@@ -114,13 +124,23 @@ export async function generateMetadata({ params }: PathPageProps): Promise<Metad
|
||||
/**
|
||||
* `showExif` covers the hover overlay only, and that overlay carries camera,
|
||||
* lens and focal length — so it follows the `camera` group, not the panel.
|
||||
*
|
||||
* `showCaption` follows the `caption` group and decides whether the Immich
|
||||
* description becomes alt text; see `assetCaption`.
|
||||
*/
|
||||
function toPhotoItems(assets: ImmichAsset[], showExif: boolean): PhotoItem[] {
|
||||
function toPhotoItems(
|
||||
assets: ImmichAsset[],
|
||||
showExif: boolean,
|
||||
showCaption: boolean,
|
||||
/** The album offering downloads, or undefined when it does not. */
|
||||
downloadAlbumId?: string,
|
||||
): PhotoItem[] {
|
||||
return assets
|
||||
.filter((a) => a.type === 'IMAGE' || a.type === 'VIDEO')
|
||||
.map((a) => {
|
||||
const ph = assetPlaceholder(a);
|
||||
const exif = showExif && a.type === 'IMAGE' ? assetExifSummary(a) : undefined;
|
||||
const caption = assetCaption(a, showCaption);
|
||||
const isVideo = a.type === 'VIDEO';
|
||||
return {
|
||||
id: encodeAssetId(a.id),
|
||||
@@ -131,11 +151,39 @@ function toPhotoItems(assets: ImmichAsset[], showExif: boolean): PhotoItem[] {
|
||||
exifUrl: exifUrl(a.id),
|
||||
...(ph ? { blurDataURL: ph.blurDataURL, dominantColor: ph.dominantColor } : {}),
|
||||
...(exif ?? {}),
|
||||
...(caption ? { caption } : {}),
|
||||
...(downloadAlbumId ? { downloadUrl: downloadUrl(downloadAlbumId, a.id) } : {}),
|
||||
aspectRatio: assetAspectRatio(a),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON-LD for an album page. Null unless a site URL is configured — structured
|
||||
* data is a set of claims about absolute URLs, and there is nothing truthful to
|
||||
* claim without one (#472).
|
||||
*/
|
||||
function structuredDataFor(
|
||||
album: { albumName: string; description?: string; albumThumbnailAssetId?: string | null },
|
||||
path: string,
|
||||
photoCount: number,
|
||||
) {
|
||||
const config = getConfig();
|
||||
const cover = album.albumThumbnailAssetId
|
||||
? absoluteUrl(config.siteUrl, imageUrl(album.albumThumbnailAssetId, 'preview'))
|
||||
: null;
|
||||
return albumStructuredData({
|
||||
siteUrl: config.siteUrl,
|
||||
pageUrl: absoluteUrl(config.siteUrl, path),
|
||||
albumName: album.albumName,
|
||||
...(album.description ? { description: album.description } : {}),
|
||||
coverUrl: cover,
|
||||
...(config.legal.name?.trim() ? { creator: config.legal.name } : {}),
|
||||
...(config.seo.license ? { license: config.seo.license } : {}),
|
||||
photoCount,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a PasswordGate element if the key (slug or ID) is protected and
|
||||
* not yet authenticated, otherwise returns null.
|
||||
@@ -175,7 +223,11 @@ async function getAlbumHeroData(
|
||||
}
|
||||
|
||||
export default async function PathPage({ params, searchParams }: PathPageProps) {
|
||||
const { path } = await params;
|
||||
// Next hands catch-all segments over percent-encoded, so a non-ASCII slug
|
||||
// ("/家族相册") would never match a stored one. Decode once, here, and every
|
||||
// comparison downstream works on the same form (#522).
|
||||
const { path: rawPath } = await params;
|
||||
const path = rawPath?.map(normalizeSlug);
|
||||
const sParams = (await searchParams) || {};
|
||||
const forceFresh = sParams.fresh === '1' || sParams.preview === 'true';
|
||||
|
||||
@@ -199,10 +251,10 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
|
||||
const resolveLayout = (overrides?: Partial<GridConfig>) =>
|
||||
overrides?.layout ?? config.grid.layout;
|
||||
|
||||
// The album-cover grid on a subpage takes its column count from the same
|
||||
// config as the photo grids, so "3 columns" in the admin means three columns
|
||||
// everywhere. `gap` deliberately does not follow the global setting — see
|
||||
// buildCoverGridVars() for why.
|
||||
// The album-cover grid on a subpage is sized by its own `coverGrid`, which
|
||||
// touches nothing but the cover tiles (#523). An unset column count still
|
||||
// falls back to the global one; `gap` deliberately does not follow the global
|
||||
// setting — see buildCoverGridVars() for why.
|
||||
const buildCoverGridStyle = (overrides?: Partial<GridConfig>): React.CSSProperties =>
|
||||
buildCoverGridVars(overrides, config.grid.columns) as React.CSSProperties;
|
||||
|
||||
@@ -254,7 +306,12 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
|
||||
const spGrid = subpageData?.subpage.grid;
|
||||
const subpageName = subpageData?.subpage.name ?? subpageSlug;
|
||||
|
||||
const images = toPhotoItems(album.assets, config.exif.onHover && config.exif.camera);
|
||||
const images = toPhotoItems(
|
||||
album.assets,
|
||||
config.exif.onHover && config.exif.camera,
|
||||
config.exif.caption,
|
||||
config.albumDownloads[album.id] ? album.id : undefined,
|
||||
);
|
||||
|
||||
// Password gate for protected albums
|
||||
const albumGate = await gateIfProtected(album.id, 'album', album.albumName);
|
||||
@@ -262,10 +319,16 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
|
||||
|
||||
const heroData = await getAlbumHeroData(album.id, config);
|
||||
|
||||
// Neighbours come from the subpage list the visitor just came through, in
|
||||
// that list's own order, so "next" agrees with the grid they saw (#483).
|
||||
const nav = subpageData ? albumNeighbours(subpageData.albums, album.id, `/${subpageSlug}`) : {};
|
||||
|
||||
return (
|
||||
<AlbumDetailView
|
||||
album={album}
|
||||
images={images}
|
||||
nav={nav}
|
||||
structuredData={structuredDataFor(album, `/${subpageSlug}/${albumSlug}`, images.length)}
|
||||
layout={resolveLayout(mergeAlbumGrid(album.id, spGrid))}
|
||||
gridStyle={buildGridStyle(mergeAlbumGrid(album.id, spGrid))}
|
||||
backLinkHref={`/${subpageSlug}`}
|
||||
@@ -324,7 +387,11 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
|
||||
albums.map((a) => immich.getAlbumBySlug(a.slug, slug, forceFresh)),
|
||||
);
|
||||
const allAssets = allAlbums.flatMap((a) => (a ? a.assets : []));
|
||||
const images = toPhotoItems(allAssets, config.exif.onHover && config.exif.camera);
|
||||
const images = toPhotoItems(
|
||||
allAssets,
|
||||
config.exif.onHover && config.exif.camera,
|
||||
config.exif.caption,
|
||||
);
|
||||
|
||||
// Fallback structured essay if layout: 'essay' is set without custom markdown file
|
||||
if (!essayParsed) {
|
||||
@@ -381,7 +448,12 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
|
||||
notFound();
|
||||
}
|
||||
|
||||
const images = toPhotoItems(album.assets, config.exif.onHover && config.exif.camera);
|
||||
const images = toPhotoItems(
|
||||
album.assets,
|
||||
config.exif.onHover && config.exif.camera,
|
||||
config.exif.caption,
|
||||
config.albumDownloads[album.id] ? album.id : undefined,
|
||||
);
|
||||
|
||||
// Password gate for protected albums
|
||||
const albumGate = await gateIfProtected(album.id, 'album', album.albumName);
|
||||
@@ -444,7 +516,7 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
|
||||
albums={albumsWithHero}
|
||||
coverPlaceholders={coverPlaceholders}
|
||||
sections={result.subpage.sections}
|
||||
gridStyle={buildCoverGridStyle(result.subpage.grid)}
|
||||
gridStyle={buildCoverGridStyle(result.subpage.coverGrid)}
|
||||
{...(subpageIndex >= 0 ? { index: subpageIndex + 1 } : {})}
|
||||
/>
|
||||
);
|
||||
@@ -464,7 +536,12 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
|
||||
notFound();
|
||||
}
|
||||
|
||||
const images = toPhotoItems(album.assets, config.exif.onHover && config.exif.camera);
|
||||
const images = toPhotoItems(
|
||||
album.assets,
|
||||
config.exif.onHover && config.exif.camera,
|
||||
config.exif.caption,
|
||||
config.albumDownloads[album.id] ? album.id : undefined,
|
||||
);
|
||||
|
||||
// Password gate for protected albums
|
||||
const albumGate = await gateIfProtected(album.id, 'album', album.albumName);
|
||||
@@ -480,6 +557,8 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
|
||||
gridStyle={buildGridStyle(mergeAlbumGrid(album.id))}
|
||||
backLinkHref="/"
|
||||
backLinkLabel={getServerDictionary().common.backToGallery}
|
||||
nav={albumNeighbours(await immich.getStandaloneAlbums(forceFresh), album.id)}
|
||||
structuredData={structuredDataFor(album, `/${slug}`, images.length)}
|
||||
watermark={config.watermark}
|
||||
showExifPanel={hasExifPanelContent(config.exif)}
|
||||
showGear={config.exif.camera}
|
||||
|
||||
@@ -1928,10 +1928,83 @@
|
||||
color: var(--admin-error, #ef4444);
|
||||
}
|
||||
|
||||
.status-val.update {
|
||||
color: var(--admin-warning, #f59e0b);
|
||||
}
|
||||
|
||||
.status-val.unknown {
|
||||
color: var(--admin-text-secondary);
|
||||
}
|
||||
|
||||
/* ── Help section ──────────────────────────────────────────── */
|
||||
|
||||
.help-kbd {
|
||||
display: inline-block;
|
||||
min-width: 1.6rem;
|
||||
padding: 2px 6px;
|
||||
border: 1px solid var(--admin-border, rgba(255, 255, 255, 0.15));
|
||||
border-bottom-width: 2px;
|
||||
border-radius: 4px;
|
||||
background: var(--admin-bg-elevated, rgba(255, 255, 255, 0.06));
|
||||
color: var(--admin-text);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.4;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.help-shortcuts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
margin: 0;
|
||||
padding: 4px 0 0;
|
||||
}
|
||||
|
||||
.help-shortcuts-row {
|
||||
display: grid;
|
||||
grid-template-columns: 8.5rem 1fr;
|
||||
gap: 12px;
|
||||
align-items: baseline;
|
||||
padding: 10px 4px;
|
||||
border-bottom: 1px solid var(--admin-border-subtle, rgba(255, 255, 255, 0.06));
|
||||
}
|
||||
|
||||
.help-shortcuts-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.help-shortcuts-keys {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.help-shortcuts-label {
|
||||
margin: 0;
|
||||
color: var(--admin-text);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.help-shortcuts-note {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
color: var(--admin-text-secondary);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.help-action {
|
||||
padding: 12px 4px 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.help-shortcuts-row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Search Bar ────────────────────────────────────────────── */
|
||||
.builder-search-container {
|
||||
padding: 0.75rem 1.5rem;
|
||||
|
||||
@@ -19,6 +19,7 @@ const TABS = [
|
||||
{ label: 'Journal', href: '/admin/journal', match: /^\/admin\/journal/ },
|
||||
{ label: 'Settings', href: '/admin/settings', match: /^\/admin\/settings/ },
|
||||
{ label: 'Analytics', href: '/admin/analytics', match: /^\/admin\/analytics/ },
|
||||
{ label: 'Help', href: '/admin/help', match: /^\/admin\/help/ },
|
||||
];
|
||||
|
||||
export default function AdminDashboard({ onLogout, children }: Props) {
|
||||
@@ -229,6 +230,28 @@ export default function AdminDashboard({ onLogout, children }: Props) {
|
||||
<span className="status-label">In-Memory Cache</span>
|
||||
<span className="status-val">{status?.cache?.size ?? 0} items</span>
|
||||
</div>
|
||||
{/*
|
||||
Discreet on purpose: a row in the status list, not a
|
||||
banner. When the check is off or could not run, the row
|
||||
still names the running version and says nothing about
|
||||
updates (#496).
|
||||
*/}
|
||||
<div className="status-item">
|
||||
<span className="status-label">Version</span>
|
||||
{status?.update?.updateAvailable ? (
|
||||
<a
|
||||
className="status-val update"
|
||||
href="https://github.com/ralksta/immich-folio/releases/latest"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={`Version ${status.update.latest} is available`}
|
||||
>
|
||||
{status.update.current} → {status.update.latest}
|
||||
</a>
|
||||
) : (
|
||||
<span className="status-val">{status?.update?.current ?? '—'}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="status-dropdown-footer">
|
||||
<button
|
||||
|
||||
92
app/admin/components/HelpView.tsx
Normal file
92
app/admin/components/HelpView.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import DoctorModal from './DoctorModal';
|
||||
import * as Icons from './Icons';
|
||||
import { en } from '@/lib/i18n/locales/en';
|
||||
import { LIGHTBOX_SHORTCUTS, shortcutDisplayKeys } from '@/lib/lightboxShortcuts';
|
||||
|
||||
/**
|
||||
* The admin help section.
|
||||
*
|
||||
* It exists because parts of the portfolio have no control of their own. The
|
||||
* lightbox is the clearest case: its keys are deliberately unadvertised to
|
||||
* visitors, which also left the site owner with no way to learn they exist
|
||||
* (#473). This is where the owner finds out what their portfolio can do.
|
||||
*
|
||||
* Labels come from the English dictionary rather than being retyped, so what
|
||||
* this page promises is what a visitor is actually shown. The admin panel is
|
||||
* English-only, hence `en` directly.
|
||||
*/
|
||||
export default function HelpView() {
|
||||
const [showDoctor, setShowDoctor] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="settings-panel">
|
||||
<div className="settings-section-header">
|
||||
<h3>
|
||||
<Icons.IconFileText size={18} /> Help
|
||||
</h3>
|
||||
<p className="settings-section-sub">
|
||||
What your portfolio can do that has no button of its own.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="settings-group">
|
||||
<div className="settings-group-head">
|
||||
<span className="settings-group-title">
|
||||
<Icons.IconFilm size={13} />
|
||||
Image viewer
|
||||
</span>
|
||||
<span className="settings-group-desc">
|
||||
Keys a visitor can press in the lightbox. There is deliberately no toolbar for these — a
|
||||
permanent control over a photograph costs every visitor something — but visitors can
|
||||
call up the same list with <kbd className="help-kbd">?</kbd> or{' '}
|
||||
<kbd className="help-kbd">H</kbd>.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<dl className="help-shortcuts">
|
||||
{LIGHTBOX_SHORTCUTS.map((shortcut) => (
|
||||
<div key={shortcut.labelKey} className="help-shortcuts-row">
|
||||
<dt className="help-shortcuts-keys">
|
||||
{shortcutDisplayKeys(shortcut).map((key) => (
|
||||
<kbd key={key} className="help-kbd">
|
||||
{key}
|
||||
</kbd>
|
||||
))}
|
||||
</dt>
|
||||
<dd className="help-shortcuts-label">
|
||||
{en.lightbox[shortcut.labelKey]}
|
||||
{shortcut.note && <span className="help-shortcuts-note">{shortcut.note}</span>}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div className="settings-group">
|
||||
<div className="settings-group-head">
|
||||
<span className="settings-group-title">
|
||||
<Icons.IconShieldCheck size={13} />
|
||||
Diagnostics
|
||||
</span>
|
||||
<span className="settings-group-desc">
|
||||
Checks the things that fail silently — a wrong proxy hop count, a deleted album ID, a
|
||||
plaintext password, an album Immich does not consider shared. It also sits behind the
|
||||
status badge in the top bar, which is where it belongs when the question is whether
|
||||
anything needs attention right now.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="help-action">
|
||||
<button type="button" className="admin-btn" onClick={() => setShowDoctor(true)}>
|
||||
<Icons.IconShieldCheck size={14} /> Run diagnostics
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DoctorModal isOpen={showDoctor} onClose={() => setShowDoctor(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -35,6 +35,10 @@ import {
|
||||
COVER_GRID_COLUMNS_MAX,
|
||||
COVER_GRID_COLUMNS_MIN,
|
||||
COVER_GRID_GAP_MAX,
|
||||
PHOTO_GRID_COLUMNS_MAX,
|
||||
PHOTO_GRID_COLUMNS_MIN,
|
||||
PHOTO_GRID_GAP_MAX,
|
||||
slugify,
|
||||
type AlbumEntryObject,
|
||||
} from '@/lib/config/schema';
|
||||
import {
|
||||
@@ -124,7 +128,24 @@ interface Subpage {
|
||||
essayFile?: string;
|
||||
sections?: Section[];
|
||||
albums: AlbumEntry[];
|
||||
/** Photo grid for the albums on this page (and the page layout style). */
|
||||
grid?: { columns?: number; gap?: number; aspectRatio?: string; layout?: string };
|
||||
/** The album-cover tiles on this page only (#523). */
|
||||
coverGrid?: { columns?: number; gap?: number; aspectRatio?: string; layout?: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* The cover grid a subpage renders with when its gallery.yaml predates #523.
|
||||
*
|
||||
* Back then one `grid` key sized both the covers and the photos, so the values
|
||||
* a user typed into "Album Cover Grid" live there. Seeding the new field from
|
||||
* them means the drawer shows what the page actually renders, and the first
|
||||
* save writes the split out explicitly. Only the two keys the cover grid reads
|
||||
* are carried over — `layout` and `aspectRatio` never reached the covers.
|
||||
*/
|
||||
function seedCoverGrid(grid: Subpage['grid']): Subpage['coverGrid'] {
|
||||
if (!grid) return undefined;
|
||||
return normalizeSubpageGrid({ columns: grid.columns, gap: grid.gap });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -553,6 +574,8 @@ export default function PageBuilder() {
|
||||
}))
|
||||
: undefined,
|
||||
grid: sp.grid as Subpage['grid'],
|
||||
coverGrid:
|
||||
(sp.coverGrid as Subpage['coverGrid']) ?? seedCoverGrid(sp.grid as Subpage['grid']),
|
||||
}));
|
||||
} else if (raw.subpages && typeof raw.subpages === 'object') {
|
||||
subpages = Object.entries(raw.subpages as Record<string, unknown>).map(([name, value]) => {
|
||||
@@ -574,6 +597,8 @@ export default function PageBuilder() {
|
||||
),
|
||||
sections: undefined,
|
||||
grid: sp.grid as Subpage['grid'],
|
||||
coverGrid:
|
||||
(sp.coverGrid as Subpage['coverGrid']) ?? seedCoverGrid(sp.grid as Subpage['grid']),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -610,6 +635,7 @@ export default function PageBuilder() {
|
||||
if (sp.essayText) entry.essayText = sp.essayText;
|
||||
if (sp.essayFile) entry.essayFile = sp.essayFile;
|
||||
if (sp.grid) entry.grid = sp.grid;
|
||||
if (sp.coverGrid) entry.coverGrid = sp.coverGrid;
|
||||
|
||||
if (sp.sections && sp.sections.length > 0) {
|
||||
entry.sections = sp.sections.map((sec) => {
|
||||
@@ -910,13 +936,6 @@ export default function PageBuilder() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function slugify(str: string): string {
|
||||
return str
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
// ── Render ───────────────────────────────────────────────────
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -1487,14 +1506,14 @@ export default function PageBuilder() {
|
||||
type="number"
|
||||
min={COVER_GRID_COLUMNS_MIN}
|
||||
max={COVER_GRID_COLUMNS_MAX}
|
||||
value={sp.grid?.columns ?? ''}
|
||||
value={sp.coverGrid?.columns ?? ''}
|
||||
placeholder="Columns (site default)"
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value;
|
||||
const columns = raw === '' ? undefined : Number(raw);
|
||||
updateSubpage(spIndex, {
|
||||
grid: normalizeSubpageGrid({
|
||||
...(sp.grid || {}),
|
||||
coverGrid: normalizeSubpageGrid({
|
||||
...(sp.coverGrid || {}),
|
||||
columns:
|
||||
columns != null &&
|
||||
columns >= COVER_GRID_COLUMNS_MIN &&
|
||||
@@ -1515,14 +1534,14 @@ export default function PageBuilder() {
|
||||
type="number"
|
||||
min={0}
|
||||
max={COVER_GRID_GAP_MAX}
|
||||
value={sp.grid?.gap ?? ''}
|
||||
value={sp.coverGrid?.gap ?? ''}
|
||||
placeholder="Gap in px (theme default)"
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value;
|
||||
const gap = raw === '' ? undefined : Number(raw);
|
||||
updateSubpage(spIndex, {
|
||||
grid: normalizeSubpageGrid({
|
||||
...(sp.grid || {}),
|
||||
coverGrid: normalizeSubpageGrid({
|
||||
...(sp.coverGrid || {}),
|
||||
gap:
|
||||
gap != null && gap >= 0 && gap <= COVER_GRID_GAP_MAX
|
||||
? gap
|
||||
@@ -1539,10 +1558,82 @@ export default function PageBuilder() {
|
||||
/>
|
||||
</div>
|
||||
<p className="admin-field-hint">
|
||||
Overrides how the album covers are tiled on this page. Leave the
|
||||
column count empty to follow the site-wide grid setting, and the
|
||||
gap empty to keep the theme's own spacing. Tablet widths
|
||||
show at most two covers per row.
|
||||
Overrides how the album covers are tiled on this page — the
|
||||
photos inside the albums are not affected. Leave the column
|
||||
count empty to follow the site-wide grid setting, and the gap
|
||||
empty to keep the theme's own spacing. Tablet widths show
|
||||
at most two covers per row.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Photo grid — the albums opened from this page.
|
||||
Separate from the cover grid above: one field
|
||||
used to drive both, so a cover gap retuned
|
||||
every photo grid on the page (#523). */}
|
||||
{sp.essayText == null && (
|
||||
<div className="admin-field" style={{ marginTop: '1rem' }}>
|
||||
<label>Photo Grid (Albums On This Page)</label>
|
||||
<div style={{ display: 'flex', gap: '12px' }}>
|
||||
<input
|
||||
type="number"
|
||||
min={PHOTO_GRID_COLUMNS_MIN}
|
||||
max={PHOTO_GRID_COLUMNS_MAX}
|
||||
value={sp.grid?.columns ?? ''}
|
||||
placeholder="Columns (site default)"
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value;
|
||||
const columns = raw === '' ? undefined : Number(raw);
|
||||
updateSubpage(spIndex, {
|
||||
grid: normalizeSubpageGrid({
|
||||
...(sp.grid || {}),
|
||||
columns:
|
||||
columns != null &&
|
||||
columns >= PHOTO_GRID_COLUMNS_MIN &&
|
||||
columns <= PHOTO_GRID_COLUMNS_MAX
|
||||
? columns
|
||||
: undefined,
|
||||
}),
|
||||
});
|
||||
}}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
borderRadius: '6px',
|
||||
flex: 1,
|
||||
fontSize: '0.9rem',
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={PHOTO_GRID_GAP_MAX}
|
||||
value={sp.grid?.gap ?? ''}
|
||||
placeholder="Gap in px (theme default)"
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value;
|
||||
const gap = raw === '' ? undefined : Number(raw);
|
||||
updateSubpage(spIndex, {
|
||||
grid: normalizeSubpageGrid({
|
||||
...(sp.grid || {}),
|
||||
gap:
|
||||
gap != null && gap >= 0 && gap <= PHOTO_GRID_GAP_MAX
|
||||
? gap
|
||||
: undefined,
|
||||
}),
|
||||
});
|
||||
}}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
borderRadius: '6px',
|
||||
flex: 1,
|
||||
fontSize: '0.9rem',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="admin-field-hint">
|
||||
How the photos inside every album on this page are laid out.
|
||||
Leave both empty to follow Settings › Grid; a single
|
||||
album can still override this in gallery.yaml.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -8,12 +8,20 @@ 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';
|
||||
import { resolveExifDisplay, resolveWatermarkOpacity } from '@/lib/config/schema';
|
||||
import {
|
||||
PHOTO_GRID_COLUMNS_MAX,
|
||||
PHOTO_GRID_COLUMNS_MIN,
|
||||
PHOTO_GRID_GAP_MAX,
|
||||
resolveExifDisplay,
|
||||
resolveWatermarkOpacity,
|
||||
} from '@/lib/config/schema';
|
||||
import { SUPPORTED_LOCALES } from '@/lib/i18n';
|
||||
|
||||
interface Settings {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
/** Absolute site URL for sitemap, feed and structured data (#472). */
|
||||
url?: string;
|
||||
lang?: string;
|
||||
sitePassword?: string;
|
||||
mode?: 'light' | 'dark' | 'auto';
|
||||
@@ -306,6 +314,11 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
? (section as string)
|
||||
: 'general';
|
||||
const [settings, setSettings] = useState<Settings>({});
|
||||
/** Resolved site URL and its origin, so the panel can name SITE_URL (#472). */
|
||||
const [siteUrlInfo, setSiteUrlInfo] = useState<{
|
||||
effective: string | null;
|
||||
source: 'env' | 'settings' | 'none';
|
||||
} | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
// Collapsed by default: the four metadata switches are a detail of one
|
||||
// decision, and showing them permanently is what made the section a wall (#510).
|
||||
@@ -418,8 +431,9 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
try {
|
||||
const res = await fetch('/api/admin/settings');
|
||||
if (res.ok) {
|
||||
const { settings: data } = await res.json();
|
||||
const { settings: data, siteUrl } = await res.json();
|
||||
setSettings(data || {});
|
||||
setSiteUrlInfo(siteUrl ?? null);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load settings:', err);
|
||||
@@ -1520,11 +1534,13 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
|
||||
<div className="admin-field-row">
|
||||
<div className="admin-field">
|
||||
<label>Columns (1 - 6)</label>
|
||||
<label>
|
||||
Columns ({PHOTO_GRID_COLUMNS_MIN} - {PHOTO_GRID_COLUMNS_MAX})
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={6}
|
||||
min={PHOTO_GRID_COLUMNS_MIN}
|
||||
max={PHOTO_GRID_COLUMNS_MAX}
|
||||
value={settings.grid?.columns ?? 3}
|
||||
onChange={(e) => update('grid.columns', parseInt(e.target.value) || 3)}
|
||||
/>
|
||||
@@ -1534,7 +1550,7 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={48}
|
||||
max={PHOTO_GRID_GAP_MAX}
|
||||
value={settings.grid?.gap ?? 12}
|
||||
onChange={(e) => update('grid.gap', parseInt(e.target.value) || 0)}
|
||||
/>
|
||||
@@ -1786,6 +1802,28 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-field">
|
||||
<label>Site URL</label>
|
||||
<input
|
||||
value={settings.url || ''}
|
||||
onChange={(e) => update('url', e.target.value)}
|
||||
placeholder="https://folio.example"
|
||||
/>
|
||||
<p style={{ fontSize: '0.8rem', opacity: 0.7, marginTop: '4px' }}>
|
||||
Needed for <code>sitemap.xml</code>, the feed and structured data — those are
|
||||
generated without a request, so the address cannot be derived from it. Leave empty
|
||||
and the sitemap stays empty rather than guessing.
|
||||
{siteUrlInfo?.source === 'env' && (
|
||||
<>
|
||||
<br />
|
||||
Currently supplied by the <code>SITE_URL</code> environment variable (
|
||||
<code>{siteUrlInfo.effective}</code>). A value entered here takes precedence
|
||||
once saved.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="admin-field">
|
||||
<label>SEO Meta Title</label>
|
||||
<input
|
||||
|
||||
5
app/admin/help/page.tsx
Normal file
5
app/admin/help/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import HelpView from '../components/HelpView';
|
||||
|
||||
export default function AdminHelpPage() {
|
||||
return <HelpView />;
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { NextResponse } from 'next/server';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { isAdminAuthenticated, isAdminEnabled } from '@/lib/admin/auth';
|
||||
import { readSettingsYaml, writeSettingsYaml } from '@/lib/admin/yaml-service';
|
||||
import { invalidateConfigCache } from '@/lib/config';
|
||||
import { invalidateConfigCache, getConfigOrNull } from '@/lib/config';
|
||||
import { immich } from '@/lib/immich';
|
||||
import type { SettingsYaml } from '@/lib/config/schema';
|
||||
|
||||
@@ -16,7 +16,16 @@ export async function GET() {
|
||||
}
|
||||
|
||||
const settings = await readSettingsYaml();
|
||||
return NextResponse.json({ settings: settings || {} });
|
||||
const config = getConfigOrNull();
|
||||
return NextResponse.json({
|
||||
settings: settings || {},
|
||||
// The resolved value and its origin, so the panel can say when what it
|
||||
// shows came from SITE_URL rather than from the field itself (#472).
|
||||
siteUrl: {
|
||||
effective: config?.siteUrl ?? null,
|
||||
source: config?.siteUrlSource ?? 'none',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** PUT: Write settings.yaml config. */
|
||||
|
||||
@@ -4,6 +4,7 @@ import { immich } from '@/lib/immich';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { cache } from '@/lib/cache';
|
||||
import { listBackups, readGalleryYaml, readSettingsYaml } from '@/lib/admin/yaml-service';
|
||||
import { getUpdateStatus } from '@/lib/updateCheck';
|
||||
|
||||
export async function GET() {
|
||||
if (!isAdminEnabled()) {
|
||||
@@ -68,6 +69,10 @@ export async function GET() {
|
||||
|
||||
const config = getConfig();
|
||||
|
||||
// Answered from a day-long cache and never throws, so a blocked or offline
|
||||
// instance still gets the rest of its status.
|
||||
const update = await getUpdateStatus();
|
||||
|
||||
return NextResponse.json({
|
||||
immich: {
|
||||
status: immichOk ? 'connected' : 'disconnected',
|
||||
@@ -89,5 +94,6 @@ export async function GET() {
|
||||
count: backupCount,
|
||||
lastBackup,
|
||||
},
|
||||
update,
|
||||
});
|
||||
}
|
||||
|
||||
130
app/api/download/[album]/[id]/route.ts
Normal file
130
app/api/download/[album]/[id]/route.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Download route for original files (#475).
|
||||
*
|
||||
* Deliberately separate from the image proxy. That route treats the opaque
|
||||
* asset token as the capability: holding one means you saw the page it was
|
||||
* rendered on, and it serves previews. Originals are the deliverable a client
|
||||
* pays for, so this route re-checks everything rather than inheriting that
|
||||
* assumption:
|
||||
*
|
||||
* - the album must be on the allowlist,
|
||||
* - the album must have opted in with `download: true`,
|
||||
* - every password gate on a route to it must be satisfied — the album's own
|
||||
* and, where that is the only way to it, the subpage's, and
|
||||
* - the asset must actually belong to that album.
|
||||
*
|
||||
* The last one is what stops one enabled album's URL being edited into a
|
||||
* download of any asset in the Immich instance.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { immich, ImmichUnavailableError } from '@/lib/immich';
|
||||
import { decodeAssetId } from '@/lib/tokens';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit';
|
||||
import { isAlbumReachable, siteLockResponse } from '@/lib/auth';
|
||||
import { contentDisposition } from '@/lib/downloadName';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* Far below the image limit. A gallery page fires dozens of image requests;
|
||||
* a download is one deliberate click, and each one is a full-size file off
|
||||
* the Immich server.
|
||||
*/
|
||||
const DOWNLOAD_RPM = 30;
|
||||
|
||||
/** One 404 for every refusal, so the response never says which check failed. */
|
||||
const notFound = () =>
|
||||
NextResponse.json(
|
||||
{ error: 'Not found' },
|
||||
{ status: 404, headers: { 'Cache-Control': 'no-store' } },
|
||||
);
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ album: string; id: string }> },
|
||||
) {
|
||||
const ip = getClientIp(request);
|
||||
const rl = checkRateLimit(`download:${ip}`, DOWNLOAD_RPM);
|
||||
if (!rl.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Too many requests' },
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
'Retry-After': String(retryAfterSeconds(rl.resetAt)),
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const locked = siteLockResponse(request);
|
||||
if (locked) return locked;
|
||||
|
||||
const { album: albumToken, id: assetToken } = await params;
|
||||
const albumId = decodeAssetId(albumToken);
|
||||
const assetId = decodeAssetId(assetToken);
|
||||
if (!albumId || !assetId) return notFound();
|
||||
|
||||
const config = getConfig();
|
||||
|
||||
// On the allowlist, and opted in.
|
||||
if (!config.albums.includes(albumId)) return notFound();
|
||||
if (!config.albumDownloads[albumId]) return notFound();
|
||||
|
||||
// Every gate on every route to the album, not just its own: an album with no
|
||||
// password of its own, reachable only through a subpage that has one, was
|
||||
// downloadable without that subpage ever being unlocked. The page checks
|
||||
// this before rendering; a route handler is reached without passing through
|
||||
// the page at all.
|
||||
const cookieStore = await cookies();
|
||||
const getCookie = (name: string) => cookieStore.get(name)?.value;
|
||||
if (!isAlbumReachable(albumId, getCookie)) return notFound();
|
||||
|
||||
let album;
|
||||
try {
|
||||
album = await immich.getAlbum(albumId);
|
||||
} catch (error) {
|
||||
if (error instanceof ImmichUnavailableError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Immich is currently unavailable' },
|
||||
{ status: 503, headers: { 'Retry-After': '30', 'Cache-Control': 'no-store' } },
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (!album) return notFound();
|
||||
|
||||
// The asset has to be in the album that authorised the download.
|
||||
const asset = album.assets.find((candidate) => candidate.id === assetId);
|
||||
if (!asset) return notFound();
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await immich.streamAsset(assetId, 'original');
|
||||
} catch (error) {
|
||||
if (error instanceof ImmichUnavailableError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Immich is currently unavailable' },
|
||||
{ status: 503, headers: { 'Retry-After': '30', 'Cache-Control': 'no-store' } },
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (!result) return notFound();
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': result.contentType || 'application/octet-stream',
|
||||
'Content-Disposition': contentDisposition(asset.originalFileName),
|
||||
// Private: a download is authorised per visitor, so a shared cache must
|
||||
// not hand the file to the next one.
|
||||
'Cache-Control': 'private, no-store',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
};
|
||||
if (result.contentLength) headers['Content-Length'] = result.contentLength;
|
||||
|
||||
return new NextResponse(result.stream, { headers });
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import { decodeAssetId } from '@/lib/tokens';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit';
|
||||
import { siteLockResponse } from '@/lib/auth';
|
||||
import { assetLocationPrecision } from '@/lib/assetLocation';
|
||||
import { placeLabel } from '@/lib/mapPrecision';
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const config = getConfig();
|
||||
@@ -73,6 +75,13 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
const exif = asset.exifInfo;
|
||||
const caption = show.caption ? exif.description?.trim() || undefined : undefined;
|
||||
|
||||
const place = show.location
|
||||
? placeLabel(await assetLocationPrecision(assetId), {
|
||||
city: exif.city ?? '',
|
||||
country: exif.country ?? '',
|
||||
})
|
||||
: { city: '', country: '' };
|
||||
|
||||
const payload = {
|
||||
...(show.camera
|
||||
? {
|
||||
@@ -89,7 +98,15 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
iso: exif.iso,
|
||||
}
|
||||
: {}),
|
||||
...(show.location ? { city: exif.city, country: exif.country } : {}),
|
||||
// Two gates, not one. `exifDisplay.location` decides whether this site
|
||||
// publishes places at all; the album's `location:` decides how precisely
|
||||
// this photograph may be placed — the same setting the map obeys, which
|
||||
// used to stop at the map (#469).
|
||||
// Emitted only when there is something to say. An empty string is not
|
||||
// null, so keeping the keys would defeat the "no data" check below and
|
||||
// render the blank panel it exists to prevent.
|
||||
...(place.city ? { city: place.city } : {}),
|
||||
...(place.country ? { country: place.country } : {}),
|
||||
...(caption ? { description: caption } : {}),
|
||||
};
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ vi.mock('@/lib/config', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/immich', () => ({
|
||||
immich: { getAssetInfo: vi.fn() },
|
||||
immich: { getAssetInfo: vi.fn(), getAlbum: vi.fn() },
|
||||
ImmichUnavailableError: class ImmichUnavailableError extends Error {},
|
||||
}));
|
||||
|
||||
@@ -36,6 +36,7 @@ import { NextRequest } from 'next/server';
|
||||
|
||||
const mockConfig = getConfig as unknown as ReturnType<typeof vi.fn>;
|
||||
const mockAssetInfo = immich.getAssetInfo as unknown as ReturnType<typeof vi.fn>;
|
||||
const mockGetAlbum = immich.getAlbum as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
const EXIF_ASSET = {
|
||||
exifInfo: {
|
||||
@@ -64,6 +65,9 @@ function configWith(raw?: ExifDisplayYaml, exifOnHover?: boolean) {
|
||||
exifOnHover: exifOnHover !== false,
|
||||
exif: resolveExifDisplay(raw, exifOnHover),
|
||||
rateLimitRpm: 120,
|
||||
// No album restricts its location, so the place comes through as the
|
||||
// asset carries it — the shape getConfig() always builds (#469).
|
||||
albumLocationPrecision: {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -152,3 +156,40 @@ describe('GET /api/exif/[id] captions', () => {
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* `location:` used to govern the map and nothing else, so an album asking to
|
||||
* be absent from it still named its city here (#469).
|
||||
*/
|
||||
describe('GET /api/exif/[id] album location precision', () => {
|
||||
/** The album that carries the asset the mocked token decodes to. */
|
||||
const carrying = { id: 'album-a', assets: [{ id: 'asset-uuid' }] };
|
||||
|
||||
async function respond(precision: Record<string, string>) {
|
||||
mockConfig.mockReturnValue({ ...configWith(), albumLocationPrecision: precision });
|
||||
mockAssetInfo.mockResolvedValue(EXIF_ASSET);
|
||||
mockGetAlbum.mockResolvedValue(carrying);
|
||||
const res = await GET(new NextRequest('http://localhost/api/exif/token'), {
|
||||
params: Promise.resolve({ id: 'token' }),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
it('names the city when nothing restricts it', async () => {
|
||||
expect(await respond({})).toMatchObject({ city: 'Vienna', country: 'Austria' });
|
||||
});
|
||||
|
||||
it('drops the city but keeps the country at country precision', async () => {
|
||||
const body = await respond({ 'album-a': 'country' });
|
||||
expect(body.country).toBe('Austria');
|
||||
expect(body).not.toHaveProperty('city');
|
||||
});
|
||||
|
||||
it('names no place at all when the album is hidden', async () => {
|
||||
const body = await respond({ 'album-a': 'hidden' });
|
||||
expect(body).not.toHaveProperty('city');
|
||||
expect(body).not.toHaveProperty('country');
|
||||
// The rest of the panel is untouched.
|
||||
expect(body.model).toBe('X-T5');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,12 @@ import { getConfig } from '@/lib/config';
|
||||
import { imageUrl } from '@/lib/urls';
|
||||
import { isAuthenticated, siteLockResponse } from '@/lib/auth';
|
||||
import { getMapData } from '@/lib/mapService';
|
||||
import {
|
||||
applyPrecision,
|
||||
placeLabel,
|
||||
strictestPrecision,
|
||||
type LocationPrecision,
|
||||
} from '@/lib/mapPrecision';
|
||||
import { ImmichUnavailableError } from '@/lib/immich';
|
||||
import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit';
|
||||
|
||||
@@ -88,11 +94,17 @@ export async function GET(request: NextRequest) {
|
||||
return result;
|
||||
};
|
||||
|
||||
const precisionOf = (albumId: string): LocationPrecision =>
|
||||
config.albumLocationPrecision[albumId] ?? 'exact';
|
||||
|
||||
const publicLocations = locations
|
||||
.map((loc) => {
|
||||
const allowedAlbums = loc.albums.filter(
|
||||
(a) => (!a.subpageSlug || isSubpageAllowed(a.subpageSlug)) && isAlbumAllowed(a.id),
|
||||
);
|
||||
const allowedAlbums = loc.albums
|
||||
.filter((a) => (!a.subpageSlug || isSubpageAllowed(a.subpageSlug)) && isAlbumAllowed(a.id))
|
||||
// `location: hidden` drops the album from the map entirely, before it
|
||||
// can contribute. Withholding only its coordinates would still let the
|
||||
// photo count and the cover name a place it asked to be absent from.
|
||||
.filter((a) => precisionOf(a.id) !== 'hidden');
|
||||
|
||||
if (allowedAlbums.length === 0) return null;
|
||||
|
||||
@@ -102,11 +114,31 @@ export async function GET(request: NextRequest) {
|
||||
const latSum = allowedAlbums.reduce((sum, a) => sum + a.latSum, 0);
|
||||
const lngSum = allowedAlbums.reduce((sum, a) => sum + a.lngSum, 0);
|
||||
|
||||
/*
|
||||
* One marker merges several albums, so the most cautious of them governs
|
||||
* the whole marker. Splitting it instead would itself announce that one
|
||||
* album is set more carefully — which is the thing being hidden.
|
||||
*
|
||||
* Quantised here, server-side: coordinates are never sent exact for the
|
||||
* client to round.
|
||||
*/
|
||||
const level = strictestPrecision(allowedAlbums.map((a) => precisionOf(a.id)));
|
||||
const position = applyPrecision(
|
||||
{ lat: latSum / photoCount, lng: lngSum / photoCount },
|
||||
level,
|
||||
);
|
||||
if (!position) return null;
|
||||
|
||||
// The name has to follow the coordinates down. Rounding the position to
|
||||
// a 100 km cell while still calling the marker "Chorin" left the label
|
||||
// more precise than the position it was meant to blur.
|
||||
const place = placeLabel(level, { city: loc.city, country: loc.country });
|
||||
|
||||
return {
|
||||
city: loc.city,
|
||||
country: loc.country,
|
||||
lat: latSum / photoCount,
|
||||
lng: lngSum / photoCount,
|
||||
city: place.city,
|
||||
country: place.country,
|
||||
lat: position.lat,
|
||||
lng: position.lng,
|
||||
photoCount,
|
||||
coverUrl: imageUrl(allowedAlbums[0].coverAssetId, 'thumbnail'),
|
||||
albums: allowedAlbums.map((a) => ({
|
||||
|
||||
@@ -1017,6 +1017,79 @@
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
/* ── Album navigation (previous / next) ── */
|
||||
|
||||
.album-nav {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: stretch;
|
||||
gap: 16px;
|
||||
max-width: var(--max-width);
|
||||
margin: 64px auto 96px;
|
||||
padding: 24px 24px 0;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.album-nav__link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
max-width: 45%;
|
||||
color: var(--text-secondary);
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.album-nav__link:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.album-nav__link--next {
|
||||
margin-left: auto;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.album-nav__link svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.album-nav__text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.album-nav__kicker {
|
||||
font-family: var(--font-caption);
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.album-nav__name {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 1.05rem;
|
||||
color: inherit;
|
||||
/* Long album names must not push the pair apart. */
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.album-nav {
|
||||
margin: 48px auto 64px;
|
||||
padding: 20px 16px 0;
|
||||
}
|
||||
|
||||
.album-nav__name {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
}
|
||||
|
||||
.album-header__title {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 2.5rem;
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
exifUrl,
|
||||
assetPlaceholder,
|
||||
assetAspectRatio,
|
||||
assetCaption,
|
||||
assetExifSummary,
|
||||
} from '@/lib/urls';
|
||||
import { encodeAssetId } from '@/lib/tokens';
|
||||
@@ -177,6 +178,7 @@ export default async function JournalDetailPage({ params }: JournalDetailPagePro
|
||||
config.exif.onHover && config.exif.camera && a.type === 'IMAGE'
|
||||
? assetExifSummary(a)
|
||||
: undefined;
|
||||
const caption = assetCaption(a, config.exif.caption);
|
||||
const isVideo = a.type === 'VIDEO';
|
||||
return {
|
||||
id: encodeAssetId(a.id),
|
||||
@@ -186,6 +188,7 @@ export default async function JournalDetailPage({ params }: JournalDetailPagePro
|
||||
exifUrl: exifUrl(a.id),
|
||||
...(ph ? { blurDataURL: ph.blurDataURL, dominantColor: ph.dominantColor } : {}),
|
||||
...(exif ?? {}),
|
||||
...(caption ? { caption } : {}),
|
||||
aspectRatio: assetAspectRatio(a),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -47,6 +47,10 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
};
|
||||
|
||||
return {
|
||||
// Without this, Next resolves Open Graph and canonical URLs against
|
||||
// localhost. Absent when nobody configured a site URL, which leaves the
|
||||
// previous behaviour rather than inventing a host.
|
||||
...(config.siteUrl ? { metadataBase: new URL(config.siteUrl) } : {}),
|
||||
title: {
|
||||
default: siteTitle,
|
||||
template: config.seo.titleTemplate,
|
||||
|
||||
41
app/robots.ts
Normal file
41
app/robots.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { MetadataRoute } from 'next';
|
||||
import { getConfigOrNull } from '@/lib/config';
|
||||
import { isSiteLocked } from '@/lib/auth';
|
||||
import { absoluteUrl } from '@/lib/siteUrl';
|
||||
|
||||
/**
|
||||
* robots.txt (#472).
|
||||
*
|
||||
* Reflects the same decision `generateMetadata()` already makes for the robots
|
||||
* meta tag, so the two cannot disagree: a site behind a password, or one that
|
||||
* asked not to be indexed, is closed to crawlers here as well.
|
||||
*/
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
const config = getConfigOrNull();
|
||||
|
||||
// Nothing configured yet — the setup screen is not worth crawling.
|
||||
if (!config) {
|
||||
return { rules: { userAgent: '*', disallow: '/' } };
|
||||
}
|
||||
|
||||
if (isSiteLocked() || config.seo.noIndex) {
|
||||
return { rules: { userAgent: '*', disallow: '/' } };
|
||||
}
|
||||
|
||||
const sitemap = absoluteUrl(config.siteUrl, '/sitemap.xml');
|
||||
|
||||
return {
|
||||
rules: {
|
||||
userAgent: '*',
|
||||
allow: '/',
|
||||
// The image proxy, the admin panel and the installer have nothing to
|
||||
// offer a crawler and plenty to cost the Immich server behind them.
|
||||
disallow: ['/api/', '/admin', '/install'],
|
||||
},
|
||||
// Omitted rather than guessed when no site URL is configured: robots.txt
|
||||
// requires an absolute sitemap URL.
|
||||
...(sitemap ? { sitemap } : {}),
|
||||
};
|
||||
}
|
||||
32
app/sitemap.ts
Normal file
32
app/sitemap.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { MetadataRoute } from 'next';
|
||||
import { getConfigOrNull } from '@/lib/config';
|
||||
import { buildSiteShape } from '@/lib/siteShape';
|
||||
import { publicPaths } from '@/lib/publicPages';
|
||||
import { absoluteUrl } from '@/lib/siteUrl';
|
||||
|
||||
/**
|
||||
* sitemap.xml (#472).
|
||||
*
|
||||
* What goes in is decided by `publicPaths()`, the same function the feed uses,
|
||||
* because a sitemap lists exactly what access control otherwise hides and a
|
||||
* second implementation of that judgement would eventually disagree with the
|
||||
* first.
|
||||
*/
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
const config = getConfigOrNull();
|
||||
|
||||
// A sitemap needs absolute URLs and this route has no request context to
|
||||
// derive a host from. Without a configured site URL there is nothing valid
|
||||
// to emit, and an empty sitemap is better than a wrong one.
|
||||
if (!config?.siteUrl) return [];
|
||||
|
||||
const shape = await buildSiteShape().catch(() => null);
|
||||
if (!shape) return [];
|
||||
|
||||
return publicPaths(shape).flatMap((path) => {
|
||||
const url = absoluteUrl(config.siteUrl, path);
|
||||
return url ? [{ url, changeFrequency: 'weekly' as const }] : [];
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user