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:
parent
4ab9eb4035
commit
5d62050745
6 changed files with 94 additions and 27 deletions
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue