Fix issue #30: Critical bugs in Reference (external folder) mode

This commit fixes the core bugs that prevented Reference mode from functioning:

1. Missing external_relpath Error (CRITICAL FIX)
   - Root cause: photoResolver prioritized event.source_mode over photo.source_origin
   - Problem: Events in "reference" mode with uploaded photos would fail
     because uploaded photos have source_origin='managed' but were being
     treated as external photos (requiring external_relpath)
   - Fix: Prioritize photo.source_origin over event.source_mode
   - Result: Events can now have MIXED sources - imported external photos
     AND newly uploaded managed photos coexisting correctly
   - File: backend/src/services/photoResolver.js:19

2. Category Assignment Failure (CRITICAL FIX)
   - Root cause: Update endpoints modified category_id column but display
     used photo.type field ('individual' or 'collage')
   - Problem: Category changes appeared to succeed but had no visible effect
   - Fix: When category_id is 'individual' or 'collage', update the type
     field instead of category_id
   - Result: Category assignments now work correctly for all photos
   - Files: backend/src/routes/adminPhotos.js:489-497, 605-607

3. Scroll Button Non-Functional (UX FIX)
   - Root cause: Scroll indicator was purely visual (no click handler)
   - Problem: Users expected to click the animated chevron to scroll
   - Fix: Convert div to button with smooth scroll to grid section
   - Result: Scroll button now functions as expected with proper a11y
   - File: frontend/src/components/gallery/layouts/HeroGalleryLayout.tsx:165-184

Technical Details:

Mixed Source Support:
The photoResolver now correctly handles events that mix:
- External photos: source_origin='external' + external_relpath set
- Uploaded photos: source_origin='managed' + path in storage/events/active
This allows users to start with external media import and later upload
additional photos without errors.

Category/Type Distinction:
The system uses photo.type ('individual'|'collage') for display but also
has a legacy category_id column. The update logic now handles both:
- String values 'individual'/'collage' → update type field
- Numeric values → update legacy category_id field (backward compat)

Notes on Remaining Issues:

Issue #30 also mentioned:
4. Image display (cropped square) - This is by design. Thumbnails use
   fit='cover' by default for consistent grid layouts. Can be changed
   via app_settings.thumbnail_fit if needed.

5. Theme application - The "Apply Theme" button updates the form state
   correctly. Users need to click "Save Changes" to persist to database.
   This is standard form behavior, not a bug.

Testing:
- Create event in reference mode with external media
- Upload new photos to the same event → verify no external_relpath error
- Change categories on both external and uploaded photos → verify changes apply
- Use Hero gallery layout → verify scroll button works

Fixes #30
This commit is contained in:
Claude
2025-11-04 20:05:44 +00:00
parent 41628b0578
commit b2626918d3
3 changed files with 52 additions and 14 deletions
+30 -9
View File
@@ -473,21 +473,34 @@ router.patch('/:eventId/photos/:photoId', adminAuth, async (req, res) => {
try {
const { eventId, photoId } = req.params;
const { category_id } = req.body;
// Verify photo belongs to event
const photo = await db('photos')
.where({ id: photoId, event_id: eventId })
.first();
if (!photo) {
return res.status(404).json({ error: 'Photo not found' });
}
// Prepare update data
const updateData = {};
// Handle type-based categories ('individual' or 'collage')
// These are string values that map to the photo.type field
if (category_id === 'individual' || category_id === 'collage') {
updateData.type = category_id;
updateData.category_id = null; // Clear legacy category_id
} else {
// Handle legacy numeric category IDs
updateData.category_id = category_id || null;
}
// Update photo
await db('photos')
.where({ id: photoId })
.update({ category_id: category_id || null });
.update(updateData);
res.json({ message: 'Photo updated successfully' });
} catch (error) {
console.error('Error updating photo:', error);
@@ -584,17 +597,25 @@ router.post('/:eventId/photos/bulk-update', adminAuth, async (req, res) => {
return res.status(400).json({ error: 'Some photos do not belong to this event' });
}
// Update photos
// Prepare update data
const updateData = {};
if (updates.category_id !== undefined) {
updateData.category_id = updates.category_id || null;
// Handle type-based categories ('individual' or 'collage')
// These are string values that map to the photo.type field
if (updates.category_id === 'individual' || updates.category_id === 'collage') {
updateData.type = updates.category_id;
updateData.category_id = null; // Clear legacy category_id
} else {
// Handle legacy numeric category IDs
updateData.category_id = updates.category_id || null;
}
}
await db('photos')
.whereIn('id', photoIds)
.where('event_id', eventId)
.update(updateData);
res.json({ message: `${photoIds.length} photos updated successfully` });
} catch (error) {
console.error('Error bulk updating photos:', error);
+6 -2
View File
@@ -12,8 +12,12 @@ const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '.
function resolvePhotoFilePath(event, photo) {
if (!event || !photo) throw new Error('resolvePhotoFilePath requires event and photo');
const mode = (event.source_mode || photo.source_origin || 'managed');
if (mode === 'reference' || photo.source_origin === 'external') {
// IMPORTANT: photo.source_origin takes precedence over event.source_mode
// This allows events in "reference" mode to have mixed sources:
// - Imported photos: source_origin = 'external'
// - Uploaded photos: source_origin = 'managed'
const mode = (photo.source_origin || event.source_mode || 'managed');
if (mode === 'reference' || mode === 'external') {
if (!photo.external_relpath) {
throw new Error('Missing external_relpath for external photo');
}
@@ -162,13 +162,26 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
</div>
{/* Scroll Indicator */}
<div className="absolute bottom-8 left-1/2 transform -translate-x-1/2 animate-bounce">
<button
onClick={() => {
// Scroll to the grid section
const gridSection = document.getElementById('gallery-grid-section');
if (gridSection) {
gridSection.scrollIntoView({ behavior: 'smooth', block: 'start' });
} else {
// Fallback: scroll down by hero section height
window.scrollBy({ top: window.innerHeight * 0.9, behavior: 'smooth' });
}
}}
className="absolute bottom-8 left-1/2 transform -translate-x-1/2 animate-bounce cursor-pointer hover:scale-110 transition-transform focus:outline-none focus:ring-2 focus:ring-white focus:ring-opacity-50 rounded-full p-2"
aria-label="Scroll to gallery"
>
<ChevronDown className="w-8 h-8 text-white drop-shadow-lg" />
</div>
</button>
</div>
{/* Grid Section */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
<div id="gallery-grid-section" className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{remainingPhotos.map((photo) => {
const actualIndex = photos.findIndex(p => p.id === photo.id);
return (