= ({
>
)}
+ {/* Update Notification Settings */}
+
+
{/* Last update time */}
{systemStatus && (
diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json
index faefbabd..33f8ac02 100644
--- a/frontend/src/i18n/locales/de.json
+++ b/frontend/src/i18n/locales/de.json
@@ -674,6 +674,7 @@
"searchPlaceholder": "Fotos suchen...",
"sortBy": "Sortieren nach",
"sortByDate": "Nach Datum sortieren",
+ "sortByCaptureDate": "Nach Aufnahmedatum sortieren",
"sortByName": "Nach Name sortieren",
"sortBySize": "Nach Größe sortieren",
"sortByRating": "Nach Bewertung sortieren",
@@ -1248,6 +1249,25 @@
"sent": "Gesendet",
"failed": "Fehlgeschlagen",
"lastUpdate": "Letzte Aktualisierung"
+ },
+ "updateNotifications": {
+ "title": "Update-Benachrichtigungen",
+ "description": "Erhalten Sie E-Mail-Benachrichtigungen, wenn neue Versionen von PicPeak verfügbar sind.",
+ "enableEmails": "E-Mail-Benachrichtigungen aktivieren",
+ "enableEmailsDesc": "E-Mail an Admins senden, wenn eine neue Version verfügbar ist",
+ "recipients": "E-Mail-Empfänger",
+ "recipientsPlaceholder": "admin@beispiel.de, andere@beispiel.de",
+ "recipientsHelper": "Kommagetrennte E-Mail-Adressen. Leer lassen, um an alle Admin-Benutzer zu senden.",
+ "lastNotified": "Letzte Benachrichtigung gesendet für Version: {{version}}",
+ "checkNow": "Prüfen & Benachrichtigen",
+ "sendTest": "Test-E-Mail senden",
+ "saved": "Einstellungen gespeichert",
+ "saveError": "Fehler beim Speichern der Einstellungen",
+ "emailSent": "Benachrichtigungs-E-Mail an {{count}} Empfänger gesendet",
+ "emailFailed": "Fehler beim Senden der Benachrichtigung",
+ "checkSuccess": "Benachrichtigung für neue Version gesendet",
+ "checkNoAction": "Keine Benachrichtigung erforderlich: {{reason}}",
+ "checkError": "Fehler beim Prüfen auf Updates"
}
},
"branding": {
@@ -1528,7 +1548,18 @@
"updateAvailableShort": "v{{version}} verfügbar",
"checkForUpdates": "Nach Updates suchen",
"upToDate": "Alles aktuell",
- "lastChecked": "Zuletzt geprüft: {{time}}"
+ "lastChecked": "Zuletzt geprüft: {{time}}",
+ "updateNow": "Jetzt aktualisieren",
+ "updateDialog": {
+ "title": "PicPeak aktualisieren",
+ "detectedEnv": "Erkannte Umgebung",
+ "beforeUpdating": "Vor dem Update:",
+ "updateCommands": "Update-Befehle:",
+ "afterUpdating": "Nach dem Update:",
+ "copyAllCommands": "Alle Befehle kopieren",
+ "completeChecklist": "Checkliste vor dem Update ausfüllen",
+ "error": "Fehler beim Laden der Update-Anweisungen"
+ }
},
"notifications": "Benachrichtigungen",
"viewAllNotifications": "Alle Benachrichtigungen anzeigen",
diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json
index 2b8f7c34..89e0022f 100644
--- a/frontend/src/i18n/locales/en.json
+++ b/frontend/src/i18n/locales/en.json
@@ -287,6 +287,7 @@
"searchPlaceholder": "Search photos...",
"sortBy": "Sort By",
"sortByDate": "Sort by Date",
+ "sortByCaptureDate": "Sort by Capture Date",
"sortByName": "Sort by Name",
"sortBySize": "Sort by Size",
"sortByRating": "Sort by Rating",
@@ -798,6 +799,25 @@
"failed": "Failed",
"lastUpdate": "Last update"
},
+ "updateNotifications": {
+ "title": "Update Notifications",
+ "description": "Receive email notifications when new versions of PicPeak are available.",
+ "enableEmails": "Enable email notifications",
+ "enableEmailsDesc": "Send email to admins when a new version is available",
+ "recipients": "Email Recipients",
+ "recipientsPlaceholder": "admin@example.com, other@example.com",
+ "recipientsHelper": "Comma-separated email addresses. Leave empty to send to all admin users.",
+ "lastNotified": "Last notification sent for version: {{version}}",
+ "checkNow": "Check & Notify",
+ "sendTest": "Send Test Email",
+ "saved": "Settings saved",
+ "saveError": "Failed to save settings",
+ "emailSent": "Notification email sent to {{count}} recipients",
+ "emailFailed": "Failed to send notification",
+ "checkSuccess": "Notification sent for new version",
+ "checkNoAction": "No notification needed: {{reason}}",
+ "checkError": "Failed to check for updates"
+ },
"events": {
"title": "Event Creation",
"requiredFields": "Required Fields",
@@ -1241,7 +1261,18 @@
"updateAvailableShort": "v{{version}} available",
"checkForUpdates": "Check for Updates",
"upToDate": "You're up to date",
- "lastChecked": "Last checked: {{time}}"
+ "lastChecked": "Last checked: {{time}}",
+ "updateNow": "Update Now",
+ "updateDialog": {
+ "title": "Update PicPeak",
+ "detectedEnv": "Detected Environment",
+ "beforeUpdating": "Before updating:",
+ "updateCommands": "Update Commands:",
+ "afterUpdating": "After updating:",
+ "copyAllCommands": "Copy All Commands",
+ "completeChecklist": "Complete the checklist before updating",
+ "error": "Failed to load update instructions"
+ }
},
"notifications": "Notifications",
"viewAllNotifications": "View all notifications",
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index d3affcc4..7b407662 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -81,6 +81,7 @@ export interface Photo {
category_slug?: string;
size: number;
uploaded_at: string;
+ captured_at?: string; // EXIF capture date (if available)
// Media type fields
media_type?: 'photo' | 'video' | 'image';
mime_type?: string;
diff --git a/package-lock.json b/package-lock.json
index 9212cfbc..398a3cf7 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11,6 +11,7 @@
},
"devDependencies": {
"@playwright/test": "^1.48.2",
+ "dotenv": "^17.3.1",
"puppeteer": "^24.17.0"
}
},
@@ -561,6 +562,19 @@
"license": "BSD-3-Clause",
"peer": true
},
+ "node_modules/dotenv": {
+ "version": "17.3.1",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz",
+ "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
diff --git a/package.json b/package.json
index 7c5d6c5e..cda2229b 100644
--- a/package.json
+++ b/package.json
@@ -8,8 +8,9 @@
"node-fetch": "^2.7.0"
},
"devDependencies": {
- "puppeteer": "^24.17.0",
- "@playwright/test": "^1.48.2"
+ "@playwright/test": "^1.48.2",
+ "dotenv": "^17.3.1",
+ "puppeteer": "^24.17.0"
},
"overrides": {
"prebuild-install": {
diff --git a/playwright.config.ts b/playwright.config.ts
index 3b23f837..1944ad0f 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -1,4 +1,8 @@
import { defineConfig, devices } from '@playwright/test';
+import dotenv from 'dotenv';
+
+// Load environment variables from .env file
+dotenv.config();
export default defineConfig({
testDir: 'tests/e2e',
diff --git a/tests/e2e/dark-mode.spec.ts b/tests/e2e/dark-mode.spec.ts
new file mode 100644
index 00000000..76f158a0
--- /dev/null
+++ b/tests/e2e/dark-mode.spec.ts
@@ -0,0 +1,217 @@
+import { test, expect } from '@playwright/test';
+
+const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@example.com';
+const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'Admin!234';
+
+// Helper to login to admin
+async function loginToAdmin(page) {
+ await page.goto('/admin/login');
+ await page.getByLabel(/Email|E-Mail/i).fill(ADMIN_EMAIL);
+ await page.getByLabel(/Password|Passwort/i).fill(ADMIN_PASSWORD);
+ await page.getByRole('button', { name: /Sign In|Log in|Anmelden/i }).click();
+ await expect(page.getByRole('heading', { name: /Dashboard|Übersicht/i })).toBeVisible({ timeout: 20000 });
+}
+
+test.describe('Admin Dark Mode Toggle', () => {
+ test('dark mode toggle button exists in admin header', async ({ page }, testInfo) => {
+ if (testInfo.project.name === 'mobile-chrome') {
+ test.skip('Dark mode toggle validated on desktop viewport');
+ }
+
+ await loginToAdmin(page);
+
+ // Look for the dark mode toggle button
+ const toggleButton = page.getByRole('button', { name: /dark mode|light mode|Dunkelmodus|Hellmodus/i });
+ await expect(toggleButton).toBeVisible();
+ });
+
+ test('clicking toggle switches to dark mode and adds dark class', async ({ page }, testInfo) => {
+ if (testInfo.project.name === 'mobile-chrome') {
+ test.skip('Dark mode toggle validated on desktop viewport');
+ }
+
+ await loginToAdmin(page);
+
+ // Check initial state - should be light
+ const html = page.locator('html');
+ const initialHasDark = await html.evaluate(el => el.classList.contains('dark'));
+
+ // Click the toggle
+ const toggleButton = page.getByRole('button', { name: /dark mode|light mode|Dunkelmodus|Hellmodus/i });
+ await toggleButton.click();
+
+ // Wait a moment for the class to toggle
+ await page.waitForTimeout(500);
+
+ // Verify dark class toggled
+ const afterHasDark = await html.evaluate(el => el.classList.contains('dark'));
+ expect(afterHasDark).toBe(!initialHasDark);
+
+ // Click again to revert
+ await toggleButton.click();
+ await page.waitForTimeout(500);
+
+ const finalHasDark = await html.evaluate(el => el.classList.contains('dark'));
+ expect(finalHasDark).toBe(initialHasDark);
+ });
+
+ test('dark mode preference persists across page reloads', async ({ page }, testInfo) => {
+ if (testInfo.project.name === 'mobile-chrome') {
+ test.skip('Dark mode toggle validated on desktop viewport');
+ }
+
+ await loginToAdmin(page);
+
+ // Set to dark mode
+ const html = page.locator('html');
+ const isAlreadyDark = await html.evaluate(el => el.classList.contains('dark'));
+
+ if (!isAlreadyDark) {
+ const toggleButton = page.getByRole('button', { name: /dark mode|Dunkelmodus/i });
+ await toggleButton.click();
+ await page.waitForTimeout(500);
+ }
+
+ // Verify dark mode is active
+ await expect(html).toHaveAttribute('class', /dark/);
+
+ // Reload the page
+ await page.reload();
+ await expect(page.getByRole('heading', { name: /Dashboard|Übersicht/i })).toBeVisible({ timeout: 20000 });
+
+ // Verify dark mode persists
+ await expect(html).toHaveAttribute('class', /dark/);
+
+ // Clean up - switch back to light mode
+ const toggleButton = page.getByRole('button', { name: /light mode|Hellmodus/i });
+ await toggleButton.click();
+ await page.waitForTimeout(500);
+ });
+
+ test('dark mode applies correct dark background to admin layout', async ({ page }, testInfo) => {
+ if (testInfo.project.name === 'mobile-chrome') {
+ test.skip('Dark mode toggle validated on desktop viewport');
+ }
+
+ await loginToAdmin(page);
+
+ // Enable dark mode
+ const html = page.locator('html');
+ const isAlreadyDark = await html.evaluate(el => el.classList.contains('dark'));
+
+ if (!isAlreadyDark) {
+ const toggleButton = page.getByRole('button', { name: /dark mode|Dunkelmodus/i });
+ await toggleButton.click();
+ await page.waitForTimeout(500);
+ }
+
+ // Verify the main layout container has dark background
+ const mainContainer = page.locator('.h-screen.bg-neutral-50, .h-screen.dark\\:bg-neutral-950').first();
+ const bgColor = await mainContainer.evaluate(el => getComputedStyle(el).backgroundColor);
+
+ // In dark mode, background should be very dark (close to black)
+ // neutral-950 is approximately rgb(10, 10, 10)
+ expect(bgColor).not.toBe('rgb(255, 255, 255)'); // Not white
+ expect(bgColor).not.toBe('rgba(0, 0, 0, 0)'); // Not transparent
+
+ // Clean up
+ const toggleButton = page.getByRole('button', { name: /light mode|Hellmodus/i });
+ await toggleButton.click();
+ await page.waitForTimeout(500);
+ });
+
+ test('dark mode preference is stored in localStorage', async ({ page }, testInfo) => {
+ if (testInfo.project.name === 'mobile-chrome') {
+ test.skip('Dark mode toggle validated on desktop viewport');
+ }
+
+ await loginToAdmin(page);
+
+ // Enable dark mode
+ const html = page.locator('html');
+ const isAlreadyDark = await html.evaluate(el => el.classList.contains('dark'));
+
+ if (!isAlreadyDark) {
+ const toggleButton = page.getByRole('button', { name: /dark mode|Dunkelmodus/i });
+ await toggleButton.click();
+ await page.waitForTimeout(500);
+ }
+
+ // Verify dark mode preference is stored in localStorage
+ const storedPreference = await page.evaluate(() => localStorage.getItem('admin-dark-mode'));
+ expect(storedPreference).toBe('dark');
+
+ // Clean up - toggle back to light
+ const toggleButton = page.getByRole('button', { name: /light mode|Hellmodus/i });
+ await toggleButton.click();
+ await page.waitForTimeout(500);
+
+ // Verify light mode preference is stored
+ const lightPreference = await page.evaluate(() => localStorage.getItem('admin-dark-mode'));
+ expect(lightPreference).toBe('light');
+ });
+});
+
+test.describe('Settings Page Dark Mode', () => {
+ test('settings page tabs render correctly in dark mode', async ({ page }, testInfo) => {
+ if (testInfo.project.name === 'mobile-chrome') {
+ test.skip('Settings dark mode validated on desktop viewport');
+ }
+
+ await loginToAdmin(page);
+
+ // Enable dark mode
+ const html = page.locator('html');
+ const isAlreadyDark = await html.evaluate(el => el.classList.contains('dark'));
+
+ if (!isAlreadyDark) {
+ const toggleButton = page.getByRole('button', { name: /dark mode|Dunkelmodus/i });
+ await toggleButton.click();
+ await page.waitForTimeout(500);
+ }
+
+ // Go to settings
+ await page.goto('/admin/settings');
+ await expect(page.getByRole('heading', { name: /Settings|Einstellungen/i })).toBeVisible({ timeout: 10000 });
+
+ // Verify settings heading has dark text style
+ const heading = page.getByRole('heading', { name: /Settings|Einstellungen/i }).first();
+ const headingColor = await heading.evaluate(el => getComputedStyle(el).color);
+
+ // In dark mode, text should be light (not dark)
+ // neutral-100 is approximately rgb(245, 245, 245)
+ const [r, g, b] = headingColor.match(/\d+/g).map(Number);
+ expect(r + g + b).toBeGreaterThan(500); // Light colored text
+
+ // Verify tab buttons are visible (use exact: true to avoid matching save buttons)
+ await expect(page.getByRole('button', { name: 'General', exact: true })).toBeVisible();
+ await expect(page.getByRole('button', { name: /^SEO & Robots$|^SEO$/ })).toBeVisible();
+ await expect(page.getByRole('button', { name: 'Security', exact: true })).toBeVisible();
+
+ // Clean up
+ const toggleButton = page.getByRole('button', { name: /light mode|Hellmodus/i });
+ await toggleButton.click();
+ await page.waitForTimeout(500);
+ });
+});
+
+test.describe('Gallery Theme Color Mode', () => {
+ test('branding page has color mode selector', async ({ page }, testInfo) => {
+ if (testInfo.project.name === 'mobile-chrome') {
+ test.skip('Theme customizer validated on desktop viewport');
+ }
+
+ await loginToAdmin(page);
+
+ await page.goto('/admin/branding');
+ await expect(page.getByText(/Theme|Themen/i).first()).toBeVisible({ timeout: 10000 });
+
+ // Look for the color mode selector
+ await expect(page.getByText(/Color Mode|Farbmodus/i)).toBeVisible();
+
+ // Verify the mode buttons exist
+ await expect(page.getByRole('button', { name: /^Light$|^Hell$/i })).toBeVisible();
+ await expect(page.getByRole('button', { name: /^Dark$|^Dunkel$/i })).toBeVisible();
+ await expect(page.getByRole('button', { name: /^Auto$/i })).toBeVisible();
+ });
+});
diff --git a/tests/e2e/seo-settings.spec.ts b/tests/e2e/seo-settings.spec.ts
new file mode 100644
index 00000000..f5222921
--- /dev/null
+++ b/tests/e2e/seo-settings.spec.ts
@@ -0,0 +1,158 @@
+import { test, expect } from '@playwright/test';
+
+const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@example.com';
+const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'Admin!234';
+
+// Helper to login and navigate to settings
+async function loginAndGoToSeoSettings(page) {
+ await page.goto('/admin/login');
+ await page.getByLabel(/Email|E-Mail/i).fill(ADMIN_EMAIL);
+ await page.getByLabel(/Password|Passwort/i).fill(ADMIN_PASSWORD);
+ await page.getByRole('button', { name: /Sign In|Log in|Anmelden/i }).click();
+ await expect(page.getByRole('heading', { name: /Dashboard|Übersicht/i })).toBeVisible({ timeout: 20000 });
+
+ await page.goto('/admin/settings');
+ // Click on SEO tab
+ const seoTab = page.getByRole('button', { name: /SEO|Robots/i });
+ await seoTab.click();
+ await expect(page.getByRole('heading', { name: /Search Engine Indexing|Suchmaschinen-Indexierung/i })).toBeVisible({ timeout: 10000 });
+}
+
+test.describe('SEO Settings Tab', () => {
+ test('SEO tab renders all sections correctly', async ({ page }, testInfo) => {
+ if (testInfo.project.name === 'mobile-chrome') {
+ test.skip('SEO settings UI validated on desktop viewport');
+ }
+
+ await loginAndGoToSeoSettings(page);
+
+ // Verify all three cards are visible
+ await expect(page.getByRole('heading', { name: /Search Engine Indexing|Suchmaschinen-Indexierung/i })).toBeVisible();
+ await expect(page.getByRole('heading', { name: /AI & Bot Blocking|KI- & Bot-Blockierung/i })).toBeVisible();
+ await expect(page.getByRole('heading', { name: /Meta Tags|Meta-Tags/i })).toBeVisible();
+
+ // Verify key form elements
+ await expect(page.getByLabel(/Allow search engine indexing|Suchmaschinen-Indexierung erlauben/i)).toBeVisible();
+ await expect(page.getByLabel(/Block AI\/LLM crawlers|KI-\/LLM-Crawler blockieren/i)).toBeVisible();
+ await expect(page.getByLabel(/Add noindex meta tag|noindex-Meta-Tag hinzufügen/i)).toBeVisible();
+ });
+
+ test('can toggle indexing and save settings', async ({ page }, testInfo) => {
+ if (testInfo.project.name === 'mobile-chrome') {
+ test.skip('SEO settings UI validated on desktop viewport');
+ }
+
+ await loginAndGoToSeoSettings(page);
+
+ const indexingToggle = page.getByLabel(/Allow search engine indexing|Suchmaschinen-Indexierung erlauben/i);
+ const initialState = await indexingToggle.isChecked();
+
+ // Toggle the setting
+ await indexingToggle.click();
+
+ // Save
+ const saveButton = page.getByRole('button', { name: /Save SEO Settings|SEO-Einstellungen speichern/i });
+ await saveButton.click();
+
+ // Wait for success toast
+ await expect(page.locator('.Toastify__toast--success')).toBeVisible({ timeout: 10000 });
+
+ // Verify toggle state changed
+ const newState = await indexingToggle.isChecked();
+ expect(newState).toBe(!initialState);
+
+ // Revert to original state
+ await indexingToggle.click();
+ await saveButton.click();
+ await expect(page.locator('.Toastify__toast--success')).toBeVisible({ timeout: 10000 });
+ });
+
+ test('robots.txt preview updates based on settings', async ({ page }, testInfo) => {
+ if (testInfo.project.name === 'mobile-chrome') {
+ test.skip('SEO settings UI validated on desktop viewport');
+ }
+
+ await loginAndGoToSeoSettings(page);
+
+ // Click show preview button
+ const previewButton = page.getByRole('button', { name: /Show robots\.txt preview|robots\.txt-Vorschau anzeigen/i });
+ await previewButton.click();
+
+ // Verify preview content is visible
+ const previewContent = page.locator('pre');
+ await expect(previewContent).toBeVisible();
+
+ // Verify it contains expected content
+ const previewText = await previewContent.textContent();
+ expect(previewText).toContain('User-agent');
+ expect(previewText).toContain('Disallow');
+ });
+
+ test('can add blocked AI agents', async ({ page }, testInfo) => {
+ if (testInfo.project.name === 'mobile-chrome') {
+ test.skip('SEO settings UI validated on desktop viewport');
+ }
+
+ await loginAndGoToSeoSettings(page);
+
+ // Find the blocked agents section
+ const agentInput = page.getByPlaceholder(/Enter agent name|Agentenname eingeben/i);
+ await expect(agentInput).toBeVisible();
+
+ // Add a new agent
+ const testAgent = 'TestBot-' + Date.now();
+ await agentInput.fill(testAgent);
+ await agentInput.press('Enter');
+
+ // Verify the agent tag appears in the list
+ await expect(page.getByText(testAgent)).toBeVisible();
+
+ // Save and verify it persists
+ const saveButton = page.getByRole('button', { name: /Save SEO Settings|SEO-Einstellungen speichern/i });
+ await saveButton.click();
+
+ // Wait for success toast
+ await expect(page.locator('.Toastify__toast--success')).toBeVisible({ timeout: 10000 });
+
+ // Refresh and verify it's still there
+ await page.reload();
+ await page.getByRole('button', { name: /SEO|Robots/i }).click();
+ await expect(page.getByText(testAgent)).toBeVisible({ timeout: 10000 });
+ });
+});
+
+test.describe('robots.txt Endpoint', () => {
+ test('returns valid robots.txt from backend', async ({ request }) => {
+ const response = await request.get('/robots.txt');
+
+ expect(response.status()).toBe(200);
+ expect(response.headers()['content-type']).toContain('text/plain');
+
+ const body = await response.text();
+ expect(body).toContain('User-agent');
+ expect(body).toContain('Disallow: /admin');
+ expect(body).toContain('Disallow: /api');
+ });
+
+ test('robots.txt blocks AI crawlers by default', async ({ request }) => {
+ const response = await request.get('/robots.txt');
+ const body = await response.text();
+
+ // Check for some of the default blocked AI agents
+ expect(body).toContain('GPTBot');
+ expect(body).toContain('Claude-Web');
+ expect(body).toContain('Google-Extended');
+ });
+});
+
+test.describe('SEO Meta Tags', () => {
+ test('public settings include SEO meta flags', async ({ request }) => {
+ const response = await request.get('/api/public/settings');
+ expect(response.status()).toBe(200);
+
+ const settings = await response.json();
+ expect(settings).toHaveProperty('seo_meta_noindex');
+ expect(settings).toHaveProperty('seo_meta_nofollow');
+ expect(settings).toHaveProperty('seo_meta_noai');
+ });
+});