Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fb739f221d | |||
| b5399aaa9b | |||
| 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
|
||||
|
||||
|
||||
+11
-1
@@ -420,7 +420,17 @@ Upon first login, the system will **automatically redirect** you to change your
|
||||
|
||||
If you lose your admin credentials after the first login, you'll need to manually reset the password in the database or create a new admin user through the database.
|
||||
|
||||
**Note**: The credentials file (`ADMIN_CREDENTIALS.txt`) is only created during initial deployment and contains the first admin password. After changing the password, this file becomes outdated but is kept for reference.
|
||||
**Note**: The credentials file (`ADMIN_CREDENTIALS.txt`) is only created during initial deployment and contains the first admin password. After changing the password, this file becomes outdated but is kept for reference. If you need to regenerate the password and file during a reinstall, re-run the installer with the `--force-admin-password-reset` flag:
|
||||
|
||||
```bash
|
||||
# Native reinstall example
|
||||
sudo ./setup.sh --native --force-admin-password-reset
|
||||
|
||||
# Docker reinstall example
|
||||
sudo ./setup.sh --docker --force-admin-password-reset
|
||||
```
|
||||
|
||||
The flag calls `scripts/reset-admin-password.js` in non-interactive mode, writes a fresh random password into `data/ADMIN_CREDENTIALS.txt`, and prints the new credentials at the end of the installer run.
|
||||
|
||||
#### Configuring Admin Email
|
||||
|
||||
|
||||
+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.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "picpeak-backend",
|
||||
"version": "1.1.1",
|
||||
"version": "1.1.4",
|
||||
"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.4",
|
||||
"description": "Backend for PicPeak event photo sharing platform",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -7,12 +7,32 @@ const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const readline = require('readline');
|
||||
|
||||
const rl = readline.createInterface({
|
||||
const args = process.argv.slice(2);
|
||||
const hasFlag = (flag) => args.includes(flag);
|
||||
const getOption = (name) => {
|
||||
const index = args.indexOf(`--${name}`);
|
||||
if (index !== -1 && index + 1 < args.length) {
|
||||
return args[index + 1];
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const force = hasFlag('--force') || hasFlag('--yes') || hasFlag('--non-interactive');
|
||||
const credentialsFileArg = getOption('credentials-file');
|
||||
const resolvedCredentialsFile = credentialsFileArg
|
||||
? path.resolve(process.cwd(), credentialsFileArg)
|
||||
: path.join(__dirname, '..', '..', 'ADMIN_PASSWORD_RESET.txt');
|
||||
|
||||
const rl = force ? null : readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout
|
||||
});
|
||||
|
||||
async function question(prompt) {
|
||||
async function ask(prompt) {
|
||||
if (force) {
|
||||
return 'yes';
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
rl.question(prompt, resolve);
|
||||
});
|
||||
@@ -37,13 +57,18 @@ async function resetAdminPassword() {
|
||||
|
||||
console.log('Found admin user:', admin.username);
|
||||
console.log('Email:', admin.email);
|
||||
console.log('\nThis will reset the password for this admin account.');
|
||||
|
||||
const confirm = await question('\nDo you want to continue? (yes/no): ');
|
||||
|
||||
if (confirm.toLowerCase() !== 'yes' && confirm.toLowerCase() !== 'y') {
|
||||
console.log('\n❌ Password reset cancelled.');
|
||||
process.exit(0);
|
||||
if (!force) {
|
||||
console.log('\nThis will reset the password for this admin account.');
|
||||
}
|
||||
|
||||
const confirm = await ask('\nDo you want to continue? (yes/no): ');
|
||||
|
||||
if (!force) {
|
||||
const normalized = confirm.trim().toLowerCase();
|
||||
if (normalized !== 'yes' && normalized !== 'y') {
|
||||
console.log('\n❌ Password reset cancelled.');
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate new password
|
||||
@@ -60,39 +85,44 @@ async function resetAdminPassword() {
|
||||
});
|
||||
|
||||
// Save to file
|
||||
const resetInfoPath = path.join(__dirname, '..', '..', 'ADMIN_PASSWORD_RESET.txt');
|
||||
const credentialsDir = path.dirname(resolvedCredentialsFile);
|
||||
await fs.mkdir(credentialsDir, { recursive: true });
|
||||
|
||||
const adminUrl = `${process.env.ADMIN_URL || 'http://localhost:3001'}/admin`;
|
||||
const resetInfo = `
|
||||
========================================
|
||||
PicPeak Admin Password Reset
|
||||
PicPeak Admin Credentials
|
||||
========================================
|
||||
|
||||
Password has been reset for admin account:
|
||||
Your admin account has been reset with these credentials:
|
||||
|
||||
Username: admin
|
||||
New Password: ${newPassword}
|
||||
Username: ${admin.username}
|
||||
Email: ${admin.email}
|
||||
Password: ${newPassword}
|
||||
|
||||
IMPORTANT:
|
||||
1. You MUST change this password on next login
|
||||
IMPORTANT SECURITY NOTES:
|
||||
1. You MUST change this password after first login
|
||||
2. This file contains sensitive information
|
||||
3. Delete this file after noting the password
|
||||
|
||||
Login URL: ${process.env.ADMIN_URL || 'http://localhost:3001'}/admin
|
||||
Login URL: ${adminUrl}
|
||||
|
||||
Reset performed on: ${new Date().toISOString()}
|
||||
========================================
|
||||
`;
|
||||
|
||||
await fs.writeFile(resetInfoPath, resetInfo, 'utf8');
|
||||
await fs.writeFile(resolvedCredentialsFile, resetInfo, 'utf8');
|
||||
|
||||
console.log('\n✅ Password reset successful!\n');
|
||||
console.log('========================================');
|
||||
console.log('New Credentials:');
|
||||
console.log('========================================');
|
||||
console.log('Username: admin');
|
||||
console.log(`Username: ${admin.username}`);
|
||||
console.log(`Email: ${admin.email}`);
|
||||
console.log(`Password: ${newPassword}`);
|
||||
console.log('\n⚠️ IMPORTANT:');
|
||||
console.log('1. You will be required to change this password on next login');
|
||||
console.log('2. Credentials are also saved in: ADMIN_PASSWORD_RESET.txt');
|
||||
console.log(`2. Credentials are also saved in: ${resolvedCredentialsFile}`);
|
||||
console.log('3. Delete the file after noting the password');
|
||||
console.log('========================================\n');
|
||||
|
||||
@@ -100,10 +130,12 @@ Reset performed on: ${new Date().toISOString()}
|
||||
console.error('❌ Error resetting password:', error.message);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
rl.close();
|
||||
if (rl) {
|
||||
rl.close();
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Run the reset
|
||||
resetAdminPassword();
|
||||
resetAdminPassword();
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -108,6 +108,8 @@ If ADMIN_CREDENTIALS.txt is missing:
|
||||
- Check the console output from when you ran migrations
|
||||
- File is created in the backend directory root
|
||||
- File might have been deleted for security (as recommended)
|
||||
- Regenerate it by running `node scripts/reset-admin-password.js --force --credentials-file data/ADMIN_CREDENTIALS.txt`
|
||||
- When using the unified `setup.sh` installer for a reinstall, append `--force-admin-password-reset` to have the script perform the reset automatically
|
||||
|
||||
## Best Practices
|
||||
|
||||
@@ -161,4 +163,4 @@ If upgrading from the old system with hardcoded `admin123`:
|
||||
- [ ] Stored new password in password manager
|
||||
- [ ] Tested login with new password
|
||||
- [ ] Set up additional admin accounts if needed
|
||||
- [ ] Configured password policies for organization
|
||||
- [ ] Configured password policies for organization
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "picpeak-frontend",
|
||||
"version": "1.1.0",
|
||||
"version": "1.1.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "picpeak-frontend",
|
||||
"version": "1.1.0",
|
||||
"version": "1.1.4",
|
||||
"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.4",
|
||||
"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);
|
||||
};
|
||||
+68
-3
@@ -55,6 +55,7 @@ CUSTOM_PORT=""
|
||||
UNATTENDED=false
|
||||
UPDATE_MODE=false
|
||||
UNINSTALL_MODE=false
|
||||
FORCE_ADMIN_PASSWORD_RESET=false
|
||||
|
||||
################################################################################
|
||||
# Helper Functions
|
||||
@@ -132,6 +133,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
|
||||
}
|
||||
@@ -456,6 +488,15 @@ EOF
|
||||
# Run database migrations
|
||||
log_step "Running database migrations..."
|
||||
docker compose exec -T backend npm run migrate
|
||||
|
||||
if [[ "$FORCE_ADMIN_PASSWORD_RESET" == "true" ]]; then
|
||||
log_step "Resetting admin credentials..."
|
||||
if docker compose exec -T backend node scripts/reset-admin-password.js --force --credentials-file data/ADMIN_CREDENTIALS.txt; then
|
||||
docker compose cp backend:/app/data/ADMIN_CREDENTIALS.txt "$app_dir/data/ADMIN_CREDENTIALS.txt" 2>/dev/null || true
|
||||
else
|
||||
log_warn "Automatic admin password reset failed; run reset-admin-password.js inside the backend container."
|
||||
fi
|
||||
fi
|
||||
|
||||
log_success "Docker installation completed!"
|
||||
}
|
||||
@@ -602,7 +643,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 +710,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}
|
||||
@@ -705,6 +747,13 @@ EOF
|
||||
log_step "Initializing database..."
|
||||
cd "$NATIVE_APP_DIR/app/backend"
|
||||
run_as_user "npm run migrate"
|
||||
|
||||
if [[ "$FORCE_ADMIN_PASSWORD_RESET" == "true" ]]; then
|
||||
log_step "Resetting admin credentials..."
|
||||
if ! run_as_user "node scripts/reset-admin-password.js --force --credentials-file data/ADMIN_CREDENTIALS.txt"; then
|
||||
log_warn "Automatic admin password reset failed; please run reset-admin-password.js manually."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Create systemd services
|
||||
create_systemd_services
|
||||
@@ -957,7 +1006,7 @@ print_success_message() {
|
||||
fi
|
||||
else
|
||||
echo -e "Email: ${CYAN}$ADMIN_EMAIL${NC}"
|
||||
echo -e "Password: ${YELLOW}(credentials file not found)${NC}"
|
||||
echo -e "Password: ${YELLOW}(credentials file not found - rerun setup with --force-admin-password-reset or run node scripts/reset-admin-password.js manually)${NC}"
|
||||
fi
|
||||
echo
|
||||
echo -e "${YELLOW}⚠️ IMPORTANT: Change the admin password on first login!${NC}"
|
||||
@@ -1101,6 +1150,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
|
||||
@@ -1213,6 +1273,10 @@ parse_arguments() {
|
||||
SMTP_PASS="$2"
|
||||
shift 2
|
||||
;;
|
||||
--force-admin-password-reset)
|
||||
FORCE_ADMIN_PASSWORD_RESET=true
|
||||
shift
|
||||
;;
|
||||
--enable-ssl)
|
||||
ENABLE_SSL=true
|
||||
shift
|
||||
@@ -1258,6 +1322,7 @@ Options:
|
||||
--smtp-port PORT SMTP server port
|
||||
--smtp-user USER SMTP username
|
||||
--smtp-pass PASS SMTP password
|
||||
--force-admin-password-reset Regenerate admin credentials after setup
|
||||
--enable-ssl Enable HTTPS with Let's Encrypt
|
||||
--port PORT Custom port (native only)
|
||||
--update Update existing installation
|
||||
|
||||
@@ -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