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 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-22 10:59:51 +00:00
parent 4ab9eb4035
commit 5d62050745
6 changed files with 94 additions and 27 deletions

View file

@ -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()
}

View file

@ -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]
)
}

View file

@ -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]
})

View file

@ -51,6 +51,8 @@ export default function TaskModal({ taskId, onClose, onChanged }: {
const [showReassign, setShowReassign] = useState(false)
const [assignment, setAssignment] = useState<Assignment | null>(null)
const [lightboxSrc, setLightboxSrc] = useState<string | null>(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 (
<>
<div className="modal-overlay" onClick={onClose}>
<div className="modal" onClick={e => e.stopPropagation()}>
<div className="modal-header">
@ -175,16 +178,6 @@ export default function TaskModal({ taskId, onClose, onChanged }: {
{task.template_id && <span className="badge badge-outline">Recurring</span>}
</div>
</div>
{canUpdate && (
<button className="btn btn-sm" onClick={openEdit} title="Edit task">
<Pencil size={13} strokeWidth={1.75} /> Edit
</button>
)}
{user.is_admin && (
<button className="btn btn-sm btn-danger" onClick={() => setConfirmDelete(true)} title="Delete task">
<Trash2 size={13} strokeWidth={1.75} />
</button>
)}
<button className="modal-close" onClick={onClose}><X size={18} strokeWidth={1.75} /></button>
</div>
@ -271,9 +264,13 @@ export default function TaskModal({ taskId, onClose, onChanged }: {
<div className="photo-grid">
{task.photos.map(p => (
<div key={p.id} className="photo-thumb-wrap">
<a href={photoUrl(p.file_path)} target="_blank" rel="noreferrer">
<img className="photo-thumb" src={photoUrl(p.file_path)} alt={p.file_name} title={`${p.stage}${p.uploaded_by}`} />
</a>
<img
className="photo-thumb"
src={photoUrl(p.file_path)}
alt={p.file_name}
title={`${p.stage}${p.uploaded_by}`}
onClick={() => setLightboxSrc(photoUrl(p.file_path))}
/>
{(p.uploaded_by === user.email || canUpdate) && (
<button className="photo-del" onClick={() => run(() => deletePhoto(p.id))} title="Delete photo">×</button>
)}
@ -402,16 +399,24 @@ export default function TaskModal({ taskId, onClose, onChanged }: {
{/* Thread */}
<div className="section-title">Activity</div>
<div className="timeline">
{task.events.map(e => (
<div key={e.id} className="timeline-item">
<span className="timeline-icon"><EventIcon type={e.event_type} /></span>
<div className="timeline-body">
{eventLine(e) && <div><strong>{eventLine(e)}</strong></div>}
{e.note && <div className="timeline-note">{e.note}</div>}
<div className="timeline-meta">{e.user_name || '—'} · {formatDateTime(e.created_at)}</div>
{task.events.map(e => {
const linkedPhoto = e.photo_id ? task.photos.find(p => p.id === e.photo_id) : null
return (
<div key={e.id} className="timeline-item">
<span className="timeline-icon"><EventIcon type={e.event_type} /></span>
<div className="timeline-body">
{eventLine(e) && <div><strong>{eventLine(e)}</strong></div>}
{e.note && <div className="timeline-note">{e.note}</div>}
{linkedPhoto && (
<div className="timeline-photo-thumb" onClick={() => setLightboxSrc(photoUrl(linkedPhoto.file_path))}>
<img src={photoUrl(linkedPhoto.file_path)} alt="" />
</div>
)}
<div className="timeline-meta">{e.user_name || '—'} · {formatDateTime(e.created_at)}</div>
</div>
</div>
</div>
))}
)
})}
</div>
{canReport && (
@ -439,7 +444,34 @@ export default function TaskModal({ taskId, onClose, onChanged }: {
</div>
</div>
)}
{/* Edit / Delete — bottom right, unobtrusive */}
{(canUpdate || user.is_admin) && (
<div className="modal-footer-actions">
{canUpdate && (
<button className="btn-ghost-sm" onClick={openEdit} title="Edit task details">
<Pencil size={13} strokeWidth={1.75} /> Edit
</button>
)}
{user.is_admin && (
<button className="btn-ghost-sm btn-ghost-danger" onClick={() => setConfirmDelete(true)} title="Delete task">
<Trash2 size={13} strokeWidth={1.75} /> Delete
</button>
)}
</div>
)}
</div>
</div>
{/* Lightbox */}
{lightboxSrc && (
<div className="lightbox-overlay" onClick={() => setLightboxSrc(null)}>
<img className="lightbox-img" src={lightboxSrc} alt="" onClick={e => e.stopPropagation()} />
<button className="lightbox-close" onClick={() => setLightboxSrc(null)}>
<X size={20} strokeWidth={1.75} />
</button>
</div>
)}
</>
)
}

View file

@ -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; }

View file

@ -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
}