Merge pull request #185 from the-luap/feat/new-features
feat: original filename in admin UI, update dialog, and security hardening
This commit is contained in:
+4
-3
@@ -1,6 +1,7 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
server_tokens off;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
@@ -21,9 +22,9 @@ server {
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "no-referrer-when-downgrade" always;
|
||||
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline' 'unsafe-eval'" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://www.google.com https://www.gstatic.com; style-src 'self' 'unsafe-inline' https:; img-src 'self' data: https: blob:; connect-src 'self' https://www.google.com https://www.gstatic.com; font-src 'self' https: data:; object-src 'none'; media-src 'self'; frame-src 'self' https://www.google.com" always;
|
||||
|
||||
# Health check endpoint
|
||||
location /health {
|
||||
|
||||
@@ -287,6 +287,11 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
||||
<p className="text-white text-xs font-medium truncate mb-1">
|
||||
{photo.filename}
|
||||
</p>
|
||||
{photo.original_filename && photo.original_filename !== photo.filename && (
|
||||
<p className="text-white/60 text-[10px] truncate mb-1">
|
||||
Original: {photo.original_filename}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-white/80 text-xs mb-2">
|
||||
{photosService.formatBytes(photo.size)}
|
||||
</p>
|
||||
|
||||
@@ -230,7 +230,11 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="lg:w-80 bg-neutral-900 rounded-lg p-6 overflow-y-auto">
|
||||
<h3 className="text-white font-medium text-lg mb-4">{currentPhoto.filename}</h3>
|
||||
<h3 className="text-white font-medium text-lg">{currentPhoto.filename}</h3>
|
||||
{currentPhoto.original_filename && currentPhoto.original_filename !== currentPhoto.filename && (
|
||||
<p className="text-neutral-400 text-sm">Original: {currentPhoto.original_filename}</p>
|
||||
)}
|
||||
<div className="mb-4" />
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 mb-6">
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
X,
|
||||
ExternalLink,
|
||||
Copy,
|
||||
Check,
|
||||
AlertTriangle,
|
||||
Server,
|
||||
Terminal,
|
||||
CheckCircle2,
|
||||
Circle
|
||||
} from 'lucide-react';
|
||||
import { api } from '../../config/api';
|
||||
|
||||
interface UpdateStep {
|
||||
description: string;
|
||||
command: string;
|
||||
note?: string;
|
||||
optional?: boolean;
|
||||
}
|
||||
|
||||
interface PreCheck {
|
||||
id: string;
|
||||
text: string;
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
interface UpdateInstructions {
|
||||
environmentName: string;
|
||||
preChecks: PreCheck[];
|
||||
steps: UpdateStep[];
|
||||
postChecks: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
interface Environment {
|
||||
type: 'docker' | 'git' | 'standalone';
|
||||
isDocker: boolean;
|
||||
isGit: boolean;
|
||||
hasDockerCompose: boolean;
|
||||
platform: string;
|
||||
nodeVersion: string;
|
||||
appVersion: string;
|
||||
}
|
||||
|
||||
interface UpdateInstructionsResponse {
|
||||
enabled?: boolean;
|
||||
updateAvailable: boolean;
|
||||
currentVersion: string;
|
||||
targetVersion?: string;
|
||||
channel?: string;
|
||||
environment?: Environment;
|
||||
instructions?: UpdateInstructions;
|
||||
releaseNotesUrl?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
async function fetchUpdateInstructions(): Promise<UpdateInstructionsResponse> {
|
||||
const response = await api.get<UpdateInstructionsResponse>('/admin/system/updates/instructions');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
interface UpdateInstructionsDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
targetVersion?: string;
|
||||
}
|
||||
|
||||
export const UpdateInstructionsDialog: React.FC<UpdateInstructionsDialogProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
targetVersion
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [checkedItems, setCheckedItems] = useState<Set<string>>(new Set());
|
||||
const [copiedCommand, setCopiedCommand] = useState<string | null>(null);
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['update-instructions'],
|
||||
queryFn: fetchUpdateInstructions,
|
||||
enabled: isOpen,
|
||||
staleTime: 5 * 60 * 1000 // 5 minutes
|
||||
});
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleCheckItem = (id: string) => {
|
||||
const newChecked = new Set(checkedItems);
|
||||
if (newChecked.has(id)) {
|
||||
newChecked.delete(id);
|
||||
} else {
|
||||
newChecked.add(id);
|
||||
}
|
||||
setCheckedItems(newChecked);
|
||||
};
|
||||
|
||||
const copyToClipboard = async (command: string, id: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(command);
|
||||
setCopiedCommand(id);
|
||||
setTimeout(() => setCopiedCommand(null), 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const copyAllCommands = async () => {
|
||||
if (!data?.instructions?.steps) return;
|
||||
const allCommands = data.instructions.steps
|
||||
.filter(step => !step.command.startsWith('#'))
|
||||
.map(step => step.command)
|
||||
.join('\n');
|
||||
try {
|
||||
await navigator.clipboard.writeText(allCommands);
|
||||
setCopiedCommand('all');
|
||||
setTimeout(() => setCopiedCommand(null), 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const requiredChecks = data?.instructions?.preChecks.filter(c => c.required) || [];
|
||||
const allRequiredChecked = requiredChecks.every(check => checkedItems.has(check.id));
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||
<div className="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 transition-opacity bg-gray-500 bg-opacity-75 dark:bg-gray-900 dark:bg-opacity-75"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Dialog */}
|
||||
<div className="inline-block w-full max-w-2xl my-8 overflow-hidden text-left align-middle transition-all transform bg-white dark:bg-gray-800 rounded-lg shadow-xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{t('admin.updates.updateDialog.title', 'Update PicPeak')}
|
||||
{data?.targetVersion && (
|
||||
<span className="ml-2 text-blue-600 dark:text-blue-400">
|
||||
v{data.targetVersion}
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-500 dark:hover:text-gray-300"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-6 py-4 max-h-[70vh] overflow-y-auto">
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center p-4 bg-red-50 dark:bg-red-900/30 rounded-lg">
|
||||
<AlertTriangle className="w-5 h-5 text-red-500 mr-3" />
|
||||
<p className="text-red-700 dark:text-red-300">
|
||||
{t('admin.updates.updateDialog.error', 'Failed to load update instructions')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && !data.updateAvailable && (
|
||||
<div className="flex items-center p-4 bg-green-50 dark:bg-green-900/30 rounded-lg">
|
||||
<CheckCircle2 className="w-5 h-5 text-green-500 mr-3" />
|
||||
<p className="text-green-700 dark:text-green-300">
|
||||
{t('admin.updates.upToDate', "You're up to date")} (v{data.currentVersion})
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data?.instructions && (
|
||||
<div className="space-y-6">
|
||||
{/* Environment Info */}
|
||||
<div className="flex items-center p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<Server className="w-5 h-5 text-gray-500 dark:text-gray-400 mr-3" />
|
||||
<span className="text-sm text-gray-600 dark:text-gray-300">
|
||||
{t('admin.updates.updateDialog.detectedEnv', 'Detected Environment')}:{' '}
|
||||
<strong>{data.instructions.environmentName}</strong>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Warnings */}
|
||||
{data.instructions.warnings.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{data.instructions.warnings.map((warning, idx) => (
|
||||
<div key={idx} className="flex items-start p-3 bg-amber-50 dark:bg-amber-900/30 rounded-lg">
|
||||
<AlertTriangle className="w-5 h-5 text-amber-500 mr-3 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-amber-700 dark:text-amber-300">{warning}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pre-flight Checklist */}
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-3 flex items-center">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-500 mr-2" />
|
||||
{t('admin.updates.updateDialog.beforeUpdating', 'Before updating:')}
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{data.instructions.preChecks.map((check) => (
|
||||
<label
|
||||
key={check.id}
|
||||
className="flex items-center p-2 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checkedItems.has(check.id)}
|
||||
onChange={() => handleCheckItem(check.id)}
|
||||
className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<span className="ml-3 text-sm text-gray-700 dark:text-gray-300">
|
||||
{check.text}
|
||||
{check.required && (
|
||||
<span className="text-red-500 ml-1">*</span>
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<hr className="border-gray-200 dark:border-gray-700" />
|
||||
|
||||
{/* Update Commands */}
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-3 flex items-center">
|
||||
<Terminal className="w-4 h-4 text-blue-500 mr-2" />
|
||||
{t('admin.updates.updateDialog.updateCommands', 'Update Commands:')}
|
||||
</h4>
|
||||
<div className="space-y-4">
|
||||
{data.instructions.steps.map((step, idx) => (
|
||||
<div key={idx} className={`${step.optional ? 'opacity-75' : ''}`}>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{idx + 1}. {step.description}
|
||||
{step.optional && (
|
||||
<span className="ml-2 text-xs text-gray-400">
|
||||
({t('common.optional', 'optional')})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center bg-gray-900 dark:bg-gray-950 rounded-lg overflow-hidden">
|
||||
<code className="flex-1 px-4 py-3 text-sm text-green-400 font-mono overflow-x-auto">
|
||||
{step.command}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => copyToClipboard(step.command, `step-${idx}`)}
|
||||
className="px-3 py-3 text-gray-400 hover:text-white border-l border-gray-700"
|
||||
title={t('common.copy', 'Copy')}
|
||||
>
|
||||
{copiedCommand === `step-${idx}` ? (
|
||||
<Check className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<Copy className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{step.note && (
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{step.note}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<hr className="border-gray-200 dark:border-gray-700" />
|
||||
|
||||
{/* Post-update Checks */}
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-3 flex items-center">
|
||||
<CheckCircle2 className="w-4 h-4 text-green-500 mr-2" />
|
||||
{t('admin.updates.updateDialog.afterUpdating', 'After updating:')}
|
||||
</h4>
|
||||
<ul className="space-y-2">
|
||||
{data.instructions.postChecks.map((check, idx) => (
|
||||
<li key={idx} className="flex items-center text-sm text-gray-600 dark:text-gray-400">
|
||||
<Circle className="w-2 h-2 mr-3 flex-shrink-0" />
|
||||
{check}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Release Notes Link */}
|
||||
{data.releaseNotesUrl && (
|
||||
<a
|
||||
href={data.releaseNotesUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center text-sm text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4 mr-2" />
|
||||
{t('admin.updates.viewReleaseNotes', 'View Release Notes')}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{!allRequiredChecked && data?.instructions && (
|
||||
<span className="text-amber-600 dark:text-amber-400">
|
||||
{t('admin.updates.updateDialog.completeChecklist', 'Complete the checklist before updating')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
{data?.instructions && (
|
||||
<button
|
||||
onClick={copyAllCommands}
|
||||
className="inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600"
|
||||
>
|
||||
{copiedCommand === 'all' ? (
|
||||
<>
|
||||
<Check className="w-4 h-4 mr-2 text-green-500" />
|
||||
{t('common.copied', 'Copied!')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="w-4 h-4 mr-2" />
|
||||
{t('admin.updates.updateDialog.copyAllCommands', 'Copy All Commands')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
{t('common.close', 'Close')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ArrowUpCircle, X, ExternalLink } from 'lucide-react';
|
||||
import { ArrowUpCircle, X, ExternalLink, Wrench } from 'lucide-react';
|
||||
import { api } from '../../config/api';
|
||||
import { UpdateInstructionsDialog } from './UpdateInstructionsDialog';
|
||||
|
||||
interface UpdateInfo {
|
||||
enabled: boolean;
|
||||
@@ -32,6 +33,7 @@ interface UpdateNotificationProps {
|
||||
export const UpdateNotification: React.FC<UpdateNotificationProps> = ({ onDismiss }) => {
|
||||
const { t } = useTranslation();
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
const [showInstructions, setShowInstructions] = useState(false);
|
||||
|
||||
const { data: updateInfo } = useQuery({
|
||||
queryKey: ['update-check'],
|
||||
@@ -79,15 +81,24 @@ export const UpdateNotification: React.FC<UpdateNotificationProps> = ({ onDismis
|
||||
channel: channelLabel
|
||||
})}
|
||||
</p>
|
||||
<a
|
||||
href="https://github.com/the-luap/picpeak/releases"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center text-xs text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 mt-2"
|
||||
>
|
||||
{t('admin.updates.viewReleaseNotes', 'View Release Notes')}
|
||||
<ExternalLink className="w-3 h-3 ml-1" />
|
||||
</a>
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
<button
|
||||
onClick={() => setShowInstructions(true)}
|
||||
className="inline-flex items-center text-xs font-medium text-white bg-blue-600 hover:bg-blue-700 px-3 py-1.5 rounded-md transition-colors"
|
||||
>
|
||||
<Wrench className="w-3 h-3 mr-1.5" />
|
||||
{t('admin.updates.updateNow', 'Update Now')}
|
||||
</button>
|
||||
<a
|
||||
href="https://github.com/the-luap/picpeak/releases"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center text-xs text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300"
|
||||
>
|
||||
{t('admin.updates.viewReleaseNotes', 'View Release Notes')}
|
||||
<ExternalLink className="w-3 h-3 ml-1" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@@ -98,6 +109,13 @@ export const UpdateNotification: React.FC<UpdateNotificationProps> = ({ onDismis
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Update Instructions Dialog */}
|
||||
<UpdateInstructionsDialog
|
||||
isOpen={showInstructions}
|
||||
onClose={() => setShowInstructions(false)}
|
||||
targetVersion={updateInfo?.latest?.forChannel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { X, Download, Filter, SortAsc, Search, Calendar, Type, HardDrive, Check, Star, Upload } from 'lucide-react';
|
||||
import { X, Download, Filter, SortAsc, Search, Calendar, Type, HardDrive, Check, Star, Upload, Camera } from 'lucide-react';
|
||||
import { Button } from '../common';
|
||||
import { PhotoCategory } from '../../types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -13,8 +13,8 @@ interface GallerySidebarProps {
|
||||
onCategoryChange: (categoryId: number | string | null) => void;
|
||||
searchTerm: string;
|
||||
onSearchChange: (term: string) => void;
|
||||
sortBy: 'date' | 'name' | 'size' | 'rating';
|
||||
onSortChange: (sort: 'date' | 'name' | 'size' | 'rating') => void;
|
||||
sortBy: 'date' | 'name' | 'size' | 'rating' | 'capture_date';
|
||||
onSortChange: (sort: 'date' | 'name' | 'size' | 'rating' | 'capture_date') => void;
|
||||
isSelectionMode: boolean;
|
||||
onToggleSelectionMode: () => void;
|
||||
selectedCount: number;
|
||||
@@ -101,6 +101,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
||||
|
||||
const sortOptions = [
|
||||
{ value: 'date', label: t('gallery.sortByDate'), icon: Calendar },
|
||||
{ value: 'capture_date', label: t('gallery.sortByCaptureDate', 'Capture Date'), icon: Camera },
|
||||
{ value: 'name', label: t('gallery.sortByName'), icon: Type },
|
||||
{ value: 'size', label: t('gallery.sortBySize'), icon: HardDrive },
|
||||
{ value: 'rating', label: t('gallery.sortByRating', 'Rating'), icon: Star }
|
||||
|
||||
@@ -47,7 +47,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
const { setTheme, theme } = useTheme();
|
||||
const [selectedCategoryId, setSelectedCategoryId] = useState<number | string | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [sortBy, setSortBy] = useState<'date' | 'name' | 'size' | 'rating'>('date');
|
||||
const [sortBy, setSortBy] = useState<'date' | 'name' | 'size' | 'rating' | 'capture_date'>('date');
|
||||
const [brandingSettings, setBrandingSettings] = useState<any>(null);
|
||||
const [showUploadModal, setShowUploadModal] = useState(false);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
@@ -397,6 +397,11 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
}
|
||||
// If ratings are equal, sort by comment count
|
||||
return (b.comment_count || 0) - (a.comment_count || 0);
|
||||
case 'capture_date':
|
||||
// Sort by capture date (from EXIF), fall back to upload date
|
||||
const captureDateA = a.captured_at || a.uploaded_at;
|
||||
const captureDateB = b.captured_at || b.uploaded_at;
|
||||
return new Date(captureDateB).getTime() - new Date(captureDateA).getTime();
|
||||
case 'date':
|
||||
default:
|
||||
return new Date(b.uploaded_at).getTime() - new Date(a.uploaded_at).getTime();
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import React from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Bell, Save, Mail, Send, RefreshCw } from 'lucide-react';
|
||||
import { Card, Button, Input } from '../../../components/common';
|
||||
import { api } from '../../../config/api';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
interface UpdateNotificationSettingsData {
|
||||
enabled: boolean;
|
||||
recipients: string;
|
||||
lastNotifiedVersion: string;
|
||||
}
|
||||
|
||||
async function fetchNotificationSettings(): Promise<UpdateNotificationSettingsData> {
|
||||
const response = await api.get<UpdateNotificationSettingsData>('/admin/system/updates/notifications');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function updateNotificationSettings(data: Partial<UpdateNotificationSettingsData>): Promise<UpdateNotificationSettingsData> {
|
||||
const response = await api.put<{ success: boolean; settings: UpdateNotificationSettingsData }>(
|
||||
'/admin/system/updates/notifications',
|
||||
data
|
||||
);
|
||||
return response.data.settings;
|
||||
}
|
||||
|
||||
async function sendTestNotification(): Promise<{ success: boolean; message?: string; successCount?: number }> {
|
||||
const response = await api.post('/admin/system/updates/notifications/send');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function checkForNotifications(): Promise<{ notified: boolean; reason?: string }> {
|
||||
const response = await api.post('/admin/system/updates/notifications/check');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export const UpdateNotificationSettings: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: settings, isLoading } = useQuery({
|
||||
queryKey: ['update-notification-settings'],
|
||||
queryFn: fetchNotificationSettings
|
||||
});
|
||||
|
||||
const [localEnabled, setLocalEnabled] = React.useState<boolean>(false);
|
||||
const [localRecipients, setLocalRecipients] = React.useState<string>('');
|
||||
const [isDirty, setIsDirty] = React.useState(false);
|
||||
|
||||
// Sync local state when data is loaded
|
||||
React.useEffect(() => {
|
||||
if (settings && !isDirty) {
|
||||
setLocalEnabled(settings.enabled);
|
||||
setLocalRecipients(settings.recipients || '');
|
||||
}
|
||||
}, [settings, isDirty]);
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: updateNotificationSettings,
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(['update-notification-settings'], data);
|
||||
setIsDirty(false);
|
||||
toast.success(t('settings.updateNotifications.saved', 'Settings saved'));
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t('settings.updateNotifications.saveError', 'Failed to save settings'));
|
||||
}
|
||||
});
|
||||
|
||||
const sendMutation = useMutation({
|
||||
mutationFn: sendTestNotification,
|
||||
onSuccess: (data) => {
|
||||
if (data.success) {
|
||||
toast.success(
|
||||
t('settings.updateNotifications.emailSent', 'Notification email sent to {{count}} recipients', {
|
||||
count: data.successCount || 0
|
||||
})
|
||||
);
|
||||
} else {
|
||||
toast.error(data.message || t('settings.updateNotifications.emailFailed', 'Failed to send notification'));
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t('settings.updateNotifications.emailFailed', 'Failed to send notification'));
|
||||
}
|
||||
});
|
||||
|
||||
const checkMutation = useMutation({
|
||||
mutationFn: checkForNotifications,
|
||||
onSuccess: (data) => {
|
||||
if (data.notified) {
|
||||
toast.success(t('settings.updateNotifications.checkSuccess', 'Notification sent for new version'));
|
||||
} else {
|
||||
toast.success(
|
||||
t('settings.updateNotifications.checkNoAction', 'No notification needed: {{reason}}', {
|
||||
reason: data.reason || 'unknown'
|
||||
})
|
||||
);
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t('settings.updateNotifications.checkError', 'Failed to check for updates'));
|
||||
}
|
||||
});
|
||||
|
||||
const handleSave = () => {
|
||||
updateMutation.mutate({
|
||||
enabled: localEnabled,
|
||||
recipients: localRecipients
|
||||
});
|
||||
};
|
||||
|
||||
const handleToggleEnabled = (value: boolean) => {
|
||||
setLocalEnabled(value);
|
||||
setIsDirty(true);
|
||||
};
|
||||
|
||||
const handleRecipientsChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setLocalRecipients(e.target.value);
|
||||
setIsDirty(true);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card padding="md">
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-6 bg-neutral-200 dark:bg-neutral-700 rounded w-1/3"></div>
|
||||
<div className="h-10 bg-neutral-200 dark:bg-neutral-700 rounded"></div>
|
||||
<div className="h-10 bg-neutral-200 dark:bg-neutral-700 rounded"></div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card padding="md">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4 flex items-center gap-2">
|
||||
<Bell className="w-5 h-5" />
|
||||
{t('settings.updateNotifications.title', 'Update Notifications')}
|
||||
</h2>
|
||||
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
|
||||
{t('settings.updateNotifications.description', 'Receive email notifications when new versions of PicPeak are available.')}
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Enable/Disable Toggle */}
|
||||
<label className="flex items-center gap-3 p-4 bg-neutral-50 dark:bg-neutral-800 rounded-lg cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={localEnabled}
|
||||
onChange={(e) => handleToggleEnabled(e.target.checked)}
|
||||
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
|
||||
/>
|
||||
<div>
|
||||
<p className="font-medium text-neutral-900 dark:text-neutral-100">
|
||||
{t('settings.updateNotifications.enableEmails', 'Enable email notifications')}
|
||||
</p>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
{t('settings.updateNotifications.enableEmailsDesc', 'Send email to admins when a new version is available')}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* Recipients */}
|
||||
<div>
|
||||
<Input
|
||||
type="text"
|
||||
value={localRecipients}
|
||||
onChange={handleRecipientsChange}
|
||||
label={t('settings.updateNotifications.recipients', 'Email Recipients')}
|
||||
placeholder={t('settings.updateNotifications.recipientsPlaceholder', '[email protected], [email protected]')}
|
||||
helperText={t('settings.updateNotifications.recipientsHelper', 'Comma-separated email addresses. Leave empty to send to all admin users.')}
|
||||
leftIcon={<Mail className="w-4 h-4 text-neutral-400" />}
|
||||
disabled={!localEnabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Last notified version */}
|
||||
{settings?.lastNotifiedVersion && (
|
||||
<div className="p-3 bg-blue-50 dark:bg-blue-900/30 rounded-lg">
|
||||
<p className="text-sm text-blue-700 dark:text-blue-300">
|
||||
{t('settings.updateNotifications.lastNotified', 'Last notification sent for version: {{version}}', {
|
||||
version: settings.lastNotifiedVersion
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 pt-4 border-t border-neutral-200 dark:border-neutral-700">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => checkMutation.mutate()}
|
||||
isLoading={checkMutation.isPending}
|
||||
leftIcon={<RefreshCw className="w-4 h-4" />}
|
||||
disabled={!localEnabled}
|
||||
>
|
||||
{t('settings.updateNotifications.checkNow', 'Check & Notify')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => sendMutation.mutate()}
|
||||
isLoading={sendMutation.isPending}
|
||||
leftIcon={<Send className="w-4 h-4" />}
|
||||
disabled={!localEnabled}
|
||||
>
|
||||
{t('settings.updateNotifications.sendTest', 'Send Test Email')}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
isLoading={updateMutation.isPending}
|
||||
leftIcon={<Save className="w-4 h-4" />}
|
||||
disabled={!isDirty}
|
||||
>
|
||||
{t('common.save', 'Save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -12,6 +12,7 @@ import { Button, Card, Input } from '../../../components/common';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { settingsService } from '../../../services/settings.service';
|
||||
import { useStatusTab } from '../hooks/useStatusTab';
|
||||
import { UpdateNotificationSettings } from '../components/UpdateNotificationSettings';
|
||||
|
||||
const BYTES_PER_GB = 1024 * 1024 * 1024;
|
||||
|
||||
@@ -527,6 +528,9 @@ export const StatusTab: React.FC<StatusTabProps> = ({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Update Notification Settings */}
|
||||
<UpdateNotificationSettings />
|
||||
|
||||
{/* Last update time */}
|
||||
{systemStatus && (
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400 text-right flex items-center justify-end gap-1">
|
||||
|
||||
@@ -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": "[email protected], [email protected]",
|
||||
"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",
|
||||
|
||||
@@ -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": "[email protected], [email protected]",
|
||||
"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",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { api } from '../config/api';
|
||||
export interface AdminPhoto {
|
||||
id: number;
|
||||
filename: string;
|
||||
original_filename?: string;
|
||||
path: string;
|
||||
url: string;
|
||||
thumbnail_url: string | null;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -17,7 +17,7 @@ const config: VitestUserConfig = {
|
||||
},
|
||||
},
|
||||
},
|
||||
sourcemap: true,
|
||||
sourcemap: false,
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
|
||||
Reference in New Issue
Block a user