Fix language setting not being saved to database on admin settings page
- Added default_language field to general settings state in SettingsPage - Replaced LanguageSelector component with simple select dropdown on settings page - Fixed public settings endpoint to read general_default_language from database - Language setting now properly saved when clicking Save Settings button - Setting is correctly used by gallery login page and legal pages 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { getAuthToken } from '../../config/api';
|
||||
|
||||
interface AuthenticatedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
|
||||
src: string;
|
||||
fallbackSrc?: string;
|
||||
useWatermark?: boolean;
|
||||
}
|
||||
|
||||
export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
||||
src,
|
||||
fallbackSrc,
|
||||
alt,
|
||||
useWatermark = false,
|
||||
...props
|
||||
}) => {
|
||||
const [imageSrc, setImageSrc] = useState<string>('');
|
||||
const [error, setError] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let objectUrl: string | null = null;
|
||||
|
||||
const token = getAuthToken();
|
||||
|
||||
if (!src) {
|
||||
setImageSrc(fallbackSrc || '');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
console.warn('No auth token found for image:', src);
|
||||
setImageSrc(fallbackSrc || '');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(false);
|
||||
|
||||
// Create a new URL with auth header
|
||||
const fetchImage = async () => {
|
||||
try {
|
||||
// If watermark is requested and this is a gallery photo, use the protected images endpoint
|
||||
let imageUrl = src;
|
||||
if (useWatermark && src.includes('/photos/')) {
|
||||
// Extract gallery slug and photo ID from the URL
|
||||
// URL format: /photos/events/active/{slug}/photos/{photoId}.jpg
|
||||
const match = src.match(/\/photos\/events\/active\/([^\/]+)\/photos\/(\d+)\./);
|
||||
if (match) {
|
||||
const [, slug, photoId] = match;
|
||||
imageUrl = `/api/images/${slug}/photo/${photoId}/view`;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Fetching authenticated image:', imageUrl);
|
||||
const response = await fetch(imageUrl, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch image: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
setImageSrc(objectUrl);
|
||||
setIsLoading(false);
|
||||
} catch (err) {
|
||||
console.error('Failed to load image:', src, err);
|
||||
setError(true);
|
||||
setImageSrc(fallbackSrc || '');
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchImage();
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
if (objectUrl) {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
};
|
||||
}, [src, fallbackSrc, useWatermark]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={props.className} style={{ backgroundColor: '#f3f4f6', ...props.style }}>
|
||||
{/* Show a placeholder while loading */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && fallbackSrc) {
|
||||
return <img src={fallbackSrc} alt={alt} {...props} />;
|
||||
}
|
||||
|
||||
if (!imageSrc) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <img src={imageSrc} alt={alt} {...props} />;
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
export const DynamicFavicon: React.FC = () => {
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: ['public-settings'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}/api/public/settings`);
|
||||
if (response.ok) {
|
||||
return response.json();
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (settings?.branding_favicon_url) {
|
||||
// Remove existing favicon links
|
||||
const existingFavicons = document.querySelectorAll("link[rel*='icon']");
|
||||
existingFavicons.forEach(favicon => favicon.remove());
|
||||
|
||||
// Create new favicon link
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'icon';
|
||||
link.type = 'image/png';
|
||||
link.href = settings.branding_favicon_url.startsWith('http')
|
||||
? settings.branding_favicon_url
|
||||
: `${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${settings.branding_favicon_url}`;
|
||||
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
}, [settings?.branding_favicon_url]);
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import React, { Component } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { AlertTriangle, RefreshCw } from 'lucide-react';
|
||||
import { Button } from './Button';
|
||||
import i18n from '../../i18n/config';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
@@ -46,16 +47,16 @@ export class ErrorBoundary extends Component<Props, State> {
|
||||
<div className="text-center max-w-md">
|
||||
<AlertTriangle className="w-12 h-12 text-red-500 mx-auto mb-4" />
|
||||
<h2 className="text-lg font-semibold text-neutral-900 mb-2">
|
||||
Something went wrong
|
||||
{i18n.t('errors.somethingWentWrong')}
|
||||
</h2>
|
||||
<p className="text-sm text-neutral-600 mb-6">
|
||||
{this.state.error?.message || 'An unexpected error occurred. Please try refreshing the page.'}
|
||||
{this.state.error?.message || i18n.t('errors.tryAgainLater')}
|
||||
</p>
|
||||
<Button
|
||||
onClick={this.handleReset}
|
||||
leftIcon={<RefreshCw className="w-4 h-4" />}
|
||||
>
|
||||
Refresh Page
|
||||
{i18n.t('errors.refreshPage')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -93,10 +94,10 @@ export class PageErrorBoundary extends Component<Props, State> {
|
||||
<div className="bg-white rounded-lg shadow-lg p-8 max-w-md w-full text-center">
|
||||
<AlertTriangle className="w-16 h-16 text-red-500 mx-auto mb-6" />
|
||||
<h1 className="text-2xl font-bold text-neutral-900 mb-4">
|
||||
Oops! Something went wrong
|
||||
{i18n.t('errors.oopsSomethingWentWrong')}
|
||||
</h1>
|
||||
<p className="text-neutral-600 mb-8">
|
||||
We encountered an unexpected error. Don't worry, your data is safe.
|
||||
{i18n.t('errors.unexpectedError')}
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
@@ -105,20 +106,20 @@ export class PageErrorBoundary extends Component<Props, State> {
|
||||
leftIcon={<RefreshCw className="w-4 h-4" />}
|
||||
className="w-full"
|
||||
>
|
||||
Go to Homepage
|
||||
{i18n.t('errors.goToHomepage')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => window.location.reload()}
|
||||
className="w-full"
|
||||
>
|
||||
Try Again
|
||||
{i18n.t('gallery.tryAgain')}
|
||||
</Button>
|
||||
</div>
|
||||
{import.meta.env.DEV && this.state.error && (
|
||||
<details className="mt-8 text-left">
|
||||
<summary className="text-sm text-neutral-500 cursor-pointer hover:text-neutral-700">
|
||||
Error Details
|
||||
{i18n.t('errors.errorDetails')}
|
||||
</summary>
|
||||
<pre className="mt-2 text-xs bg-neutral-100 p-3 rounded overflow-auto">
|
||||
{this.state.error.stack}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Globe } from 'lucide-react';
|
||||
|
||||
const languages = [
|
||||
{ code: 'en', name: 'English', flag: '🇬🇧' },
|
||||
{ code: 'de', name: 'Deutsch', flag: '🇩🇪' },
|
||||
];
|
||||
|
||||
export const LanguageSelector: React.FC = () => {
|
||||
const { i18n } = useTranslation();
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
|
||||
const currentLanguage = languages.find(lang => lang.code === i18n.language) || languages[0];
|
||||
|
||||
const handleLanguageChange = (languageCode: string) => {
|
||||
i18n.changeLanguage(languageCode);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-neutral-700 bg-white border border-neutral-300 rounded-lg hover:bg-neutral-50 focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<Globe className="w-4 h-4" />
|
||||
<span>{currentLanguage.flag}</span>
|
||||
<span>{currentLanguage.name}</span>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-neutral-200 py-1 z-50">
|
||||
{languages.map((language) => (
|
||||
<button
|
||||
key={language.code}
|
||||
onClick={() => handleLanguageChange(language.code)}
|
||||
className={`w-full text-left px-4 py-2 text-sm hover:bg-neutral-50 flex items-center gap-3 ${
|
||||
language.code === i18n.language
|
||||
? 'text-primary-600 bg-primary-50'
|
||||
: 'text-neutral-700'
|
||||
}`}
|
||||
>
|
||||
<span className="text-lg">{language.flag}</span>
|
||||
<span>{language.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
LanguageSelector.displayName = 'LanguageSelector';
|
||||
@@ -12,4 +12,7 @@ export {
|
||||
SkeletonList
|
||||
} from './Skeleton';
|
||||
export { OfflineIndicator, useOnlineStatus } from './OfflineIndicator';
|
||||
export { SkipLink } from './SkipLink';
|
||||
export { SkipLink } from './SkipLink';
|
||||
export { DynamicFavicon } from './DynamicFavicon';
|
||||
export { LanguageSelector } from './LanguageSelector';
|
||||
export { AuthenticatedImage } from './AuthenticatedImage';
|
||||
Reference in New Issue
Block a user