update('legal.address', e.target.value)}
- placeholder="Musterstraße 1"
- />
+
- ZIP & City
+ ZIP & City
update('legal.zipCity', e.target.value)}
@@ -589,7 +959,7 @@ export default function SettingsEditor() {
- Extra Info
+ Additional Disclosures / Tax ID
@@ -621,42 +991,202 @@ export default function SettingsEditor() {
{activeSection === 'seo' && (
-
SEO & Metadata
+
+
Search Engine Optimization (SEO)
+
Customize search engine metadata, OpenGraph tags, and indexing rules.
+
+
+ {/* Live Google Search Result Snippet Card */}
+
+
+ Google Search Result Preview
+
+
+
+ https://yourportfolio.com ▼
+
+
+ {settings.seo?.title || settings.title || 'My Photography Portfolio'}
+
+
+ {settings.seo?.description || settings.subtitle || 'A curated selection of photography work.'}
+
+
+
+
- SEO Title
+ SEO Meta Title
update('seo.title', e.target.value)}
- placeholder="Overrides site title for search engines"
+ placeholder="Overrides default site title for Google search results"
/>
+
-
SEO Description
+
Subpage Title Template
+
update('seo.titleTemplate', e.target.value)}
+ placeholder={`%s | ${settings.seo?.title || settings.title || 'My Portfolio'}`}
+ />
+
+ Template for subpages & albums. Use %s as placeholder for the page title.
+
+ Preview: {' '}
+ {(
+ settings.seo?.titleTemplate ||
+ `%s | ${settings.seo?.title || settings.title || 'My Portfolio'}`
+ ).replace('%s', 'Landscapes')}
+
+
+
+
+ SEO Meta Description
-
-
- update('seo.noIndex', e.target.checked)}
- />
- noindex (hide from search engines)
-
-
- update('seo.noFollow', e.target.checked)}
- />
- nofollow (don't follow links)
-
+
+
+
+
+
Search Crawler Directives
+
Control how Googlebot and other web crawlers index your site.
+
+
+
update('seo.noIndex', !settings.seo?.noIndex)}
+ >
+
+ noindex (Hide from Google)
+ Instructs search engines NOT to index this site in search results
+
+
+
+
+
+
+
update('seo.noFollow', !settings.seo?.noFollow)}
+ >
+
+ nofollow (Block Link Following)
+ Instructs search engine crawlers not to follow outgoing links
+
+
+
+
+
+
+
+ )}
+
+ {activeSection === 'security' && (
+
+
+
Asset Protection & Watermark
+
Configure image protection rules, right-click prevention, and watermark overlay.
+
+
+
+
update('protection.disableRightClick', !settings.protection?.disableRightClick)}
+ >
+
+ Disable Right-Click Menu
+ Prevents context menu on portfolio images to hinder unauthorized downloads
+
+
+
+
+
+
+
update('protection.disableImageDrag', !settings.protection?.disableImageDrag)}
+ >
+
+ Disable Image Dragging
+ Prevents visitors from dragging images off the portfolio page
+
+
+
+
+
+
+
+
+
+
+
Dynamic Watermark Overlay
+
Overlay copyright branding text on Lightbox images.
+
+
+
+
update('watermark.enabled', !settings.watermark?.enabled)}
+ >
+
+ Enable Watermark
+ Overlay copyright text on portfolio image views
+
+
+
+
+
+
+
+ {settings.watermark?.enabled && (
+ <>
+
+ Watermark Text
+ update('watermark.text', e.target.value)}
+ placeholder="© Ralfo Photography"
+ />
+
+
+
+
+ Position
+ update('watermark.position', e.target.value)}
+ >
+ Bottom Right
+ Bottom Left
+ Center Overlay
+
+
+
+ Opacity ({Math.round((settings.watermark?.opacity ?? 0.3) * 100)}%)
+ update('watermark.opacity', parseFloat(e.target.value))}
+ />
+
+
+ >
+ )}
)}
diff --git a/app/api/admin/analytics/route.ts b/app/api/admin/analytics/route.ts
new file mode 100644
index 0000000..97e5e8d
--- /dev/null
+++ b/app/api/admin/analytics/route.ts
@@ -0,0 +1,50 @@
+import { NextResponse } from 'next/server';
+import fs from 'fs/promises';
+import path from 'path';
+import { getConfigOrNull } from '@/lib/config';
+
+const ANALYTICS_FILE = path.join(process.cwd(), 'content', 'analytics.json');
+
+export async function GET() {
+ try {
+ const config = getConfigOrNull();
+ let data;
+ try {
+ const raw = await fs.readFile(ANALYTICS_FILE, 'utf8');
+ data = JSON.parse(raw);
+ } catch {
+ data = { summary: { totalViews: 0, lastUpdated: new Date().toISOString() }, days: {} };
+ }
+
+ // Compute top pages across all days
+ const topPagesMap: Record
= {};
+ let desktopCount = 0;
+ let mobileCount = 0;
+
+ Object.values(data.days || {}).forEach((day: any) => {
+ Object.entries(day.pages || {}).forEach(([p, count]: [string, any]) => {
+ topPagesMap[p] = (topPagesMap[p] || 0) + count;
+ });
+ desktopCount += day.devices?.desktop || 0;
+ mobileCount += day.devices?.mobile || 0;
+ });
+
+ const topPages = Object.entries(topPagesMap)
+ .map(([path, count]) => ({ path, count }))
+ .sort((a, b) => b.count - a.count)
+ .slice(0, 10);
+
+ return NextResponse.json({
+ trackingEnabled: config?.analytics !== false,
+ summary: data.summary,
+ days: data.days,
+ topPages,
+ devices: {
+ desktop: desktopCount,
+ mobile: mobileCount,
+ },
+ });
+ } catch (err: any) {
+ return NextResponse.json({ error: err.message || 'Failed to fetch analytics' }, { status: 500 });
+ }
+}
diff --git a/app/api/admin/backups/route.ts b/app/api/admin/backups/route.ts
new file mode 100644
index 0000000..3415eed
--- /dev/null
+++ b/app/api/admin/backups/route.ts
@@ -0,0 +1,109 @@
+import { NextResponse } from 'next/server';
+import { revalidatePath } from 'next/cache';
+import { isAdminAuthenticated, isAdminEnabled } from '@/lib/admin/auth';
+import { listBackups, restoreBackup } from '@/lib/admin/yaml-service';
+import { invalidateConfigCache } from '@/lib/config';
+import { immich } from '@/lib/immich';
+
+export interface BackupItem {
+ filename: string;
+ target: 'gallery' | 'settings';
+ timestamp: string | null;
+ isPreRestore: boolean;
+}
+
+function parseBackupInfo(filename: string): BackupItem {
+ const target = filename.startsWith('gallery.yaml') ? 'gallery' : 'settings';
+ const isPreRestore = filename.includes('pre-restore');
+
+ // Extract timestamp from filename like filename.2026-05-31T17-30-00-000Z.bak
+ const match = filename.match(/\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z/);
+ let timestamp: string | null = null;
+ if (match) {
+ // Reconstruct valid ISO string: 2026-05-31T17:30:00.000Z
+ const parts = match[0].split('T');
+ const datePart = parts[0];
+ const timeParts = parts[1].replace('Z', '').split('-');
+ if (timeParts.length >= 4) {
+ timestamp = `${datePart}T${timeParts[0]}:${timeParts[1]}:${timeParts[2]}.${timeParts[3]}Z`;
+ }
+ }
+
+ return {
+ filename,
+ target,
+ timestamp,
+ isPreRestore,
+ };
+}
+
+/** GET: List all available backups for gallery and settings. */
+export async function GET() {
+ if (!isAdminEnabled()) {
+ return NextResponse.json({ error: 'Admin not enabled' }, { status: 403 });
+ }
+ if (!(await isAdminAuthenticated())) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ try {
+ const galleryRaw = await listBackups('gallery.yaml');
+ const settingsRaw = await listBackups('settings.yaml');
+
+ const gallery = galleryRaw.map(parseBackupInfo);
+ const settings = settingsRaw.map(parseBackupInfo);
+
+ return NextResponse.json({
+ backups: {
+ gallery,
+ settings,
+ },
+ });
+ } catch (err) {
+ console.error('[Admin API] Error listing backups:', err);
+ return NextResponse.json({ error: 'Failed to list backups' }, { status: 500 });
+ }
+}
+
+/** POST: Restore a specific backup file. */
+export async function POST(req: Request) {
+ if (!isAdminEnabled()) {
+ return NextResponse.json({ error: 'Admin not enabled' }, { status: 403 });
+ }
+ if (!(await isAdminAuthenticated())) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ try {
+ const body = await req.json();
+ const { backupFilename } = body || {};
+
+ if (!backupFilename || typeof backupFilename !== 'string') {
+ return NextResponse.json({ error: 'backupFilename is required' }, { status: 400 });
+ }
+
+ // Security check: prevent directory traversal
+ if (backupFilename.includes('..') || backupFilename.includes('/') || backupFilename.includes('\\')) {
+ return NextResponse.json({ error: 'Invalid backup filename' }, { status: 400 });
+ }
+
+ if (!backupFilename.endsWith('.bak')) {
+ return NextResponse.json({ error: 'Invalid backup file extension' }, { status: 400 });
+ }
+
+ await restoreBackup(backupFilename);
+
+ // Invalidate caches & revalidate pages
+ invalidateConfigCache();
+ immich.invalidateAll();
+ revalidatePath('/', 'layout');
+
+ return NextResponse.json({
+ success: true,
+ message: `Successfully restored ${backupFilename}`,
+ });
+ } catch (err) {
+ console.error('[Admin API] Error restoring backup:', err);
+ return NextResponse.json({ error: 'Failed to restore backup' }, { status: 500 });
+ }
+}
diff --git a/app/api/analytics/track/route.ts b/app/api/analytics/track/route.ts
new file mode 100644
index 0000000..935b13b
--- /dev/null
+++ b/app/api/analytics/track/route.ts
@@ -0,0 +1,75 @@
+import { NextResponse } from 'next/server';
+import fs from 'fs/promises';
+import path from 'path';
+import { getConfigOrNull } from '@/lib/config';
+
+const CONTENT_DIR = path.join(process.cwd(), 'content');
+const ANALYTICS_FILE = path.join(CONTENT_DIR, 'analytics.json');
+
+interface AnalyticsData {
+ summary: {
+ totalViews: number;
+ lastUpdated: string;
+ };
+ days: Record<
+ string,
+ {
+ pageviews: number;
+ pages: Record;
+ devices: Record;
+ }
+ >;
+}
+
+async function getAnalytics(): Promise {
+ try {
+ const raw = await fs.readFile(ANALYTICS_FILE, 'utf8');
+ return JSON.parse(raw);
+ } catch {
+ return { summary: { totalViews: 0, lastUpdated: new Date().toISOString() }, days: {} };
+ }
+}
+
+async function saveAnalytics(data: AnalyticsData) {
+ try {
+ await fs.mkdir(CONTENT_DIR, { recursive: true });
+ await fs.writeFile(ANALYTICS_FILE, JSON.stringify(data, null, 2), 'utf8');
+ } catch (err) {
+ console.error('[Analytics] Failed to save analytics data:', err);
+ }
+}
+
+export async function POST(req: Request) {
+ try {
+ const config = getConfigOrNull();
+ if (config?.analytics === false) {
+ return NextResponse.json({ ok: true, trackingDisabled: true });
+ }
+
+ const body = await req.json();
+ const pagePath = (body.path || '/').split('?')[0];
+ const userAgent = req.headers.get('user-agent') || '';
+ const isMobile = /mobile|iphone|ipad|android/i.test(userAgent);
+ const deviceType = isMobile ? 'mobile' : 'desktop';
+
+ const dateKey = new Date().toISOString().split('T')[0];
+ const data = await getAnalytics();
+
+ data.summary.totalViews = (data.summary.totalViews || 0) + 1;
+ data.summary.lastUpdated = new Date().toISOString();
+
+ if (!data.days[dateKey]) {
+ data.days[dateKey] = { pageviews: 0, pages: {}, devices: {} };
+ }
+
+ const dayObj = data.days[dateKey];
+ dayObj.pageviews = (dayObj.pageviews || 0) + 1;
+ dayObj.pages[pagePath] = (dayObj.pages[pagePath] || 0) + 1;
+ dayObj.devices[deviceType] = (dayObj.devices[deviceType] || 0) + 1;
+
+ await saveAnalytics(data);
+ return NextResponse.json({ ok: true });
+ } catch (err) {
+ return NextResponse.json({ error: 'Failed to record tracking' }, { status: 500 });
+ }
+}
diff --git a/app/layout.tsx b/app/layout.tsx
index ff78d93..637fcbd 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -17,6 +17,8 @@ import { getConfigOrNull, getGoogleFontsUrl, AppConfig } from '@/lib/config';
import { isAdminPath } from '@/lib/admin/paths';
// DevToolbarLoader is a Client Component (ssr: false is only allowed there)
import { DevToolbarLoader } from '@/components/DevToolbarLoader';
+import AssetProtection from '@/components/AssetProtection';
+import AnalyticsTracker from '@/components/AnalyticsTracker';
export async function generateMetadata(): Promise {
const config = getConfigOrNull();
@@ -33,7 +35,10 @@ export async function generateMetadata(): Promise {
};
return {
- title: siteTitle,
+ title: {
+ default: siteTitle,
+ template: config.seo.titleTemplate,
+ },
description: siteDescription,
robots,
icons: {
@@ -97,6 +102,8 @@ export default async function RootLayout({ children }: { children: React.ReactNo
);
}
+ const isAdmin = isAdminPath(pathname);
+
return (
-
- Skip to content
-
-
-
-
- Home
-
-
-
- About
-
- {config.map && (
-
- Map
-
- )}
-
-
-
-
- {children}
-
-
-
- {process.env.NODE_ENV === 'development' && }
+ {isAdmin ? (
+ children
+ ) : (
+ <>
+
+ Skip to content
+
+
+
+
+ Home
+
+
+
+ About
+
+ {config.map && (
+
+ Map
+
+ )}
+
+
+
+
+ {children}
+
+
+
+
+
+ {process.env.NODE_ENV === 'development' && }
+ >
+ )}