Compare commits

..

6 Commits

Author SHA1 Message Date
Gitea Actions Bot a26dfd3d6f chore: bump version to 1.0.40
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-15 14:21:15 +00:00
paul 1db908771f fix: resolve multiple UI issues in admin panel
Test and Lint / backend-test (push) Successful in 1m11s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m13s
Version and Release / version-bump (push) Successful in 36s
Version and Release / trigger-drone (push) Successful in 3s
- Fixed dropdown menu visibility in events table by using fixed positioning
- Fixed double /api prefix in settings upload endpoints (favicon, watermark)
- Fixed thumbnail display in hero image selection by properly handling API paths
- Removed unnecessary console.log statements
- Added proper cleanup for dropdown on scroll/resize events

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-15 16:16:47 +02:00
Gitea Actions Bot 59651b8c24 chore: bump version to 1.0.39
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-15 11:04:23 +00:00
paul 7ccd48297f fix: resolve dropdown menu visibility issue in admin events table
Test and Lint / backend-test (push) Successful in 1m7s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m18s
Version and Release / version-bump (push) Successful in 38s
Version and Release / trigger-drone (push) Successful in 3s
- Changed Card container from overflow-hidden to overflow-visible
- Added overflow-y-visible to prevent vertical clipping
- Dropdown menu now properly displays even with few events
- Table only scrolls when content exceeds viewport height

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-15 12:59:54 +02:00
Gitea Actions Bot d05ff6380e chore: bump version to 1.0.38
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-15 09:51:55 +00:00
paul 605f773a7e fix: resolve gallery photo/thumbnail serving issues
Test and Lint / backend-test (push) Successful in 1m13s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m11s
Version and Release / version-bump (push) Successful in 34s
Version and Release / trigger-drone (push) Successful in 3s
- Change gallery photo URLs from static paths to API endpoints
- Add dedicated thumbnail serving endpoint for galleries
- Add test script to diagnose authentication issues
- Add nginx configuration documentation for Authorization header

This fixes the issue where photos and thumbnails work in admin but not
in gallery view. The problem was that static file routes with auth
middleware often have Authorization headers stripped by reverse proxies.
Using API endpoints ensures proper authentication handling.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-15 11:47:53 +02:00
15 changed files with 266 additions and 126 deletions
+1 -2
View File
@@ -11,8 +11,7 @@
**PicPeak** is a powerful, self-hosted open-source alternative to commercial photo-sharing platforms like PicDrop.com and Scrapbook.de. Designed specifically for photographers and event organizers, PicPeak makes it simple to share beautiful, time-limited photo galleries with clients while maintaining full control over your data and branding.
![PicPeak Gallery Preview](https://github.com/the-luap/picpeak/assets/placeholder-hero.png)
> 📸 *Gallery preview will be updated soon with latest interface*
![PicPeak Gallery Preview](docs/screenshot-gallery.png)
## 🌟 Why Choose PicPeak?
Binary file not shown.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "picpeak-backend",
"version": "1.0.37",
"version": "1.0.40",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picpeak-backend",
"version": "1.0.37",
"version": "1.0.40",
"dependencies": {
"adm-zip": "^0.5.16",
"archiver": "^5.3.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "picpeak-backend",
"version": "1.0.37",
"version": "1.0.40",
"description": "Backend for PicPeak event photo sharing platform",
"main": "server.js",
"scripts": {
-103
View File
@@ -1,103 +0,0 @@
# Storage Management Scripts
## check-storage.js
Checks the storage directory structure and verifies that photo files exist.
### Usage:
```bash
# Check overall storage structure
node scripts/check-storage.js
# Check specific event
node scripts/check-storage.js wedding-test-gallery-2025-07-14-1
```
### What it checks:
- Storage directory structure and permissions
- Event directories
- Photo files existence
- Thumbnail files existence
- Database vs filesystem consistency
## regenerate-thumbnails.js
Regenerates missing thumbnails for photos in the database.
### Usage:
```bash
# Regenerate all missing thumbnails
node scripts/regenerate-thumbnails.js
# Regenerate thumbnails for specific event (by ID)
node scripts/regenerate-thumbnails.js 2
```
### What it does:
- Scans photos in database
- Checks if thumbnails exist
- Generates missing thumbnails using Sharp
- Updates database with thumbnail paths
- Reports success/error statistics
### Prerequisites:
- Node.js environment
- Database access
- Write permissions to storage directory
- Sharp library installed
## Production Usage
On your production server:
1. First, check the storage structure:
```bash
cd /path/to/picpeak/backend
NODE_ENV=production node scripts/check-storage.js wedding-test-gallery-2025-07-14-1
```
2. If thumbnails are missing, regenerate them:
```bash
NODE_ENV=production node scripts/regenerate-thumbnails.js 2
```
Note: Replace `2` with the actual event ID from your database.
## cleanup-thumbnails.js
Cleans up temporary and orphaned thumbnail files.
### Usage:
```bash
# Dry run - see what would be deleted
node scripts/cleanup-thumbnails.js --dry-run
# Actually delete orphaned thumbnails
node scripts/cleanup-thumbnails.js
```
### What it does:
- Identifies temporary thumbnails (thumb_temp_*)
- Finds orphaned thumbnails not linked to any photo
- Removes unnecessary files to free up space
- Reports statistics on cleanup
## diagnose-thumbnails.js
Diagnoses why thumbnails might not be showing for a specific event.
### Usage:
```bash
node scripts/diagnose-thumbnails.js 2
```
### What it checks:
- Thumbnail paths in database vs filesystem
- Path format inconsistencies
- Missing thumbnail files
- Provides SQL to fix path issues
### Common Issues:
1. **Path mismatch**: Database has wrong thumbnail path format
2. **Missing files**: Thumbnails were never generated
3. **Permission issues**: Web server can't read thumbnail files
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env node
/**
* Script to test photo authentication
* Usage: node scripts/test-photo-auth.js <jwt-token>
*/
const axios = require('axios');
async function testPhotoAuth(token) {
if (!token) {
console.error('Usage: node scripts/test-photo-auth.js <jwt-token>');
console.error('\nTo get a token, login to a gallery and check localStorage for gallery_token_<slug>');
process.exit(1);
}
const baseUrl = process.env.API_URL || 'http://localhost:3001';
console.log(`Testing photo authentication with token: ${token.substring(0, 20)}...`);
console.log(`Base URL: ${baseUrl}\n`);
// Test URLs
const tests = [
{
name: 'Thumbnail via static route',
url: `${baseUrl}/thumbnails/thumb_Test_Gallery_uncategorized_5210.jpg`,
headers: { 'Authorization': `Bearer ${token}` }
},
{
name: 'Photo via static route',
url: `${baseUrl}/photos/wedding-test-gallery-2025-07-14-1/Test_Gallery_uncategorized_5210.jpg`,
headers: { 'Authorization': `Bearer ${token}` }
},
{
name: 'Gallery photos API',
url: `${baseUrl}/api/gallery/wedding-test-gallery-2025-07-14-1/photos`,
headers: { 'Authorization': `Bearer ${token}` }
}
];
for (const test of tests) {
console.log(`Testing: ${test.name}`);
console.log(`URL: ${test.url}`);
try {
const response = await axios.get(test.url, {
headers: test.headers,
validateStatus: () => true // Don't throw on any status
});
console.log(`Status: ${response.status}`);
console.log(`Headers:`, response.headers['content-type']);
if (response.status === 200) {
if (test.name.includes('API')) {
console.log(`Photos count: ${response.data.photos?.length || 0}`);
} else {
console.log(`Content length: ${response.headers['content-length']} bytes`);
}
} else {
console.log(`Error:`, response.data);
}
} catch (error) {
console.log(`Network error:`, error.message);
}
console.log('---\n');
}
// Decode token to show info
try {
const parts = token.split('.');
const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString());
console.log('Token payload:', payload);
} catch (error) {
console.log('Failed to decode token');
}
}
// Get token from command line
const token = process.argv[2];
testPhotoAuth(token).catch(error => {
console.error('Test failed:', error);
process.exit(1);
});
+38 -2
View File
@@ -136,8 +136,8 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
photos: photos.map(photo => ({
id: photo.id,
filename: photo.filename,
url: `/photos/${photo.path}`,
thumbnail_url: photo.thumbnail_path ? `/${photo.thumbnail_path}` : null,
url: `/api/gallery/${req.params.slug}/photo/${photo.id}`,
thumbnail_url: photo.thumbnail_path ? `/api/gallery/${req.params.slug}/thumbnail/${photo.id}` : null,
type: photo.type,
category_id: photo.category_id,
category_name: photo.category_name,
@@ -320,6 +320,42 @@ router.get('/:slug/photo/:photoId', verifyGalleryAccess, async (req, res) => {
}
});
// Serve thumbnail
router.get('/:slug/thumbnail/:photoId', verifyGalleryAccess, async (req, res) => {
try {
const { photoId } = req.params;
const photo = await db('photos')
.where({ id: photoId, event_id: req.event.id })
.first();
if (!photo || !photo.thumbnail_path) {
return res.status(404).json({ error: 'Thumbnail not found' });
}
const thumbPath = path.join(getStoragePath(), photo.thumbnail_path);
// Check if file exists
const fs = require('fs').promises;
try {
await fs.access(thumbPath);
} catch (error) {
return res.status(404).json({ error: 'Thumbnail file not found' });
}
// Set appropriate headers
res.setHeader('Content-Type', 'image/jpeg');
res.setHeader('Cache-Control', 'private, max-age=3600');
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
// Send file
res.sendFile(path.resolve(thumbPath));
} catch (error) {
console.error('Error serving thumbnail:', error);
res.status(500).json({ error: 'Failed to serve thumbnail' });
}
});
// Get photo stats
router.get('/:slug/stats', verifyGalleryAccess, async (req, res) => {
try {
View File
+59
View File
@@ -0,0 +1,59 @@
# Nginx Configuration Fix for Photo Authentication
If photos and thumbnails are not loading in gallery view but work in admin, it's likely that the Authorization header is being stripped by nginx or another reverse proxy.
## Common Issue
The `Authorization` header is often not passed through by default in nginx proxy configurations.
## Fix
Add these lines to your nginx configuration for the PicPeak location block:
```nginx
location / {
proxy_pass http://localhost:3001;
# Important: Pass the Authorization header
proxy_pass_header Authorization;
proxy_set_header Authorization $http_authorization;
# Other standard proxy headers
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
```
## Alternative Fix Using Traefik
If using Traefik, ensure headers are passed:
```yaml
services:
picpeak:
labels:
- "traefik.http.middlewares.picpeak-headers.headers.customrequestheaders.Authorization="
```
## Testing
1. Check if Authorization header is reaching the backend:
```bash
curl -H "Authorization: Bearer YOUR_TOKEN" https://picpeak.yourdomain.com/thumbnails/test.jpg -v
```
2. Check nginx logs to see if the header is present:
```bash
tail -f /var/log/nginx/access.log
```
## Docker Compose Fix
If using docker-compose with nginx proxy, add:
```yaml
environment:
- NGINX_PROXY_PASS_HEADER=Authorization
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "picpeak-frontend",
"version": "1.0.37",
"version": "1.0.40",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picpeak-frontend",
"version": "1.0.37",
"version": "1.0.40",
"dependencies": {
"@tanstack/react-query": "^5.0.0",
"@tiptap/extension-link": "^2.25.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "picpeak-frontend",
"private": true,
"version": "1.0.37",
"version": "1.0.40",
"type": "module",
"scripts": {
"dev": "vite",
@@ -62,9 +62,14 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
let imageUrl = src;
// Build full URL for the image
const fullImageUrl = imageUrl.startsWith('/') ? buildResourceUrl(imageUrl) : imageUrl;
// For API paths that start with /admin, we need to prepend /api
const fullImageUrl = imageUrl.startsWith('/admin')
? buildResourceUrl(`/api${imageUrl}`)
: imageUrl.startsWith('/')
? buildResourceUrl(imageUrl)
: imageUrl;
console.log('Fetching authenticated image:', fullImageUrl);
// console.log('Fetching authenticated image:', fullImageUrl);
const response = await fetch(fullImageUrl, {
headers: {
'Authorization': `Bearer ${token}`
+66 -8
View File
@@ -1,4 +1,4 @@
import React, { useState, useMemo } from 'react';
import React, { useState, useMemo, useEffect, useRef } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import {
Plus,
@@ -33,12 +33,47 @@ export const EventsListPage: React.FC = () => {
const [selectedEvents, setSelectedEvents] = useState<number[]>([]);
// const [showFilters, setShowFilters] = useState(false);
const [activeDropdown, setActiveDropdown] = useState<number | null>(null);
const [dropdownPosition, setDropdownPosition] = useState<{ top: number; left: number } | null>(null);
const [showBulkArchiveModal, setShowBulkArchiveModal] = useState(false);
// Get filter from URL
const statusFilter = searchParams.get('filter') as 'active' | 'archived' | null;
const isExpiringFilter = searchParams.get('filter') === 'expiring';
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as HTMLElement;
if (!target.closest('.dropdown-container')) {
setActiveDropdown(null);
setDropdownPosition(null);
}
};
if (activeDropdown !== null) {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}
}, [activeDropdown]);
// Update dropdown position on scroll/resize
useEffect(() => {
const handleScrollOrResize = () => {
if (activeDropdown !== null) {
setActiveDropdown(null);
setDropdownPosition(null);
}
};
window.addEventListener('scroll', handleScrollOrResize, true);
window.addEventListener('resize', handleScrollOrResize);
return () => {
window.removeEventListener('scroll', handleScrollOrResize, true);
window.removeEventListener('resize', handleScrollOrResize);
};
}, [activeDropdown]);
// Fetch events
const { data, isLoading, error } = useQuery({
queryKey: ['admin-events', statusFilter],
@@ -272,8 +307,8 @@ export const EventsListPage: React.FC = () => {
</Card>
{/* Events Table */}
<Card className="overflow-hidden">
<div className="overflow-x-auto">
<Card className="overflow-visible">
<div className="overflow-x-auto overflow-y-visible">
<table className="w-full">
<thead className="bg-neutral-50 border-b border-neutral-200">
<tr>
@@ -347,21 +382,38 @@ export const EventsListPage: React.FC = () => {
{event.expires_at ? format(parseISO(event.expires_at), 'MMM d, yyyy') : 'N/A'}
</td>
<td className="px-6 py-4 text-right">
<div className="relative inline-block text-left">
<div className="relative inline-block text-left dropdown-container">
<button
onClick={() => setActiveDropdown(activeDropdown === event.id ? null : event.id)}
onClick={(e) => {
e.stopPropagation();
if (activeDropdown === event.id) {
setActiveDropdown(null);
setDropdownPosition(null);
} else {
const rect = e.currentTarget.getBoundingClientRect();
setActiveDropdown(event.id);
setDropdownPosition({
top: rect.bottom + window.scrollY,
left: rect.right - 224 + window.scrollX // 224px = 14rem (w-56)
});
}
}}
className="text-neutral-400 hover:text-neutral-600 p-1"
>
<MoreVertical className="w-5 h-5" />
</button>
{activeDropdown === event.id && (
<div className="absolute right-0 z-10 mt-2 w-56 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5">
{activeDropdown === event.id && dropdownPosition && (
<div
className="fixed z-50 w-56 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5"
style={{ top: `${dropdownPosition.top}px`, left: `${dropdownPosition.left}px` }}
>
<div className="py-1">
<button
onClick={() => {
navigate(`/admin/events/${event.id}`);
setActiveDropdown(null);
setDropdownPosition(null);
}}
className="w-full text-left px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100 flex items-center gap-2"
>
@@ -374,7 +426,10 @@ export const EventsListPage: React.FC = () => {
target="_blank"
rel="noopener noreferrer"
className="w-full text-left px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100 flex items-center gap-2"
onClick={() => setActiveDropdown(null)}
onClick={() => {
setActiveDropdown(null);
setDropdownPosition(null);
}}
>
<ExternalLink className="w-4 h-4" />
{t('events.viewGallery')}
@@ -385,6 +440,7 @@ export const EventsListPage: React.FC = () => {
onClick={() => {
archiveMutation.mutate(event.id);
setActiveDropdown(null);
setDropdownPosition(null);
}}
className="w-full text-left px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100 flex items-center gap-2"
>
@@ -397,6 +453,7 @@ export const EventsListPage: React.FC = () => {
onClick={() => {
toast.info(t('events.downloadArchiveSoon'));
setActiveDropdown(null);
setDropdownPosition(null);
}}
className="w-full text-left px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100 flex items-center gap-2"
>
@@ -409,6 +466,7 @@ export const EventsListPage: React.FC = () => {
if (confirm(t('events.deleteEventConfirm'))) {
deleteMutation.mutate(event.id);
setActiveDropdown(null);
setDropdownPosition(null);
}
}}
className="w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-red-50 flex items-center gap-2"
+3 -3
View File
@@ -100,7 +100,7 @@ export const settingsService = {
formData.append('logo', file);
const response = await api.post<{ logoUrl: string }>(
'/api/admin/settings/logo',
'/admin/settings/logo',
formData,
{
headers: {
@@ -118,7 +118,7 @@ export const settingsService = {
formData.append('favicon', file);
const response = await api.post<{ faviconUrl: string }>(
'/api/admin/settings/favicon',
'/admin/settings/favicon',
formData,
{
headers: {
@@ -136,7 +136,7 @@ export const settingsService = {
formData.append('watermarkLogo', file);
const response = await api.post<{ watermarkLogoUrl: string }>(
'/api/admin/settings/branding/watermark-logo',
'/admin/settings/branding/watermark-logo',
formData,
{
headers: {