Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a4595e2ab2 | |||
| 0911711a37 | |||
| f2c7594b23 | |||
| 32355fabad | |||
| c127fd829d |
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "1.1.2",
|
"version": "1.1.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "1.1.2",
|
"version": "1.1.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.850.0",
|
"@aws-sdk/client-s3": "^3.850.0",
|
||||||
"@aws-sdk/lib-storage": "^3.850.0",
|
"@aws-sdk/lib-storage": "^3.850.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "1.1.2",
|
"version": "1.1.3",
|
||||||
"description": "Backend for PicPeak event photo sharing platform",
|
"description": "Backend for PicPeak event photo sharing platform",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"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 }))
|
.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()));
|
.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;
|
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
|
// Insert photos
|
||||||
for (const f of files) {
|
for (const f of dedupeMap.values()) {
|
||||||
// Infer type by subfolder names
|
// Infer type by subfolder names
|
||||||
const segs = f.rel.split(path.sep);
|
const segs = f.rel.split(path.sep);
|
||||||
let type = 'individual';
|
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 })
|
.where({ event_id: eventId, external_relpath: f.rel })
|
||||||
.first();
|
.first();
|
||||||
if (exists) { skipped++; continue; }
|
if (exists) { skipped++; continue; }
|
||||||
|
|
||||||
const stats = await fs.stat(f.full);
|
const stats = await fs.stat(f.full);
|
||||||
const inserted = await db('photos')
|
const inserted = await db('photos')
|
||||||
.insert({
|
.insert({
|
||||||
|
|||||||
@@ -291,6 +291,7 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
|||||||
protectionLevel={protectionLevel}
|
protectionLevel={protectionLevel}
|
||||||
useEnhancedProtection={useEnhancedProtection}
|
useEnhancedProtection={useEnhancedProtection}
|
||||||
initialShowFeedback={openFeedbackInitially}
|
initialShowFeedback={openFeedbackInitially}
|
||||||
|
onFeedbackChange={onFeedbackChange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ interface PhotoLightboxProps {
|
|||||||
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
|
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
|
||||||
useEnhancedProtection?: boolean;
|
useEnhancedProtection?: boolean;
|
||||||
initialShowFeedback?: boolean;
|
initialShowFeedback?: boolean;
|
||||||
|
onFeedbackChange?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||||
@@ -30,6 +31,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
|||||||
protectionLevel = 'standard',
|
protectionLevel = 'standard',
|
||||||
useEnhancedProtection = false,
|
useEnhancedProtection = false,
|
||||||
initialShowFeedback = false,
|
initialShowFeedback = false,
|
||||||
|
onFeedbackChange,
|
||||||
}) => {
|
}) => {
|
||||||
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
||||||
const [zoom, setZoom] = useState(1);
|
const [zoom, setZoom] = useState(1);
|
||||||
@@ -533,6 +535,9 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
|||||||
gallerySlug={slug}
|
gallerySlug={slug}
|
||||||
showComments={true}
|
showComments={true}
|
||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
|
onFeedbackUpdate={() => {
|
||||||
|
if (onFeedbackChange) onFeedbackChange();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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