Files
thejayman77 96d2a1a087 Add photo attachments to parts
A bag falling apart after forty years still has the part number printed on it,
and the picture carries more than any field you could retype it into. Photos
attach from the part form; on a phone the picker opens the camera directly.

Files live beside the database in the same volume, with only metadata in SQLite
— blobs there bloat the database and complicate the VACUUM INTO backup. The
browser downscales to 2000px before uploading, honouring EXIF orientation via
createImageBitmap, which keeps an image library and its native dependencies out
of the server entirely.

Uploads are sniffed by content rather than trusted by their declared type, so a
file that merely claims to be a JPEG cannot be stored and served back from this
origin as something a browser will execute. SVG is refused for the same reason.
Reads are capped rather than trusting Content-Length, at most 12 photos per
part, and a row that fails to insert takes its file with it.

Deleting a photo or a part removes the files, not just the rows, and
`app.admin prune-images` sweeps anything a crash stranded. The README's backup
procedure now covers both halves; capturing only the database would have
silently lost every photo.

python-multipart returns for the upload, pinned at 0.0.32 — the version removed
earlier was 0.0.20, which carried advisories. Audit is clean.

Two frontend bugs surfaced while testing this in the browser: a photo count
changing left the list row stale, and the part form opened from the list's
cached copy rather than fetching current data.

Checks go from 197 to 271.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 17:03:36 -04:00

311 lines
8.6 KiB
CSS

:root {
--bg: #12141a;
--panel: #1a1d26;
--panel-2: #222634;
--line: #2e3342;
--text: #e6e8ef;
--dim: #949bb0;
--accent: #5b9dff;
--good: #4ec98a;
--warn: #f0a848;
--bad: #f2685f;
--radius: 10px;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font: 15px/1.5 ui-sans-serif, -apple-system, "Segoe UI", Roboto, sans-serif;
-webkit-text-size-adjust: 100%;
}
button, input, select, textarea { font: inherit; color: inherit; }
button {
background: var(--panel-2);
border: 1px solid var(--line);
color: var(--text);
border-radius: var(--radius);
padding: 8px 12px;
cursor: pointer;
min-height: 38px;
}
button:hover { border-color: #414861; }
button.primary { background: var(--accent); border-color: var(--accent); color: #08101f; font-weight: 600; }
button.danger { color: var(--bad); }
button.ghost { background: transparent; }
button.small { padding: 4px 9px; min-height: 30px; font-size: 13px; }
input, select, textarea {
background: var(--panel-2);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 9px 11px;
width: 100%;
min-height: 38px;
}
input:focus, select:focus, textarea:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
textarea { resize: vertical; min-height: 70px; }
label { display: block; font-size: 12px; color: var(--dim); margin-bottom: 4px; text-transform: uppercase; letter-spacing: .04em; }
.hidden { display: none !important; }
.mobile-only { display: none; }
/* --- login --- */
#login {
min-height: 100dvh;
display: grid;
place-items: center;
padding: 24px;
}
#login form {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 14px;
padding: 28px;
width: min(360px, 100%);
}
#login h1 { margin: 0 0 6px; font-size: 20px; }
#login p { margin: 0 0 18px; color: var(--dim); font-size: 13px; }
/* --- shell --- */
header {
position: sticky;
top: 0;
z-index: 20;
background: rgba(18,20,26,.94);
backdrop-filter: blur(8px);
border-bottom: 1px solid var(--line);
padding: 10px 16px;
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
header .brand { font-weight: 700; letter-spacing: -.01em; white-space: nowrap; }
header .search { flex: 1 1 240px; min-width: 160px; }
header .stat { color: var(--dim); font-size: 13px; white-space: nowrap; }
.banner {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
flex-wrap: wrap;
margin: 12px 16px -4px;
padding: 10px 14px;
border: 1px solid var(--warn);
border-radius: var(--radius);
background: rgba(240, 168, 72, .10);
color: var(--warn);
font-size: 14px;
}
.layout { display: flex; align-items: flex-start; gap: 18px; padding: 16px; max-width: 1400px; margin: 0 auto; }
aside {
width: 250px;
flex: 0 0 250px;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 12px;
padding: 12px;
position: sticky;
top: 68px;
max-height: calc(100dvh - 84px);
overflow-y: auto;
}
aside h3 { font-size: 11px; text-transform: uppercase; letter-spacing: .07em; color: var(--dim); margin: 14px 0 6px; }
aside h3:first-child { margin-top: 0; }
.filter {
display: flex;
justify-content: space-between;
gap: 8px;
padding: 5px 8px;
border-radius: 7px;
cursor: pointer;
font-size: 14px;
}
.filter:hover { background: var(--panel-2); }
.filter.active { background: var(--accent); color: #08101f; font-weight: 600; }
.filter .count { color: var(--dim); font-size: 12px; }
.filter.active .count { color: #08101f; }
.filter.child { padding-left: 20px; font-size: 13px; }
main { flex: 1; min-width: 0; }
.toolbar { display: flex; gap: 10px; align-items: center; margin-bottom: 12px; flex-wrap: wrap; }
.toolbar .grow { flex: 1; }
.toolbar select { width: auto; min-width: 150px; }
/* --- part rows --- */
.part {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 12px;
padding: 12px 14px;
margin-bottom: 9px;
display: flex;
gap: 14px;
align-items: center;
flex-wrap: wrap;
}
.part:hover { border-color: #3c445e; }
.part .body { flex: 1 1 260px; min-width: 0; cursor: pointer; }
.part .name { font-weight: 600; }
.part .meta { color: var(--dim); font-size: 13px; margin-top: 2px; word-break: break-word; }
.part .chips { margin-top: 6px; display: flex; gap: 5px; flex-wrap: wrap; }
.chip {
background: var(--panel-2);
border: 1px solid var(--line);
border-radius: 999px;
padding: 1px 9px;
font-size: 12px;
color: var(--dim);
}
.chip.spec { color: var(--text); }
.chip.tag { color: var(--accent); border-color: #2c4470; }
.qty { display: flex; align-items: center; gap: 6px; }
.qty .value { min-width: 84px; text-align: right; font-variant-numeric: tabular-nums; font-weight: 600; }
.qty .value .unit { color: var(--dim); font-weight: 400; font-size: 13px; margin-left: 3px; }
.qty.low .value { color: var(--warn); }
.qty.zero .value { color: var(--bad); }
.empty { text-align: center; color: var(--dim); padding: 60px 20px; }
/* --- modal --- */
.overlay {
position: fixed;
inset: 0;
background: rgba(6,8,13,.72);
display: grid;
place-items: center;
padding: 16px;
z-index: 50;
overflow-y: auto;
}
.modal {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 14px;
width: min(720px, 100%);
max-height: calc(100dvh - 32px);
display: flex;
flex-direction: column;
margin: auto;
}
.modal header { position: static; background: none; backdrop-filter: none; justify-content: space-between; }
.modal .content { padding: 16px; overflow-y: auto; }
.modal footer {
padding: 12px 16px;
border-top: 1px solid var(--line);
display: flex;
gap: 10px;
justify-content: flex-end;
flex-wrap: wrap;
}
.modal footer .grow { flex: 1; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.grid .full { grid-column: 1 / -1; }
.spec-row { display: flex; gap: 8px; margin-bottom: 7px; align-items: center; }
.spec-row input:first-child { flex: 0 0 38%; }
.shots { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 8px; }
.shot {
position: relative;
width: 96px;
border: 1px solid var(--line);
border-radius: 8px;
overflow: hidden;
background: var(--panel-2);
}
.shot img { display: block; width: 96px; height: 96px; object-fit: cover; cursor: zoom-in; }
.shot .cap {
width: 100%;
border: 0;
border-top: 1px solid var(--line);
border-radius: 0;
background: var(--panel);
font-size: 11px;
padding: 4px 6px;
min-height: 0;
}
.shot .rm {
position: absolute;
top: 3px;
right: 3px;
min-height: 0;
padding: 0 6px;
font-size: 12px;
line-height: 18px;
background: rgba(6, 8, 13, .78);
border-color: transparent;
color: var(--bad);
}
.shot.pending { opacity: .6; }
.shot.pending::after {
content: "queued";
position: absolute;
left: 3px; top: 3px;
background: rgba(6,8,13,.78);
font-size: 10px;
padding: 1px 5px;
border-radius: 4px;
color: var(--dim);
}
.lightbox {
position: fixed;
inset: 0;
background: rgba(4, 6, 10, .93);
display: grid;
place-items: center;
z-index: 200;
padding: 20px;
cursor: zoom-out;
}
.lightbox img { max-width: 100%; max-height: 88vh; border-radius: 10px; }
.lightbox .cap { margin-top: 10px; color: var(--dim); font-size: 13px; text-align: center; }
.history { font-size: 13px; color: var(--dim); }
.history div { padding: 4px 0; border-bottom: 1px solid var(--line); display: flex; justify-content: space-between; gap: 10px; }
.pos { color: var(--good); }
.neg { color: var(--bad); }
.node-row { display: flex; gap: 8px; align-items: center; padding: 6px 0; border-bottom: 1px solid var(--line); }
.node-row .path { flex: 1; min-width: 0; word-break: break-word; }
.node-row .count { color: var(--dim); font-size: 12px; }
.toast {
position: fixed;
bottom: 18px;
left: 50%;
transform: translateX(-50%);
background: var(--panel-2);
border: 1px solid var(--line);
border-radius: 10px;
padding: 10px 16px;
z-index: 100;
box-shadow: 0 8px 30px rgba(0,0,0,.5);
}
.toast.bad { border-color: var(--bad); color: var(--bad); }
@media (max-width: 820px) {
.layout { flex-direction: column; padding: 12px; gap: 12px; }
/* On a phone the filter list is longer than the screen, so it starts closed:
search and results are what you came for, filters are a tap away. */
aside { width: 100%; flex: none; position: static; max-height: none; display: none; }
aside.open { display: block; }
.mobile-only { display: inline-flex; align-items: center; }
#filters-btn.active { background: var(--accent); border-color: var(--accent); color: #08101f; }
.grid { grid-template-columns: 1fr; }
header { padding: 8px 12px; }
header .stat { display: none; }
}