From 5d62050745bcf5039703ac5ee5ebb4b98b26e782 Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Wed, 22 Jul 2026 10:59:51 +0000 Subject: [PATCH] Photo lightbox, timeline photo previews, subtle edit/delete footer - Photos open in a full-screen lightbox overlay instead of navigating to a new tab (fixes PWA back-button issue) - DB migration adds photo_id to task_events; photo upload logs the reference so activity timeline can show a clickable thumbnail for each 'Photo added' event - Edit and Delete buttons moved to a quiet ghost-button footer at the bottom-right of the modal Co-Authored-By: Claude Sonnet 4.6 --- backend/src/db.js | 5 ++ backend/src/lib/task-core.js | 8 +-- backend/src/routes/photos.js | 2 +- frontend/src/components/TaskModal.tsx | 76 +++++++++++++++++++-------- frontend/src/index.css | 29 ++++++++++ frontend/src/types.ts | 1 + 6 files changed, 94 insertions(+), 27 deletions(-) diff --git a/backend/src/db.js b/backend/src/db.js index 092d5fb..40f4f76 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -162,6 +162,11 @@ export async function initDb() { CREATE INDEX IF NOT EXISTS push_subs_email_idx ON push_subscriptions (user_email); `) + // Incremental migrations — idempotent + await pool.query(` + ALTER TABLE task_events ADD COLUMN IF NOT EXISTS photo_id INT REFERENCES task_photos(id) ON DELETE SET NULL + `) + await seedDefaults() } diff --git a/backend/src/lib/task-core.js b/backend/src/lib/task-core.js index 5fce187..382de74 100644 --- a/backend/src/lib/task-core.js +++ b/backend/src/lib/task-core.js @@ -15,11 +15,11 @@ export const TRANSITIONS = { fixed: ['submitted'], // reopen only } -export async function logEvent(taskId, eventType, { fromStatus = null, toStatus = null, note = null, userName = null } = {}) { +export async function logEvent(taskId, eventType, { fromStatus = null, toStatus = null, note = null, userName = null, photoId = null } = {}) { await pool.query( - `INSERT INTO task_events (task_id, event_type, from_status, to_status, note, user_name) - VALUES ($1, $2, $3, $4, $5, $6)`, - [taskId, eventType, fromStatus, toStatus, note, userName] + `INSERT INTO task_events (task_id, event_type, from_status, to_status, note, user_name, photo_id) + VALUES ($1, $2, $3, $4, $5, $6, $7)`, + [taskId, eventType, fromStatus, toStatus, note, userName, photoId] ) } diff --git a/backend/src/routes/photos.js b/backend/src/routes/photos.js index 7d0d405..ebe9ff9 100644 --- a/backend/src/routes/photos.js +++ b/backend/src/routes/photos.js @@ -57,7 +57,7 @@ export async function photoRoutes(app, opts) { VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`, [taskId, fileData.originalName, filePath, 'image/jpeg', fileData.size, stage, req.user.email] ) - await logEvent(taskId, 'photo', { note: `Photo added (${stage})`, userName: req.user.name }) + await logEvent(taskId, 'photo', { note: `Photo added (${stage})`, userName: req.user.name, photoId: ins[0].id }) return ins[0] }) diff --git a/frontend/src/components/TaskModal.tsx b/frontend/src/components/TaskModal.tsx index 3fde73e..05b63bc 100644 --- a/frontend/src/components/TaskModal.tsx +++ b/frontend/src/components/TaskModal.tsx @@ -51,6 +51,8 @@ export default function TaskModal({ taskId, onClose, onChanged }: { const [showReassign, setShowReassign] = useState(false) const [assignment, setAssignment] = useState(null) + const [lightboxSrc, setLightboxSrc] = useState(null) + const [showEdit, setShowEdit] = useState(false) const [editTitle, setEditTitle] = useState('') const [editDescription, setEditDescription] = useState('') @@ -163,6 +165,7 @@ export default function TaskModal({ taskId, onClose, onChanged }: { } return ( + <>
e.stopPropagation()}>
@@ -175,16 +178,6 @@ export default function TaskModal({ taskId, onClose, onChanged }: { {task.template_id && Recurring}
- {canUpdate && ( - - )} - {user.is_admin && ( - - )}
@@ -271,9 +264,13 @@ export default function TaskModal({ taskId, onClose, onChanged }: {
{task.photos.map(p => (
- - {p.file_name} - + {p.file_name} setLightboxSrc(photoUrl(p.file_path))} + /> {(p.uploaded_by === user.email || canUpdate) && ( )} @@ -402,16 +399,24 @@ export default function TaskModal({ taskId, onClose, onChanged }: { {/* Thread */}
Activity
- {task.events.map(e => ( -
- -
- {eventLine(e) &&
{eventLine(e)}
} - {e.note &&
{e.note}
} -
{e.user_name || '—'} · {formatDateTime(e.created_at)}
+ {task.events.map(e => { + const linkedPhoto = e.photo_id ? task.photos.find(p => p.id === e.photo_id) : null + return ( +
+ +
+ {eventLine(e) &&
{eventLine(e)}
} + {e.note &&
{e.note}
} + {linkedPhoto && ( +
setLightboxSrc(photoUrl(linkedPhoto.file_path))}> + +
+ )} +
{e.user_name || '—'} · {formatDateTime(e.created_at)}
+
-
- ))} + ) + })}
{canReport && ( @@ -439,7 +444,34 @@ export default function TaskModal({ taskId, onClose, onChanged }: {
)} + + {/* Edit / Delete — bottom right, unobtrusive */} + {(canUpdate || user.is_admin) && ( +
+ {canUpdate && ( + + )} + {user.is_admin && ( + + )} +
+ )}
+ + {/* Lightbox */} + {lightboxSrc && ( +
setLightboxSrc(null)}> + e.stopPropagation()} /> + +
+ )} + ) } diff --git a/frontend/src/index.css b/frontend/src/index.css index 91248a8..17cc07a 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -336,6 +336,16 @@ html, body, #root { height: 100%; margin: 0; font-size: 14px; } display: flex; } .modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; flex-wrap: wrap; } +.modal-footer-actions { display: flex; gap: 12px; justify-content: flex-end; margin-top: 20px; padding-top: 12px; border-top: 1px solid var(--card-border); } +.btn-ghost-sm { + display: inline-flex; align-items: center; gap: 5px; + padding: 4px 8px; border-radius: 6px; border: none; background: none; + font-size: 12px; color: var(--text-mid); cursor: pointer; font-family: var(--font); + transition: color .12s, background .12s; +} +.btn-ghost-sm:hover { color: var(--text-dark); background: var(--card-border); } +.btn-ghost-danger { color: var(--danger); } +.btn-ghost-danger:hover { color: var(--danger); background: var(--danger-bg); } /* ── Timeline / thread ─────────────────────────────────────── */ .timeline { margin: 8px 0; } @@ -351,6 +361,9 @@ html, body, #root { height: 100%; margin: 0; font-size: 14px; } .timeline-body { flex: 1; min-width: 0; } .timeline-note { white-space: pre-wrap; } .timeline-meta { font-size: 11.5px; color: var(--text-mid); margin-top: 2px; } +.timeline-photo-thumb { margin: 6px 0 2px; cursor: pointer; display: inline-block; } +.timeline-photo-thumb img { width: 80px; height: 80px; object-fit: cover; border-radius: 6px; border: 1px solid var(--card-border); transition: opacity .12s; } +.timeline-photo-thumb img:hover { opacity: 0.8; } /* ── Photos ────────────────────────────────────────────────── */ .photo-grid { display: flex; gap: 8px; flex-wrap: wrap; margin: 8px 0; } @@ -380,6 +393,22 @@ html, body, #root { height: 100%; margin: 0; font-size: 14px; } padding: 0; } +/* ── Lightbox ──────────────────────────────────────────────── */ +.lightbox-overlay { + position: fixed; inset: 0; z-index: 200; + background: rgba(0,0,0,.92); + display: flex; align-items: center; justify-content: center; + padding: 16px; +} +.lightbox-img { max-width: 100%; max-height: 100%; object-fit: contain; border-radius: 4px; } +.lightbox-close { + position: absolute; top: 16px; right: 16px; + background: rgba(255,255,255,.15); border: none; border-radius: 50%; + width: 38px; height: 38px; display: flex; align-items: center; justify-content: center; + cursor: pointer; color: #fff; transition: background .12s; +} +.lightbox-close:hover { background: rgba(255,255,255,.28); } + /* ── Tables ────────────────────────────────────────────────── */ .table-wrap { overflow-x: auto; background: var(--card-bg); border: 1px solid var(--card-border); border-radius: var(--radius); box-shadow: var(--shadow-sm); } table.data { width: 100%; border-collapse: collapse; font-size: 13px; } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 9ef50d3..cf7ca40 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -116,6 +116,7 @@ export interface TaskEvent { to_status: TaskStatus | null note: string | null user_name: string | null + photo_id: number | null created_at: string }