Fix gallery login persistence and favorites (#29)
Test and Lint / backend-test (push) Successful in 2m6s
Test and Lint / frontend-test (push) Successful in 2m16s

This commit is contained in:
2025-09-22 14:32:25 +02:00
parent e0865b81b6
commit 8d6ddd257d
7 changed files with 253 additions and 13 deletions
@@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query';
import { feedbackService } from '../../services/feedback.service'; import { feedbackService } from '../../services/feedback.service';
import { PhotoRating } from './PhotoRating'; import { PhotoRating } from './PhotoRating';
import { PhotoLikes } from './PhotoLikes'; import { PhotoLikes } from './PhotoLikes';
import { PhotoFavorites } from './PhotoFavorites';
import { PhotoComments } from './PhotoComments'; import { PhotoComments } from './PhotoComments';
import { Skeleton } from '../common'; import { Skeleton } from '../common';
@@ -42,13 +43,17 @@ export const PhotoFeedback: React.FC<PhotoFeedbackProps> = ({
const [currentRating, setCurrentRating] = useState(0); const [currentRating, setCurrentRating] = useState(0);
const [isLiked, setIsLiked] = useState(false); const [isLiked, setIsLiked] = useState(false);
const [likeCount, setLikeCount] = useState(0); const [likeCount, setLikeCount] = useState(0);
const [isFavorited, setIsFavorited] = useState(false);
const [favoriteCount, setFavoriteCount] = useState(0);
// Update local state when data loads // Update local state when data loads
useEffect(() => { useEffect(() => {
if (feedbackData) { if (feedbackData) {
setCurrentRating(feedbackData.my_feedback.rating || 0); setCurrentRating(feedbackData.my_feedback.rating || 0);
setIsLiked(feedbackData.my_feedback.liked); setIsLiked(Boolean(feedbackData.my_feedback.liked));
setLikeCount(feedbackData.summary.like_count); setLikeCount(Number(feedbackData.summary.like_count) || 0);
setIsFavorited(Boolean(feedbackData.my_feedback.favorited));
setFavoriteCount(Number(feedbackData.summary.favorite_count) || 0);
} }
}, [feedbackData]); }, [feedbackData]);
@@ -64,6 +69,12 @@ export const PhotoFeedback: React.FC<PhotoFeedbackProps> = ({
if (onFeedbackUpdate) onFeedbackUpdate(); if (onFeedbackUpdate) onFeedbackUpdate();
}; };
const handleFavoriteChange = (favorited: boolean) => {
setIsFavorited(favorited);
setFavoriteCount(prev => favorited ? prev + 1 : Math.max(0, prev - 1));
if (onFeedbackUpdate) onFeedbackUpdate();
};
if (settingsLoading) { if (settingsLoading) {
return ( return (
<div className={`space-y-3 ${className}`}> <div className={`space-y-3 ${className}`}>
@@ -78,7 +89,7 @@ export const PhotoFeedback: React.FC<PhotoFeedbackProps> = ({
} }
const hasAnyFeedbackType = settings.allow_ratings || settings.allow_likes || const hasAnyFeedbackType = settings.allow_ratings || settings.allow_likes ||
settings.allow_comments; settings.allow_comments || settings.allow_favorites;
if (!hasAnyFeedbackType) { if (!hasAnyFeedbackType) {
return null; return null;
@@ -101,8 +112,8 @@ export const PhotoFeedback: React.FC<PhotoFeedbackProps> = ({
)} )}
{/* Action Buttons */} {/* Action Buttons */}
{settings.allow_likes && ( {(settings.allow_likes || settings.allow_favorites) && (
<div className="flex items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{settings.allow_likes && ( {settings.allow_likes && (
<PhotoLikes <PhotoLikes
photoId={photoId} photoId={photoId}
@@ -114,6 +125,18 @@ export const PhotoFeedback: React.FC<PhotoFeedbackProps> = ({
onLikeChange={handleLikeChange} onLikeChange={handleLikeChange}
/> />
)} )}
{settings.allow_favorites && (
<PhotoFavorites
photoId={photoId}
gallerySlug={gallerySlug}
isFavorited={isFavorited}
favoriteCount={favoriteCount}
isEnabled={true}
requireNameEmail={settings.require_name_email || false}
onFavoriteChange={handleFavoriteChange}
/>
)}
</div> </div>
)} )}
+61 -1
View File
@@ -1,4 +1,10 @@
import axios from 'axios'; import axios, { AxiosHeaders } from 'axios';
import {
getActiveGallerySlug,
getGalleryToken,
inferGallerySlugFromLocation,
resolveSlugFromRequestUrl,
} from '../utils/galleryAuthStorage';
// Maintenance mode callback // Maintenance mode callback
let maintenanceModeCallback: ((enabled: boolean) => void) | null = null; let maintenanceModeCallback: ((enabled: boolean) => void) | null = null;
@@ -23,6 +29,60 @@ api.interceptors.request.use(
delete config.headers?.['Content-Type']; delete config.headers?.['Content-Type'];
} }
if (typeof window !== 'undefined') {
const pathSlug = resolveSlugFromRequestUrl(config.url || '');
const params = config.params as Record<string, unknown> | 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<string, string | undefined>;
if (!headersRecord.Authorization) {
headersRecord.Authorization = `Bearer ${token}`;
}
}
}
}
}
}
return config; return config;
}, },
(error) => { (error) => {
+31 -2
View File
@@ -3,6 +3,12 @@ import type { ReactNode } from 'react';
import { api } from '../config/api'; import { api } from '../config/api';
import { authService, galleryService } from '../services'; import { authService, galleryService } from '../services';
import { cleanupOldGalleryAuth } from '../utils/cleanupGalleryAuth'; import { cleanupOldGalleryAuth } from '../utils/cleanupGalleryAuth';
import {
clearActiveGallerySlug,
clearGalleryToken,
setActiveGallerySlug,
storeGalleryToken,
} from '../utils/galleryAuthStorage';
interface GalleryEvent { interface GalleryEvent {
id: number; id: number;
@@ -55,6 +61,13 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
useEffect(() => { useEffect(() => {
cleanupOldGalleryAuth(); cleanupOldGalleryAuth();
const slugAtMount = getCurrentGallerySlug();
if (slugAtMount) {
setActiveGallerySlug(slugAtMount);
} else {
clearActiveGallerySlug();
}
const initialise = async () => { const initialise = async () => {
const currentSlug = getCurrentGallerySlug(); const currentSlug = getCurrentGallerySlug();
@@ -63,6 +76,8 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
return; return;
} }
setActiveGallerySlug(currentSlug);
const storedEvent = sessionStorage.getItem(`gallery_event_${currentSlug}`); const storedEvent = sessionStorage.getItem(`gallery_event_${currentSlug}`);
if (storedEvent) { if (storedEvent) {
try { try {
@@ -109,6 +124,10 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
setEvent(response.event); setEvent(response.event);
setIsAuthenticated(true); setIsAuthenticated(true);
sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(response.event)); sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(response.event));
if (response.token) {
storeGalleryToken(currentSlug, response.token);
}
setActiveGallerySlug(currentSlug);
return; return;
} }
} }
@@ -118,16 +137,21 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
setIsAuthenticated(false); setIsAuthenticated(false);
sessionStorage.removeItem(`gallery_event_${currentSlug}`); sessionStorage.removeItem(`gallery_event_${currentSlug}`);
setEvent(null); setEvent(null);
clearGalleryToken(currentSlug);
} catch (error) { } catch (error) {
setIsAuthenticated(false); setIsAuthenticated(false);
sessionStorage.removeItem(`gallery_event_${currentSlug}`); sessionStorage.removeItem(`gallery_event_${currentSlug}`);
setEvent(null); setEvent(null);
clearGalleryToken(currentSlug);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}; };
initialise(); initialise();
return () => {
clearActiveGallerySlug();
};
}, []); }, []);
const login = async (slug: string, password: string, recaptchaToken?: string | null) => { const login = async (slug: string, password: string, recaptchaToken?: string | null) => {
@@ -137,6 +161,10 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
const response = await authService.verifyGalleryPassword(slug, password, recaptchaToken); const response = await authService.verifyGalleryPassword(slug, password, recaptchaToken);
setEvent(response.event); setEvent(response.event);
setIsAuthenticated(true); setIsAuthenticated(true);
if (response.token) {
storeGalleryToken(slug, response.token);
}
setActiveGallerySlug(slug);
// Store event data for quick reloads (non-sensitive) // Store event data for quick reloads (non-sensitive)
sessionStorage.setItem(`gallery_event_${slug}`, JSON.stringify(response.event)); sessionStorage.setItem(`gallery_event_${slug}`, JSON.stringify(response.event));
@@ -152,12 +180,13 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
const currentSlug = getCurrentGallerySlug(); const currentSlug = getCurrentGallerySlug();
if (currentSlug) { if (currentSlug) {
sessionStorage.removeItem(`gallery_event_${currentSlug}`); sessionStorage.removeItem(`gallery_event_${currentSlug}`);
clearGalleryToken(currentSlug);
} }
authService.galleryLogout(currentSlug || undefined); authService.galleryLogout(currentSlug || undefined);
setIsAuthenticated(false); setIsAuthenticated(false);
setEvent(null); setEvent(null);
} clearActiveGallerySlug();
; };
return ( return (
<GalleryAuthContext.Provider <GalleryAuthContext.Provider
@@ -226,6 +226,8 @@ export const CreateEventPageEnhanced: React.FC = () => {
return; return;
} }
const feedbackSettings = formData.feedback_settings;
const payload = { const payload = {
event_type: formData.event_type, event_type: formData.event_type,
event_name: formData.event_name, event_name: formData.event_name,
@@ -239,7 +241,14 @@ export const CreateEventPageEnhanced: React.FC = () => {
expiration_days: formData.expires_in_days, expiration_days: formData.expires_in_days,
allow_user_uploads: formData.allow_user_uploads, allow_user_uploads: formData.allow_user_uploads,
upload_category_id: formData.upload_category_id, 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); createMutation.mutate(payload);
+8
View File
@@ -13,6 +13,14 @@ interface CreateEventData {
expiration_days: number; expiration_days: number;
allow_user_uploads?: boolean; allow_user_uploads?: boolean;
upload_category_id?: number | null; 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 { interface UpdateEventData {
+1
View File
@@ -23,4 +23,5 @@ export const cleanupOldGalleryAuth = () => {
// Also clear session storage // Also clear session storage
sessionStorage.removeItem('gallery_event'); sessionStorage.removeItem('gallery_event');
sessionStorage.removeItem('gallery_token'); sessionStorage.removeItem('gallery_token');
sessionStorage.removeItem('gallery_active_slug');
}; };
+110
View File
@@ -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);
};