Add short gallery URL toggle and token support (#38)
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import React, { createContext, useContext, useState, useEffect, useRef } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { api } from '../config/api';
|
||||
import { authService, galleryService } from '../services';
|
||||
import { cleanupOldGalleryAuth } from '../utils/cleanupGalleryAuth';
|
||||
@@ -61,52 +62,133 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
||||
const [event, setEvent] = useState<GalleryEvent | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Get current gallery slug from URL
|
||||
const getCurrentGallerySlug = () => {
|
||||
const pathParts = window.location.pathname.split('/');
|
||||
if (pathParts[1] === 'gallery' && pathParts[2]) {
|
||||
return pathParts[2];
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const [routeError, setRouteError] = useState<string | null>(null);
|
||||
const location = useLocation();
|
||||
const [routeInfo, setRouteInfo] = useState<{ slug: string | null; token?: string; identifier: string | null; ready: boolean }>({
|
||||
slug: null,
|
||||
token: undefined,
|
||||
identifier: null,
|
||||
ready: false,
|
||||
});
|
||||
const lastResolvedIdentifier = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
cleanupOldGalleryAuth();
|
||||
}, []);
|
||||
|
||||
const slugAtMount = getCurrentGallerySlug();
|
||||
if (slugAtMount) {
|
||||
setActiveGallerySlug(slugAtMount);
|
||||
} else {
|
||||
clearActiveGallerySlug();
|
||||
}
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const initialise = async () => {
|
||||
const currentSlug = getCurrentGallerySlug();
|
||||
const parseRoute = async () => {
|
||||
const segments = location.pathname.split('/').filter(Boolean);
|
||||
|
||||
if (!currentSlug) {
|
||||
setIsLoading(false);
|
||||
if (segments[0] !== 'gallery') {
|
||||
if (!cancelled) {
|
||||
setRouteInfo({ slug: null, token: undefined, identifier: null, ready: true });
|
||||
setRouteError(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveGallerySlug(currentSlug);
|
||||
const identifier = segments[1] || null;
|
||||
const tokenSegment = segments[2];
|
||||
|
||||
const storedEvent = sessionStorage.getItem(`gallery_event_${currentSlug}`);
|
||||
if (storedEvent) {
|
||||
try {
|
||||
const parsed = JSON.parse(storedEvent);
|
||||
if (parsed && parsed.id) {
|
||||
const normalizedStored = normalizeEvent(parsed);
|
||||
setEvent(normalizedStored);
|
||||
if (normalizedStored) {
|
||||
sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(normalizedStored));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
sessionStorage.removeItem(`gallery_event_${currentSlug}`);
|
||||
if (!identifier) {
|
||||
if (!cancelled) {
|
||||
setRouteInfo({ slug: null, token: undefined, identifier: null, ready: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const looksLikeToken = /^[0-9a-fA-F]{32}$/.test(identifier) && !tokenSegment;
|
||||
|
||||
if (looksLikeToken) {
|
||||
if (lastResolvedIdentifier.current === identifier) {
|
||||
setRouteInfo(prev => ({
|
||||
slug: prev.slug,
|
||||
token: prev.token,
|
||||
identifier,
|
||||
ready: true,
|
||||
}));
|
||||
setRouteError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const resolved = await galleryService.resolveIdentifier(identifier);
|
||||
if (cancelled) return;
|
||||
lastResolvedIdentifier.current = identifier;
|
||||
setRouteInfo({
|
||||
slug: resolved.slug,
|
||||
token: resolved.token,
|
||||
identifier,
|
||||
ready: true,
|
||||
});
|
||||
setRouteError(null);
|
||||
} catch (err: any) {
|
||||
if (cancelled) return;
|
||||
lastResolvedIdentifier.current = identifier;
|
||||
setRouteInfo({
|
||||
slug: null,
|
||||
token: undefined,
|
||||
identifier,
|
||||
ready: true,
|
||||
});
|
||||
setRouteError(err?.response?.data?.error || 'Unable to resolve gallery link');
|
||||
}
|
||||
} else {
|
||||
lastResolvedIdentifier.current = null;
|
||||
setRouteInfo({
|
||||
slug: identifier,
|
||||
token: tokenSegment,
|
||||
identifier,
|
||||
ready: true,
|
||||
});
|
||||
setRouteError(null);
|
||||
}
|
||||
};
|
||||
|
||||
setRouteInfo(prev => ({ ...prev, ready: false }));
|
||||
parseRoute();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [location.pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!routeInfo.ready) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!routeInfo.slug) {
|
||||
clearActiveGallerySlug();
|
||||
setIsAuthenticated(false);
|
||||
setEvent(null);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentSlug = routeInfo.slug;
|
||||
setActiveGallerySlug(currentSlug);
|
||||
|
||||
const storedEvent = sessionStorage.getItem(`gallery_event_${currentSlug}`);
|
||||
if (storedEvent) {
|
||||
try {
|
||||
const parsed = JSON.parse(storedEvent);
|
||||
if (parsed && parsed.id) {
|
||||
const normalizedStored = normalizeEvent(parsed);
|
||||
setEvent(normalizedStored);
|
||||
if (normalizedStored) {
|
||||
sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(normalizedStored));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
sessionStorage.removeItem(`gallery_event_${currentSlug}`);
|
||||
}
|
||||
}
|
||||
|
||||
const initialise = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const sessionResponse = await api.get<{ valid: boolean; type: string; eventSlug?: string }>(
|
||||
@@ -118,7 +200,6 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
||||
setIsAuthenticated(true);
|
||||
|
||||
if (!storedEvent) {
|
||||
// Fetch gallery details to hydrate context
|
||||
const galleryData = await galleryService.getGalleryPhotos(currentSlug);
|
||||
if (galleryData?.event) {
|
||||
const normalizedEvent = normalizeEvent(galleryData.event);
|
||||
@@ -132,14 +213,10 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
||||
return;
|
||||
}
|
||||
|
||||
// If no active session, check for share token in URL
|
||||
const parts = window.location.pathname.split('/');
|
||||
const urlToken = parts.length >= 5 ? parts[4] : (parts.length >= 4 ? parts[3] : undefined);
|
||||
|
||||
if (urlToken) {
|
||||
const verify = await galleryService.verifyToken(currentSlug, urlToken);
|
||||
if (routeInfo.token) {
|
||||
const verify = await galleryService.verifyToken(currentSlug, routeInfo.token);
|
||||
if (verify?.valid) {
|
||||
const response = await authService.shareLinkLogin(currentSlug, urlToken);
|
||||
const response = await authService.shareLinkLogin(currentSlug, routeInfo.token);
|
||||
if (response?.event) {
|
||||
const normalizedEvent = normalizeEvent(response.event);
|
||||
setEvent(normalizedEvent);
|
||||
@@ -156,29 +233,33 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
||||
}
|
||||
}
|
||||
|
||||
// No valid session found
|
||||
setIsAuthenticated(false);
|
||||
sessionStorage.removeItem(`gallery_event_${currentSlug}`);
|
||||
setEvent(null);
|
||||
clearGalleryToken(currentSlug);
|
||||
} catch (error) {
|
||||
} catch (initialiseError: any) {
|
||||
setIsAuthenticated(false);
|
||||
sessionStorage.removeItem(`gallery_event_${currentSlug}`);
|
||||
setEvent(null);
|
||||
clearGalleryToken(currentSlug);
|
||||
if (initialiseError?.response?.data?.error) {
|
||||
setError(initialiseError.response.data.error);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
initialise();
|
||||
|
||||
return () => {
|
||||
clearActiveGallerySlug();
|
||||
};
|
||||
}, []);
|
||||
}, [routeInfo]);
|
||||
|
||||
const login = async (slug: string, password?: string, recaptchaToken?: string | null) => {
|
||||
try {
|
||||
setRouteError(null);
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
const response = await authService.verifyGalleryPassword(slug, password, recaptchaToken);
|
||||
@@ -190,7 +271,6 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
||||
}
|
||||
setActiveGallerySlug(slug);
|
||||
|
||||
// Store event data for quick reloads (non-sensitive)
|
||||
if (normalizedEvent) {
|
||||
sessionStorage.setItem(`gallery_event_${slug}`, JSON.stringify(normalizedEvent));
|
||||
}
|
||||
@@ -203,7 +283,7 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
const currentSlug = getCurrentGallerySlug();
|
||||
const currentSlug = routeInfo.slug;
|
||||
if (currentSlug) {
|
||||
sessionStorage.removeItem(`gallery_event_${currentSlug}`);
|
||||
clearGalleryToken(currentSlug);
|
||||
@@ -222,7 +302,7 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
||||
login,
|
||||
logout,
|
||||
isLoading,
|
||||
error,
|
||||
error: routeError ?? error,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -2,12 +2,18 @@ import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { galleryService } from '../services';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
export const useGalleryInfo = (slug: string, token?: string) => {
|
||||
export const useGalleryInfo = (slug?: string, token?: string, enabled: boolean = true) => {
|
||||
return useQuery({
|
||||
queryKey: ['gallery-info', slug, token],
|
||||
queryFn: () => galleryService.getGalleryInfo(slug, token),
|
||||
queryFn: () => {
|
||||
if (!slug) {
|
||||
throw new Error('Gallery slug is required');
|
||||
}
|
||||
return galleryService.getGalleryInfo(slug, token);
|
||||
},
|
||||
retry: 1,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
enabled: Boolean(slug) && enabled,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -783,6 +783,8 @@
|
||||
"enableWatermark": "Wasserzeichen auf Fotos aktivieren",
|
||||
"enableAnalytics": "Analytics-Tracking aktivieren",
|
||||
"enableRegistration": "Selbstregistrierung für Admins erlauben",
|
||||
"enableShortGalleryUrls": "Kurze Galerie-Links verwenden",
|
||||
"enableShortGalleryUrlsHelp": "Entfernt den Veranstaltungs-Slug aus neuen Freigabelinks und lässt bestehende Links weiterhin funktionieren.",
|
||||
"maintenanceMode": "Wartungsmodus aktivieren",
|
||||
"language": "Sprache",
|
||||
"defaultLanguage": "Standardsprache",
|
||||
|
||||
@@ -463,6 +463,8 @@
|
||||
"enableWatermark": "Enable watermark on photos",
|
||||
"enableAnalytics": "Enable analytics tracking",
|
||||
"enableRegistration": "Allow self-registration for admins",
|
||||
"enableShortGalleryUrls": "Use short gallery URLs",
|
||||
"enableShortGalleryUrlsHelp": "Removes the event slug from new share links while keeping existing links working.",
|
||||
"maintenanceMode": "Enable maintenance mode",
|
||||
"language": "Language",
|
||||
"defaultLanguage": "Default Language",
|
||||
|
||||
@@ -11,13 +11,14 @@ import { useGalleryAuth, useTheme } from '../contexts';
|
||||
import { useGalleryInfo } from '../hooks/useGallery';
|
||||
import { GalleryView } from '../components/gallery';
|
||||
import { analyticsService } from '../services/analytics.service';
|
||||
import { galleryService } from '../services';
|
||||
import { api } from '../config/api';
|
||||
import { GALLERY_THEME_PRESETS } from '../types/theme.types';
|
||||
import { buildResourceUrl } from '../utils/url';
|
||||
import { isGalleryPublic, normalizeRequirePassword } from '../utils/accessControl';
|
||||
|
||||
export const GalleryPage: React.FC = () => {
|
||||
const { slug, token } = useParams<{ slug: string; token?: string }>();
|
||||
const { slug: rawSlug, token: rawToken } = useParams<{ slug: string; token?: string }>();
|
||||
const { isAuthenticated, login, event } = useGalleryAuth();
|
||||
const { t, i18n } = useTranslation();
|
||||
const { format } = useLocalizedDate();
|
||||
@@ -27,10 +28,82 @@ export const GalleryPage: React.FC = () => {
|
||||
const [loginError, setLoginError] = useState<string | null>(null);
|
||||
const [recaptchaToken, setRecaptchaToken] = useState<string | null>(null);
|
||||
const [autoLoginAttempted, setAutoLoginAttempted] = useState(false);
|
||||
const [resolvedSlug, setResolvedSlug] = useState<string | null>(() => {
|
||||
if (rawSlug && !rawToken && /^[0-9a-fA-F]{32}$/.test(rawSlug)) {
|
||||
return null;
|
||||
}
|
||||
return rawSlug || null;
|
||||
});
|
||||
const [resolvedToken, setResolvedToken] = useState<string | undefined>(rawToken);
|
||||
const [isResolvingIdentifier, setIsResolvingIdentifier] = useState<boolean>(() =>
|
||||
Boolean(rawSlug && !rawToken && /^[0-9a-fA-F]{32}$/.test(rawSlug))
|
||||
);
|
||||
const [identifierError, setIdentifierError] = useState<string | null>(null);
|
||||
const lastResolvedIdentifier = React.useRef<string | null>(null);
|
||||
|
||||
// Fetch gallery info (public data)
|
||||
const { data: galleryInfo, isLoading: isLoadingInfo, error: infoError } = useGalleryInfo(slug!, token);
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const looksLikeToken = Boolean(rawSlug && !rawToken && /^[0-9a-fA-F]{32}$/.test(rawSlug));
|
||||
|
||||
if (!rawSlug) {
|
||||
lastResolvedIdentifier.current = null;
|
||||
setResolvedSlug(null);
|
||||
setResolvedToken(rawToken);
|
||||
setIsResolvingIdentifier(false);
|
||||
setIdentifierError(null);
|
||||
} else if (!looksLikeToken) {
|
||||
lastResolvedIdentifier.current = null;
|
||||
setResolvedSlug(rawSlug);
|
||||
setResolvedToken(rawToken);
|
||||
setIsResolvingIdentifier(false);
|
||||
setIdentifierError(null);
|
||||
} else if (lastResolvedIdentifier.current !== rawSlug) {
|
||||
setIsResolvingIdentifier(true);
|
||||
setIdentifierError(null);
|
||||
|
||||
galleryService.resolveIdentifier(rawSlug)
|
||||
.then((data) => {
|
||||
if (cancelled) return;
|
||||
lastResolvedIdentifier.current = rawSlug;
|
||||
setResolvedSlug(data.slug);
|
||||
setResolvedToken(data.token);
|
||||
setIdentifierError(null);
|
||||
})
|
||||
.catch((error: any) => {
|
||||
if (cancelled) return;
|
||||
lastResolvedIdentifier.current = rawSlug;
|
||||
setResolvedSlug(null);
|
||||
setResolvedToken(undefined);
|
||||
const message = error?.response?.data?.error || 'Unable to resolve gallery link';
|
||||
setIdentifierError(message);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setIsResolvingIdentifier(false);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setIsResolvingIdentifier(false);
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [rawSlug, rawToken]);
|
||||
|
||||
const canFetchGalleryInfo = Boolean(resolvedSlug) && !isResolvingIdentifier;
|
||||
const {
|
||||
data: galleryInfo,
|
||||
isLoading: isLoadingInfoQuery,
|
||||
error: infoError
|
||||
} = useGalleryInfo(canFetchGalleryInfo ? resolvedSlug ?? undefined : undefined, resolvedToken, canFetchGalleryInfo);
|
||||
const isLoadingInfo = isLoadingInfoQuery || isResolvingIdentifier;
|
||||
const requiresPassword = normalizeRequirePassword(galleryInfo?.requires_password, true);
|
||||
|
||||
React.useEffect(() => {
|
||||
setAutoLoginAttempted(false);
|
||||
}, [resolvedSlug]);
|
||||
|
||||
// Fetch branding settings
|
||||
const { data: settingsData } = useQuery({
|
||||
@@ -91,14 +164,14 @@ export const GalleryPage: React.FC = () => {
|
||||
}, [galleryInfo, settingsData, isAuthenticated, setTheme]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!slug) {
|
||||
if (!resolvedSlug || isResolvingIdentifier) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (galleryInfo && isGalleryPublic(galleryInfo.requires_password) && !isAuthenticated && !autoLoginAttempted) {
|
||||
setAutoLoginAttempted(true);
|
||||
setIsLoggingIn(true);
|
||||
login(slug, '')
|
||||
login(resolvedSlug, '')
|
||||
.then(() => {
|
||||
setLoginError(null);
|
||||
})
|
||||
@@ -112,7 +185,7 @@ export const GalleryPage: React.FC = () => {
|
||||
setIsLoggingIn(false);
|
||||
});
|
||||
}
|
||||
}, [galleryInfo, isAuthenticated, autoLoginAttempted, login, slug]);
|
||||
}, [galleryInfo, isAuthenticated, autoLoginAttempted, login, resolvedSlug, isResolvingIdentifier]);
|
||||
|
||||
// Calculate days until expiration
|
||||
const daysUntilExpiration = galleryInfo
|
||||
@@ -131,11 +204,16 @@ export const GalleryPage: React.FC = () => {
|
||||
try {
|
||||
setIsLoggingIn(true);
|
||||
setLoginError(null);
|
||||
await login(slug!, requiresPassword ? password : '', recaptchaToken);
|
||||
if (!resolvedSlug) {
|
||||
setLoginError(t('errors.galleryNotFound'));
|
||||
return;
|
||||
}
|
||||
|
||||
await login(resolvedSlug, requiresPassword ? password : '', recaptchaToken);
|
||||
|
||||
if (requiresPassword) {
|
||||
analyticsService.trackGalleryEvent('password_entry', {
|
||||
gallery: slug,
|
||||
gallery: resolvedSlug,
|
||||
success: true
|
||||
});
|
||||
}
|
||||
@@ -158,7 +236,7 @@ export const GalleryPage: React.FC = () => {
|
||||
// Track failed password entry
|
||||
if (requiresPassword) {
|
||||
analyticsService.trackGalleryEvent('password_entry', {
|
||||
gallery: slug,
|
||||
gallery: resolvedSlug ?? rawSlug ?? 'unknown',
|
||||
success: false,
|
||||
statusCode
|
||||
});
|
||||
@@ -182,6 +260,59 @@ export const GalleryPage: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
if (identifierError && !resolvedSlug && !isResolvingIdentifier) {
|
||||
return (
|
||||
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
|
||||
<div className="min-h-screen flex flex-col">
|
||||
{settingsData?.branding_logo_url && (
|
||||
<div className="p-8 text-center">
|
||||
<img
|
||||
src={buildResourceUrl(settingsData.branding_logo_url)}
|
||||
alt={settingsData.branding_company_name || 'Company Logo'}
|
||||
className="h-16 w-auto object-contain mx-auto"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<Card className="max-w-md w-full mx-4">
|
||||
<CardContent className="text-center py-12">
|
||||
<AlertCircle className="w-16 h-16 text-red-500 mx-auto mb-4" />
|
||||
<h2 className="text-xl font-semibold mb-2">
|
||||
{t('errors.galleryNotFound')}
|
||||
</h2>
|
||||
<p className="text-neutral-600">
|
||||
{identifierError}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="p-8 text-center">
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<Link
|
||||
to="/impressum"
|
||||
className="text-xs text-neutral-500 hover:text-neutral-700 transition-colors"
|
||||
>
|
||||
{t('legal.impressum')}
|
||||
</Link>
|
||||
<span className="text-xs text-neutral-400">|</span>
|
||||
<Link
|
||||
to="/datenschutz"
|
||||
className="text-xs text-neutral-500 hover:text-neutral-700 transition-colors"
|
||||
>
|
||||
{t('legal.datenschutz')}
|
||||
</Link>
|
||||
</div>
|
||||
<p className="text-xs mt-2 text-neutral-500">
|
||||
Powered by <span className="font-semibold">PicPeak</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show error state
|
||||
if (infoError) {
|
||||
// Check if it's an archived gallery error
|
||||
@@ -299,9 +430,11 @@ export const GalleryPage: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const gallerySlugForView = resolvedSlug ?? rawSlug ?? '';
|
||||
|
||||
// Show gallery view if authenticated
|
||||
if (isAuthenticated && event) {
|
||||
return <GalleryView slug={slug!} event={event} />;
|
||||
return <GalleryView slug={gallerySlugForView} event={event} />;
|
||||
}
|
||||
|
||||
// Show login form
|
||||
|
||||
@@ -100,6 +100,7 @@ export const SettingsPage: React.FC = () => {
|
||||
enable_analytics: true,
|
||||
enable_registration: false,
|
||||
maintenance_mode: false,
|
||||
short_gallery_urls: false,
|
||||
default_language: 'en',
|
||||
date_format: { format: 'dd/MM/yyyy', locale: 'en-GB' }
|
||||
});
|
||||
@@ -156,6 +157,7 @@ export const SettingsPage: React.FC = () => {
|
||||
enable_analytics: toBoolean(settings.general_enable_analytics, true),
|
||||
enable_registration: toBoolean(settings.general_enable_registration, false),
|
||||
maintenance_mode: toBoolean(settings.general_maintenance_mode, false),
|
||||
short_gallery_urls: toBoolean(settings.general_short_gallery_urls, false),
|
||||
default_language: settings.general_default_language || 'en',
|
||||
date_format: settings.general_date_format
|
||||
? (typeof settings.general_date_format === 'string'
|
||||
@@ -761,6 +763,21 @@ export const SettingsPage: React.FC = () => {
|
||||
/>
|
||||
<span className="ml-2 text-sm text-neutral-700">{t('settings.general.maintenanceMode')}</span>
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={generalSettings.short_gallery_urls}
|
||||
onChange={(e) => setGeneralSettings(prev => ({ ...prev, short_gallery_urls: e.target.checked }))}
|
||||
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
|
||||
/>
|
||||
<span className="ml-2 text-sm text-neutral-700">{t('settings.general.enableShortGalleryUrls')}</span>
|
||||
</label>
|
||||
<p className="text-xs text-neutral-500 ml-6 mt-1">
|
||||
{t('settings.general.enableShortGalleryUrlsHelp')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { api } from '../config/api';
|
||||
import type { GalleryInfo, GalleryData, GalleryStats } from '../types';
|
||||
import type { GalleryInfo, GalleryData, GalleryStats, ResolvedGalleryIdentifier } from '../types';
|
||||
import { normalizeRequirePassword } from '../utils/accessControl';
|
||||
|
||||
export const galleryService = {
|
||||
@@ -119,4 +119,9 @@ export const galleryService = {
|
||||
const response = await api.get<GalleryStats>(`/gallery/${slug}/stats`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async resolveIdentifier(identifier: string): Promise<ResolvedGalleryIdentifier> {
|
||||
const response = await api.get<ResolvedGalleryIdentifier>(`/gallery/resolve/${identifier}`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -111,6 +111,17 @@ export interface GalleryStats {
|
||||
unique_visitors: number;
|
||||
}
|
||||
|
||||
export interface ResolvedGalleryIdentifier {
|
||||
slug: string;
|
||||
token: string;
|
||||
matchType: string;
|
||||
share_link: string;
|
||||
share_path: string;
|
||||
share_url: string;
|
||||
short_enabled: boolean;
|
||||
requires_password: boolean;
|
||||
}
|
||||
|
||||
// Auth types
|
||||
export interface AdminUser {
|
||||
id: number;
|
||||
|
||||
Reference in New Issue
Block a user