From 8d6ddd257d14b18c67c66c74c016c6f613b21ac4 Mon Sep 17 00:00:00 2001 From: paul Date: Mon, 22 Sep 2025 14:32:25 +0200 Subject: [PATCH] Fix gallery login persistence and favorites (#29) --- .../src/components/gallery/PhotoFeedback.tsx | 35 +++++- frontend/src/config/api.ts | 62 +++++++++- frontend/src/contexts/GalleryAuthContext.tsx | 35 +++++- .../pages/admin/CreateEventPageEnhanced.tsx | 15 ++- frontend/src/services/events.service.ts | 8 ++ frontend/src/utils/cleanupGalleryAuth.ts | 1 + frontend/src/utils/galleryAuthStorage.ts | 110 ++++++++++++++++++ 7 files changed, 253 insertions(+), 13 deletions(-) create mode 100644 frontend/src/utils/galleryAuthStorage.ts diff --git a/frontend/src/components/gallery/PhotoFeedback.tsx b/frontend/src/components/gallery/PhotoFeedback.tsx index 5427b5f..3b74a69 100644 --- a/frontend/src/components/gallery/PhotoFeedback.tsx +++ b/frontend/src/components/gallery/PhotoFeedback.tsx @@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query'; import { feedbackService } from '../../services/feedback.service'; import { PhotoRating } from './PhotoRating'; import { PhotoLikes } from './PhotoLikes'; +import { PhotoFavorites } from './PhotoFavorites'; import { PhotoComments } from './PhotoComments'; import { Skeleton } from '../common'; @@ -42,13 +43,17 @@ export const PhotoFeedback: React.FC = ({ const [currentRating, setCurrentRating] = useState(0); const [isLiked, setIsLiked] = useState(false); const [likeCount, setLikeCount] = useState(0); + const [isFavorited, setIsFavorited] = useState(false); + const [favoriteCount, setFavoriteCount] = useState(0); // Update local state when data loads useEffect(() => { if (feedbackData) { setCurrentRating(feedbackData.my_feedback.rating || 0); - setIsLiked(feedbackData.my_feedback.liked); - setLikeCount(feedbackData.summary.like_count); + setIsLiked(Boolean(feedbackData.my_feedback.liked)); + setLikeCount(Number(feedbackData.summary.like_count) || 0); + setIsFavorited(Boolean(feedbackData.my_feedback.favorited)); + setFavoriteCount(Number(feedbackData.summary.favorite_count) || 0); } }, [feedbackData]); @@ -64,6 +69,12 @@ export const PhotoFeedback: React.FC = ({ if (onFeedbackUpdate) onFeedbackUpdate(); }; + const handleFavoriteChange = (favorited: boolean) => { + setIsFavorited(favorited); + setFavoriteCount(prev => favorited ? prev + 1 : Math.max(0, prev - 1)); + if (onFeedbackUpdate) onFeedbackUpdate(); + }; + if (settingsLoading) { return (
@@ -77,8 +88,8 @@ export const PhotoFeedback: React.FC = ({ return null; } - const hasAnyFeedbackType = settings.allow_ratings || settings.allow_likes || - settings.allow_comments; + const hasAnyFeedbackType = settings.allow_ratings || settings.allow_likes || + settings.allow_comments || settings.allow_favorites; if (!hasAnyFeedbackType) { return null; @@ -101,8 +112,8 @@ export const PhotoFeedback: React.FC = ({ )} {/* Action Buttons */} - {settings.allow_likes && ( -
+ {(settings.allow_likes || settings.allow_favorites) && ( +
{settings.allow_likes && ( = ({ onLikeChange={handleLikeChange} /> )} + + {settings.allow_favorites && ( + + )}
)} diff --git a/frontend/src/config/api.ts b/frontend/src/config/api.ts index 2396a35..3697c88 100644 --- a/frontend/src/config/api.ts +++ b/frontend/src/config/api.ts @@ -1,4 +1,10 @@ -import axios from 'axios'; +import axios, { AxiosHeaders } from 'axios'; +import { + getActiveGallerySlug, + getGalleryToken, + inferGallerySlugFromLocation, + resolveSlugFromRequestUrl, +} from '../utils/galleryAuthStorage'; // Maintenance mode callback let maintenanceModeCallback: ((enabled: boolean) => void) | null = null; @@ -23,6 +29,60 @@ api.interceptors.request.use( delete config.headers?.['Content-Type']; } + if (typeof window !== 'undefined') { + const pathSlug = resolveSlugFromRequestUrl(config.url || ''); + const params = config.params as Record | undefined; + const paramSlug = typeof params?.slug === 'string' ? (params.slug as string) : null; + + const rawPath = (() => { + if (!config.url) return ''; + try { + if (config.url.startsWith('http://') || config.url.startsWith('https://')) { + return new URL(config.url).pathname; + } + } catch (error) { + return config.url; + } + return config.url; + })(); + + const pathname = rawPath.startsWith('/') ? rawPath : `/${rawPath}`; + + const isGalleryEndpoint = /^\/gallery\//.test(pathname) + || /^\/secure-images\//.test(pathname) + || /^\/auth\/gallery\//.test(pathname); + + const isGallerySessionCheck = pathname === '/auth/session' + && (!!paramSlug || window.location.pathname.startsWith('/gallery/')); + + if (isGalleryEndpoint || isGallerySessionCheck) { + const fallbackSlug = getActiveGallerySlug() + || inferGallerySlugFromLocation(); + const slug = pathSlug || paramSlug || fallbackSlug; + + if (slug) { + const token = getGalleryToken(slug); + if (token) { + if (!config.headers) { + config.headers = new AxiosHeaders(); + } + + if (config.headers instanceof AxiosHeaders) { + const existing = config.headers.get('Authorization'); + if (!existing) { + config.headers.set('Authorization', `Bearer ${token}`); + } + } else { + const headersRecord = config.headers as Record; + if (!headersRecord.Authorization) { + headersRecord.Authorization = `Bearer ${token}`; + } + } + } + } + } + } + return config; }, (error) => { diff --git a/frontend/src/contexts/GalleryAuthContext.tsx b/frontend/src/contexts/GalleryAuthContext.tsx index df10d9f..b23f940 100644 --- a/frontend/src/contexts/GalleryAuthContext.tsx +++ b/frontend/src/contexts/GalleryAuthContext.tsx @@ -3,6 +3,12 @@ import type { ReactNode } from 'react'; import { api } from '../config/api'; import { authService, galleryService } from '../services'; import { cleanupOldGalleryAuth } from '../utils/cleanupGalleryAuth'; +import { + clearActiveGallerySlug, + clearGalleryToken, + setActiveGallerySlug, + storeGalleryToken, +} from '../utils/galleryAuthStorage'; interface GalleryEvent { id: number; @@ -55,6 +61,13 @@ export const GalleryAuthProvider: React.FC = ({ childr useEffect(() => { cleanupOldGalleryAuth(); + const slugAtMount = getCurrentGallerySlug(); + if (slugAtMount) { + setActiveGallerySlug(slugAtMount); + } else { + clearActiveGallerySlug(); + } + const initialise = async () => { const currentSlug = getCurrentGallerySlug(); @@ -63,6 +76,8 @@ export const GalleryAuthProvider: React.FC = ({ childr return; } + setActiveGallerySlug(currentSlug); + const storedEvent = sessionStorage.getItem(`gallery_event_${currentSlug}`); if (storedEvent) { try { @@ -109,6 +124,10 @@ export const GalleryAuthProvider: React.FC = ({ childr setEvent(response.event); setIsAuthenticated(true); sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(response.event)); + if (response.token) { + storeGalleryToken(currentSlug, response.token); + } + setActiveGallerySlug(currentSlug); return; } } @@ -118,16 +137,21 @@ export const GalleryAuthProvider: React.FC = ({ childr setIsAuthenticated(false); sessionStorage.removeItem(`gallery_event_${currentSlug}`); setEvent(null); + clearGalleryToken(currentSlug); } catch (error) { setIsAuthenticated(false); sessionStorage.removeItem(`gallery_event_${currentSlug}`); setEvent(null); + clearGalleryToken(currentSlug); } finally { setIsLoading(false); } }; initialise(); + return () => { + clearActiveGallerySlug(); + }; }, []); const login = async (slug: string, password: string, recaptchaToken?: string | null) => { @@ -137,7 +161,11 @@ export const GalleryAuthProvider: React.FC = ({ childr const response = await authService.verifyGalleryPassword(slug, password, recaptchaToken); setEvent(response.event); setIsAuthenticated(true); - + if (response.token) { + storeGalleryToken(slug, response.token); + } + setActiveGallerySlug(slug); + // Store event data for quick reloads (non-sensitive) sessionStorage.setItem(`gallery_event_${slug}`, JSON.stringify(response.event)); } catch (err: any) { @@ -152,12 +180,13 @@ export const GalleryAuthProvider: React.FC = ({ childr const currentSlug = getCurrentGallerySlug(); if (currentSlug) { sessionStorage.removeItem(`gallery_event_${currentSlug}`); + clearGalleryToken(currentSlug); } authService.galleryLogout(currentSlug || undefined); setIsAuthenticated(false); setEvent(null); - } -; + clearActiveGallerySlug(); + }; return ( { if (!validateForm()) { return; } - + + const feedbackSettings = formData.feedback_settings; + const payload = { event_type: formData.event_type, event_name: formData.event_name, @@ -239,9 +241,16 @@ export const CreateEventPageEnhanced: React.FC = () => { expiration_days: formData.expires_in_days, allow_user_uploads: formData.allow_user_uploads, upload_category_id: formData.upload_category_id, - feedback_settings: formData.feedback_settings, + feedback_enabled: feedbackSettings.feedback_enabled, + allow_ratings: feedbackSettings.allow_ratings, + allow_likes: feedbackSettings.allow_likes, + allow_comments: feedbackSettings.allow_comments, + allow_favorites: feedbackSettings.allow_favorites, + require_name_email: feedbackSettings.require_name_email, + moderate_comments: feedbackSettings.moderate_comments, + show_feedback_to_guests: feedbackSettings.show_feedback_to_guests, }; - + createMutation.mutate(payload); }; diff --git a/frontend/src/services/events.service.ts b/frontend/src/services/events.service.ts index 6426cc9..12af073 100644 --- a/frontend/src/services/events.service.ts +++ b/frontend/src/services/events.service.ts @@ -13,6 +13,14 @@ interface CreateEventData { expiration_days: number; allow_user_uploads?: boolean; upload_category_id?: number | null; + feedback_enabled?: boolean; + allow_ratings?: boolean; + allow_likes?: boolean; + allow_comments?: boolean; + allow_favorites?: boolean; + require_name_email?: boolean; + moderate_comments?: boolean; + show_feedback_to_guests?: boolean; } interface UpdateEventData { diff --git a/frontend/src/utils/cleanupGalleryAuth.ts b/frontend/src/utils/cleanupGalleryAuth.ts index 5aca910..e5a2f4a 100644 --- a/frontend/src/utils/cleanupGalleryAuth.ts +++ b/frontend/src/utils/cleanupGalleryAuth.ts @@ -23,4 +23,5 @@ export const cleanupOldGalleryAuth = () => { // Also clear session storage sessionStorage.removeItem('gallery_event'); sessionStorage.removeItem('gallery_token'); + sessionStorage.removeItem('gallery_active_slug'); }; diff --git a/frontend/src/utils/galleryAuthStorage.ts b/frontend/src/utils/galleryAuthStorage.ts new file mode 100644 index 0000000..e9ef9b8 --- /dev/null +++ b/frontend/src/utils/galleryAuthStorage.ts @@ -0,0 +1,110 @@ +const TOKEN_STORAGE_PREFIX = 'gallery_token_'; +const ACTIVE_SLUG_KEY = 'gallery_active_slug'; + +const isBrowser = typeof window !== 'undefined'; + +const getSessionStorage = (): Storage | null => { + if (!isBrowser) return null; + try { + return window.sessionStorage; + } catch (error) { + console.warn('Session storage unavailable', error); + return null; + } +}; + +const extractSlugFromPath = (path: string): string | null => { + if (!path) return null; + const match = path.match(/\/gallery\/([^\/?#]+)/); + return match ? decodeURIComponent(match[1]) : null; +}; + +export const inferGallerySlugFromLocation = (): string | null => { + if (!isBrowser) return null; + return extractSlugFromPath(window.location.pathname); +}; + +export const setActiveGallerySlug = (slug: string | null) => { + const storage = getSessionStorage(); + if (!storage) return; + if (slug) { + storage.setItem(ACTIVE_SLUG_KEY, slug); + } else { + storage.removeItem(ACTIVE_SLUG_KEY); + } +}; + +export const getActiveGallerySlug = (): string | null => { + const storage = getSessionStorage(); + if (!storage) return null; + return storage.getItem(ACTIVE_SLUG_KEY); +}; + +export const clearActiveGallerySlug = () => { + const storage = getSessionStorage(); + if (!storage) return; + storage.removeItem(ACTIVE_SLUG_KEY); +}; + +export const storeGalleryToken = (slug: string, token: string) => { + const storage = getSessionStorage(); + if (!storage || !slug) return; + storage.setItem(`${TOKEN_STORAGE_PREFIX}${slug}`, token); +}; + +export const getGalleryToken = (slug?: string | null): string | null => { + const storage = getSessionStorage(); + if (!storage) return null; + const resolvedSlug = slug || getActiveGallerySlug() || inferGallerySlugFromLocation(); + if (!resolvedSlug) return null; + return storage.getItem(`${TOKEN_STORAGE_PREFIX}${resolvedSlug}`); +}; + +export const clearGalleryToken = (slug?: string | null) => { + const storage = getSessionStorage(); + if (!storage) return; + + if (slug) { + storage.removeItem(`${TOKEN_STORAGE_PREFIX}${slug}`); + return; + } + + const active = storage.getItem(ACTIVE_SLUG_KEY); + if (active) { + storage.removeItem(`${TOKEN_STORAGE_PREFIX}${active}`); + } +}; + +export const clearAllGalleryTokens = () => { + const storage = getSessionStorage(); + if (!storage) return; + + const keysToRemove: string[] = []; + for (let i = 0; i < storage.length; i += 1) { + const key = storage.key(i); + if (key && key.startsWith(TOKEN_STORAGE_PREFIX)) { + keysToRemove.push(key); + } + } + + keysToRemove.forEach((key) => storage.removeItem(key)); +}; + +export const resolveSlugFromRequestUrl = (url?: string | null): string | null => { + if (!url) return null; + let pathname = url; + + try { + if (url.startsWith('http://') || url.startsWith('https://')) { + pathname = new URL(url).pathname; + } + } catch (error) { + // Leave pathname as provided if URL parsing fails + } + + if (!pathname.startsWith('/')) { + pathname = `/${pathname}`; + } + + return extractSlugFromPath(pathname); +};