Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a4595e2ab2 | |||
| 0911711a37 | |||
| f2c7594b23 | |||
| 32355fabad | |||
| c127fd829d | |||
| cab5b0d795 | |||
| ba95aad3c6 | |||
| c1be7d6785 | |||
| 0024686dc2 | |||
| 96b8b77792 | |||
| 9d2726b3d3 | |||
| 8d6ddd257d |
@@ -128,10 +128,17 @@ jobs:
|
||||
MINOR="${version_parts[1]}"
|
||||
PATCH="${version_parts[2]}"
|
||||
|
||||
# Increment patch version
|
||||
# Increment patch version and ensure tag uniqueness
|
||||
git fetch --tags --quiet || true
|
||||
NEW_PATCH=$((PATCH + 1))
|
||||
NEW_VERSION="$MAJOR.$MINOR.$NEW_PATCH"
|
||||
|
||||
while git rev-parse "v${NEW_VERSION}" >/dev/null 2>&1; do
|
||||
echo "Tag v${NEW_VERSION} already exists, bumping patch version again"
|
||||
NEW_PATCH=$((NEW_PATCH + 1))
|
||||
NEW_VERSION="$MAJOR.$MINOR.$NEW_PATCH"
|
||||
done
|
||||
|
||||
echo "New version: $NEW_VERSION"
|
||||
echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "component_changed=$COMPONENT_CHANGED" >> $GITHUB_OUTPUT
|
||||
@@ -260,4 +267,3 @@ jobs:
|
||||
echo "Component(s) changed: ${{ needs.version-bump.outputs.component_changed }}"
|
||||
echo "Drone will automatically trigger on the new tag"
|
||||
# Drone CI will automatically trigger on the tag push event
|
||||
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
FROM node:18-alpine AS builder
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
# Add build arguments
|
||||
ARG CACHEBUST=1
|
||||
@@ -23,7 +23,7 @@ RUN npm ci --only=production
|
||||
COPY . .
|
||||
|
||||
# Production stage
|
||||
FROM node:18-alpine
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "picpeak-backend",
|
||||
"version": "1.1.1",
|
||||
"version": "1.1.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "picpeak-backend",
|
||||
"version": "1.1.1",
|
||||
"version": "1.1.3",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.850.0",
|
||||
"@aws-sdk/lib-storage": "^3.850.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "picpeak-backend",
|
||||
"version": "1.1.1",
|
||||
"version": "1.1.3",
|
||||
"description": "Backend for PicPeak event photo sharing platform",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -62,11 +62,38 @@ router.post('/events/:id/import-external', adminAuth, async (req, res) => {
|
||||
.map(e => ({ full: path.join(baseAbs, e.name), rel: e.name, name: e.name }))
|
||||
.filter(f => ['.jpg', '.jpeg', '.png', '.webp'].includes(path.extname(f.name).toLowerCase()));
|
||||
|
||||
let imported = 0;
|
||||
// Prepare file metadata and deduplicate by filename within type (keep largest)
|
||||
let skipped = 0;
|
||||
const preparedFiles = [];
|
||||
for (const f of files) {
|
||||
try {
|
||||
const stats = await fs.stat(f.full);
|
||||
const segs = f.rel.split(path.sep);
|
||||
let type = 'individual';
|
||||
if (segs[0] === map.collages) type = 'collage';
|
||||
if (segs[0] === map.individual) type = 'individual';
|
||||
preparedFiles.push({ ...f, type, size: stats.size });
|
||||
} catch (err) {
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
const dedupeMap = new Map();
|
||||
for (const file of preparedFiles) {
|
||||
const dedupeKey = `${file.type}:${path.basename(file.rel).toLowerCase()}`;
|
||||
const existing = dedupeMap.get(dedupeKey);
|
||||
if (!existing || file.size > existing.size) {
|
||||
if (existing) skipped++;
|
||||
dedupeMap.set(dedupeKey, file);
|
||||
} else {
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
let imported = 0;
|
||||
|
||||
// Insert photos
|
||||
for (const f of files) {
|
||||
for (const f of dedupeMap.values()) {
|
||||
// Infer type by subfolder names
|
||||
const segs = f.rel.split(path.sep);
|
||||
let type = 'individual';
|
||||
@@ -79,7 +106,6 @@ router.post('/events/:id/import-external', adminAuth, async (req, res) => {
|
||||
.where({ event_id: eventId, external_relpath: f.rel })
|
||||
.first();
|
||||
if (exists) { skipped++; continue; }
|
||||
|
||||
const stats = await fs.stat(f.full);
|
||||
const inserted = await db('photos')
|
||||
.insert({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const path = require('path');
|
||||
const { resolveExternalPath } = require('./externalMediaService');
|
||||
const { safePathJoin } = require('../utils/fileSecurityUtils');
|
||||
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
|
||||
@@ -33,10 +34,15 @@ function resolvePhotoFilePath(event, photo) {
|
||||
}
|
||||
|
||||
const storagePath = getStoragePath();
|
||||
const eventsRoot = path.join(storagePath, 'events/active');
|
||||
|
||||
if (photo.path && photo.path.startsWith('events/active/')) {
|
||||
return path.join(storagePath, photo.path);
|
||||
// Legacy paths already include prefix; normalize via safe join
|
||||
return safePathJoin(storagePath, photo.path.replace(/^events\/active\/?/, 'events/active/'));
|
||||
}
|
||||
return path.join(storagePath, 'events/active', photo.path || '');
|
||||
|
||||
const relativeSegment = photo.path ? photo.path.replace(/^\/+/, '') : '';
|
||||
return safePathJoin(eventsRoot, relativeSegment);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "picpeak-frontend",
|
||||
"version": "1.1.0",
|
||||
"version": "1.1.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "picpeak-frontend",
|
||||
"version": "1.1.0",
|
||||
"version": "1.1.1",
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.0.0",
|
||||
"@tiptap/extension-character-count": "^2.26.1",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "picpeak-frontend",
|
||||
"private": true,
|
||||
"version": "1.1.0",
|
||||
"version": "1.1.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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<PhotoFeedbackProps> = ({
|
||||
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<PhotoFeedbackProps> = ({
|
||||
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 (
|
||||
<div className={`space-y-3 ${className}`}>
|
||||
@@ -77,8 +88,8 @@ export const PhotoFeedback: React.FC<PhotoFeedbackProps> = ({
|
||||
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<PhotoFeedbackProps> = ({
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
{settings.allow_likes && (
|
||||
<div className="flex items-center gap-2">
|
||||
{(settings.allow_likes || settings.allow_favorites) && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{settings.allow_likes && (
|
||||
<PhotoLikes
|
||||
photoId={photoId}
|
||||
@@ -114,6 +125,18 @@ export const PhotoFeedback: React.FC<PhotoFeedbackProps> = ({
|
||||
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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -291,6 +291,7 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
||||
protectionLevel={protectionLevel}
|
||||
useEnhancedProtection={useEnhancedProtection}
|
||||
initialShowFeedback={openFeedbackInitially}
|
||||
onFeedbackChange={onFeedbackChange}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -18,6 +18,7 @@ interface PhotoLightboxProps {
|
||||
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
|
||||
useEnhancedProtection?: boolean;
|
||||
initialShowFeedback?: boolean;
|
||||
onFeedbackChange?: () => void;
|
||||
}
|
||||
|
||||
export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
@@ -30,6 +31,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
protectionLevel = 'standard',
|
||||
useEnhancedProtection = false,
|
||||
initialShowFeedback = false,
|
||||
onFeedbackChange,
|
||||
}) => {
|
||||
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
||||
const [zoom, setZoom] = useState(1);
|
||||
@@ -533,6 +535,9 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
gallerySlug={slug}
|
||||
showComments={true}
|
||||
className="space-y-4"
|
||||
onFeedbackUpdate={() => {
|
||||
if (onFeedbackChange) onFeedbackChange();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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<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;
|
||||
},
|
||||
(error) => {
|
||||
|
||||
@@ -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<GalleryAuthProviderProps> = ({ 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<GalleryAuthProviderProps> = ({ childr
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveGallerySlug(currentSlug);
|
||||
|
||||
const storedEvent = sessionStorage.getItem(`gallery_event_${currentSlug}`);
|
||||
if (storedEvent) {
|
||||
try {
|
||||
@@ -109,6 +124,10 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ 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<GalleryAuthProviderProps> = ({ 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<GalleryAuthProviderProps> = ({ 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<GalleryAuthProviderProps> = ({ childr
|
||||
const currentSlug = getCurrentGallerySlug();
|
||||
if (currentSlug) {
|
||||
sessionStorage.removeItem(`gallery_event_${currentSlug}`);
|
||||
clearGalleryToken(currentSlug);
|
||||
}
|
||||
authService.galleryLogout(currentSlug || undefined);
|
||||
setIsAuthenticated(false);
|
||||
setEvent(null);
|
||||
}
|
||||
;
|
||||
clearActiveGallerySlug();
|
||||
};
|
||||
|
||||
return (
|
||||
<GalleryAuthContext.Provider
|
||||
|
||||
@@ -225,7 +225,9 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
||||
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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -23,4 +23,5 @@ export const cleanupOldGalleryAuth = () => {
|
||||
// Also clear session storage
|
||||
sessionStorage.removeItem('gallery_event');
|
||||
sessionStorage.removeItem('gallery_token');
|
||||
sessionStorage.removeItem('gallery_active_slug');
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
+45
-2
@@ -132,6 +132,37 @@ command_exists() {
|
||||
command -v "$1" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
ensure_storage_layout() {
|
||||
local base_dir="$1"
|
||||
local storage_root="$base_dir/storage"
|
||||
local storage_events_dir="$storage_root/events"
|
||||
|
||||
mkdir -p "$storage_events_dir/active" \
|
||||
"$storage_events_dir/archived" \
|
||||
"$storage_root/thumbnails" \
|
||||
"$storage_root/tmp"
|
||||
|
||||
local legacy_dir="$base_dir/events"
|
||||
if [[ -d "$legacy_dir" ]]; then
|
||||
log_step "Migrating legacy events directory to storage/events..."
|
||||
mkdir -p "$storage_events_dir"
|
||||
|
||||
local existing=""
|
||||
if [[ -d "$storage_events_dir" ]]; then
|
||||
existing=$(ls -A "$storage_events_dir" 2>/dev/null || true)
|
||||
fi
|
||||
|
||||
if [[ ! -d "$storage_events_dir" || -z "$existing" ]]; then
|
||||
rm -rf "$storage_events_dir"
|
||||
mv "$legacy_dir" "$storage_events_dir"
|
||||
else
|
||||
cp -a "$legacy_dir/." "$storage_events_dir/"
|
||||
rm -rf "$legacy_dir"
|
||||
fi
|
||||
fi
|
||||
mkdir -p "$storage_events_dir/active" "$storage_events_dir/archived"
|
||||
}
|
||||
|
||||
generate_password() {
|
||||
openssl rand -base64 32 | tr -d "=+/" | cut -c1-16
|
||||
}
|
||||
@@ -602,7 +633,8 @@ setup_native_installation() {
|
||||
|
||||
# Create application directory
|
||||
log_step "Creating application directory..."
|
||||
mkdir -p "$NATIVE_APP_DIR"/{app,events/{active,archived},logs,config}
|
||||
mkdir -p "$NATIVE_APP_DIR"/{app,logs,config}
|
||||
ensure_storage_layout "$NATIVE_APP_DIR"
|
||||
chown -R $NATIVE_APP_USER:$NATIVE_APP_USER "$NATIVE_APP_DIR"
|
||||
|
||||
# Clone repository
|
||||
@@ -668,7 +700,7 @@ DATABASE_CLIENT=sqlite3
|
||||
DATABASE_PATH=$NATIVE_APP_DIR/app/backend/data/photo_sharing.db
|
||||
|
||||
# Storage root (thumbnails/uploads live under this path)
|
||||
STORAGE_PATH=$NATIVE_APP_DIR
|
||||
STORAGE_PATH=$NATIVE_APP_DIR/storage
|
||||
|
||||
# Email
|
||||
SMTP_ENABLED=${SMTP_HOST:+true}
|
||||
@@ -1101,6 +1133,17 @@ update_native_installation() {
|
||||
if ! grep -q '^FRONTEND_DIR=' "$NATIVE_APP_DIR/app/backend/.env"; then
|
||||
echo "FRONTEND_DIR=$NATIVE_APP_DIR/app/frontend/dist" >> "$NATIVE_APP_DIR/app/backend/.env"
|
||||
fi
|
||||
|
||||
ensure_storage_layout "$NATIVE_APP_DIR"
|
||||
chown -R $NATIVE_APP_USER:$NATIVE_APP_USER "$NATIVE_APP_DIR/storage"
|
||||
|
||||
if [[ -f "$NATIVE_APP_DIR/app/backend/.env" ]]; then
|
||||
if grep -q '^STORAGE_PATH=' "$NATIVE_APP_DIR/app/backend/.env"; then
|
||||
sed -i "s|^STORAGE_PATH=.*|STORAGE_PATH=$NATIVE_APP_DIR/storage|" "$NATIVE_APP_DIR/app/backend/.env"
|
||||
else
|
||||
echo "STORAGE_PATH=$NATIVE_APP_DIR/storage" >> "$NATIVE_APP_DIR/app/backend/.env"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Restart services
|
||||
systemctl restart picpeak-backend
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@example.com';
|
||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'Admin!234';
|
||||
const GALLERY_PASSWORD = process.env.GALLERY_PASSWORD || 'ExternalMediaPass!1';
|
||||
|
||||
async function createExternalGallery(page) {
|
||||
const loginResponse = await page.request.post('/api/auth/admin/login', {
|
||||
data: {
|
||||
username: ADMIN_EMAIL,
|
||||
password: ADMIN_PASSWORD,
|
||||
},
|
||||
failOnStatusCode: false,
|
||||
});
|
||||
expect(loginResponse.ok()).toBeTruthy();
|
||||
const { token } = await loginResponse.json();
|
||||
expect(token).toBeTruthy();
|
||||
|
||||
const eventName = `External Media Playwright ${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const eventDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
|
||||
const createResponse = await page.request.post('/api/admin/events', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: {
|
||||
event_type: 'wedding',
|
||||
event_name: eventName,
|
||||
event_date: eventDate,
|
||||
host_name: 'External Host',
|
||||
host_email: 'host@example.com',
|
||||
admin_email: ADMIN_EMAIL,
|
||||
password: GALLERY_PASSWORD,
|
||||
expiration_days: 30,
|
||||
allow_user_uploads: false,
|
||||
allow_downloads: true,
|
||||
disable_right_click: false,
|
||||
watermark_downloads: false,
|
||||
feedback_enabled: true,
|
||||
allow_ratings: true,
|
||||
allow_likes: true,
|
||||
allow_comments: true,
|
||||
allow_favorites: true,
|
||||
require_name_email: false,
|
||||
moderate_comments: false,
|
||||
show_feedback_to_guests: true,
|
||||
source_mode: 'reference',
|
||||
external_path: 'picsum-demo'
|
||||
},
|
||||
failOnStatusCode: false,
|
||||
});
|
||||
|
||||
if (!createResponse.ok()) {
|
||||
const bodyText = await createResponse.text();
|
||||
throw new Error(`Failed to create event: ${createResponse.status()} ${bodyText}`);
|
||||
}
|
||||
const createdEvent = await createResponse.json();
|
||||
expect(createdEvent?.id).toBeTruthy();
|
||||
|
||||
const importResponse = await page.request.post(`/api/admin/external-media/events/${createdEvent.id}/import-external`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: {
|
||||
external_path: 'picsum-demo',
|
||||
recursive: true,
|
||||
},
|
||||
failOnStatusCode: false,
|
||||
});
|
||||
|
||||
expect(importResponse.ok()).toBeTruthy();
|
||||
const importBody = await importResponse.json();
|
||||
expect(importBody.imported).toBeGreaterThan(0);
|
||||
|
||||
await page.request.put(`/api/admin/feedback/events/${createdEvent.id}/feedback-settings`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: {
|
||||
feedback_enabled: true,
|
||||
allow_ratings: true,
|
||||
allow_likes: true,
|
||||
allow_comments: true,
|
||||
allow_favorites: true,
|
||||
require_name_email: false,
|
||||
moderate_comments: false,
|
||||
show_feedback_to_guests: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
shareLink: createdEvent.share_link,
|
||||
slug: createdEvent.slug,
|
||||
};
|
||||
}
|
||||
|
||||
test.describe('External media gallery behavior', () => {
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test('Maintains session and favorites after reload', async ({ page, context }) => {
|
||||
if (test.info().project.name.includes('mobile')) {
|
||||
test.skip('Mobile viewport handling requires manual verification.');
|
||||
}
|
||||
|
||||
const { shareLink, slug } = await createExternalGallery(page);
|
||||
|
||||
await page.goto(shareLink);
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
|
||||
const passwordField = page.getByPlaceholder(/gallery password/i).first();
|
||||
await expect(passwordField).toBeVisible();
|
||||
await passwordField.fill(GALLERY_PASSWORD);
|
||||
await page.getByRole('button', { name: /View Gallery/i }).click();
|
||||
|
||||
const tiles = page.locator('.relative.group');
|
||||
await expect(tiles.first()).toBeVisible({ timeout: 20000 });
|
||||
|
||||
const initialTileCount = await tiles.count();
|
||||
expect(initialTileCount).toBeGreaterThan(0);
|
||||
|
||||
const firstTile = tiles.first();
|
||||
await firstTile.scrollIntoViewIfNeeded();
|
||||
await firstTile.getByRole('button', { name: /View full size/i }).click();
|
||||
|
||||
await page.evaluate(() => {
|
||||
const toggle = document.querySelector('[aria-label="Toggle feedback"]');
|
||||
if (toggle instanceof HTMLElement) toggle.click();
|
||||
});
|
||||
|
||||
const favoritesButtonInLightbox = page.getByRole('button', { name: /Add to favorites|Remove from favorites/ }).first();
|
||||
await expect(favoritesButtonInLightbox).toBeVisible();
|
||||
|
||||
const ariaLabel = await favoritesButtonInLightbox.getAttribute('aria-label');
|
||||
const isAlreadyFavorited = ariaLabel ? /Remove from favorites/i.test(ariaLabel) : false;
|
||||
const refetchPromise = page.waitForResponse((res) => {
|
||||
return res.request().method() === 'GET' && res.url().includes(`/api/gallery/${slug}/photos`);
|
||||
});
|
||||
if (!isAlreadyFavorited) {
|
||||
const favResponsePromise = page.waitForResponse((res) => {
|
||||
return res.request().method() === 'POST' && res.url().includes(`/api/gallery/${slug}/photos/`);
|
||||
});
|
||||
await favoritesButtonInLightbox.click();
|
||||
await Promise.all([favResponsePromise, refetchPromise]);
|
||||
} else {
|
||||
await refetchPromise;
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: 'Close', exact: true }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Favorited' }).click();
|
||||
await expect(page.locator('.relative.group')).toHaveCount(1, { timeout: 15000 });
|
||||
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await expect(page).toHaveURL(/\/gallery\//);
|
||||
await expect(page.locator('.relative.group').first()).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Favorited' }).click();
|
||||
await expect(page.locator('.relative.group')).toHaveCount(1, { timeout: 15000 });
|
||||
|
||||
await page.getByRole('button', { name: 'All', exact: true }).click();
|
||||
await expect(page.locator('.relative.group')).toHaveCount(initialTileCount);
|
||||
|
||||
const cookies = await context.cookies();
|
||||
expect(cookies.some((cookie) => cookie.name === 'gallery_token')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user