Add PDF file attachments to components

- Upload PDFs, rename them (conflict-checked), delete them
- Link/unlink files per component (many components can share a file)
- File picker dialog: browse existing files, rename inline, upload new
- Component detail shows linked files as clickable links
- Files stored in data/pdfs/, served at /pdf/:filename
- KV prefix: pdf:

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-22 00:19:30 +00:00
parent e91a656dc8
commit f0bedc80a7
6 changed files with 393 additions and 4 deletions

View File

@@ -26,13 +26,14 @@ let grid_draft = null; // { id?, name, rows, cols, panel_rows, panel_cols, panel
let current_panel_idx = null;
let all_drafts = [];
let all_templates = [];
let all_pdfs = [];
// ---------------------------------------------------------------------------
// Data loading
// ---------------------------------------------------------------------------
async function load_all() {
const [cf, ci, cmp, gr, dr, sr, ct] = await Promise.all([
const [cf, ci, cmp, gr, dr, sr, ct, pd] = await Promise.all([
api.get_fields(),
api.get_inventory(),
api.get_components(),
@@ -40,6 +41,7 @@ async function load_all() {
api.get_grid_drafts(),
api.get_source_images(),
api.get_component_templates(),
api.get_pdfs(),
]);
all_fields = cf.fields;
all_inventory = ci.entries;
@@ -48,6 +50,7 @@ async function load_all() {
all_drafts = dr.drafts;
all_sources = sr.sources;
all_templates = ct.templates;
all_pdfs = pd.pdfs;
compile_templates();
}
@@ -331,6 +334,26 @@ function render_detail_panel() {
e.target.value = '';
});
// Linked files
const files_list = qs(content, '.detail-files-list');
const linked_files = all_pdfs.filter(p => (comp.file_ids ?? []).includes(p.id));
if (linked_files.length === 0) {
const note = document.createElement('p');
note.className = 'detail-empty-note';
note.textContent = 'No files linked.';
files_list.replaceChildren(note);
} else {
files_list.replaceChildren(...linked_files.map(pdf => {
const a = document.createElement('a');
a.className = 'detail-file-link';
a.href = `/pdf/${pdf.filename}`;
a.target = '_blank';
a.rel = 'noopener';
a.textContent = pdf.display_name;
return a;
}));
}
// Inventory entries
const inv_list = qs(content, '.detail-inventory-list');
const entries = inventory_for_component(comp.id);
@@ -1283,6 +1306,106 @@ function open_cell_inventory(grid, row, col, e) {
// Dialog: Component
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Dialog: File picker
// ---------------------------------------------------------------------------
let file_picker_callback = null;
function open_file_picker(on_select) {
file_picker_callback = on_select;
const dlg = document.getElementById('dialog-file-picker');
render_file_picker_list();
dlg.showModal();
}
function render_file_picker_list() {
const dlg = document.getElementById('dialog-file-picker');
const list_el = qs(dlg, '#fp-list');
list_el.replaceChildren(...all_pdfs.map(pdf => {
const row = document.createElement('div');
row.className = 'fp-row';
const name_el = document.createElement('span');
name_el.className = 'fp-name';
name_el.textContent = pdf.display_name;
const rename_btn = document.createElement('button');
rename_btn.type = 'button';
rename_btn.className = 'btn btn-secondary btn-sm';
rename_btn.textContent = 'Rename';
rename_btn.addEventListener('click', () => {
const input = document.createElement('input');
input.type = 'text';
input.className = 'fp-rename-input';
input.value = pdf.display_name;
name_el.replaceWith(input);
rename_btn.textContent = 'Save';
input.focus();
input.select();
const do_rename = async () => {
const new_name = input.value.trim();
if (!new_name || new_name === pdf.display_name) {
render_file_picker_list();
return;
}
try {
const result = await api.rename_pdf(pdf.id, new_name);
const idx = all_pdfs.findIndex(p => p.id === pdf.id);
if (idx !== -1) all_pdfs[idx] = result.pdf;
all_pdfs.sort((a, b) => a.display_name.localeCompare(b.display_name));
render_file_picker_list();
} catch (err) {
alert(err.message);
}
};
rename_btn.onclick = do_rename;
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { do_rename(); }
if (e.key === 'Escape') { render_file_picker_list(); }
});
});
const select_btn = document.createElement('button');
select_btn.type = 'button';
select_btn.className = 'btn btn-primary btn-sm';
select_btn.textContent = 'Select';
select_btn.addEventListener('click', () => {
file_picker_callback?.(pdf);
dlg.close();
});
const del_btn = document.createElement('button');
del_btn.type = 'button';
del_btn.className = 'btn btn-danger btn-sm';
del_btn.textContent = '✕';
del_btn.title = 'Delete file';
del_btn.addEventListener('click', async () => {
if (!confirm(`Delete "${pdf.display_name}"? This cannot be undone.`)) return;
await api.delete_pdf(pdf.id);
all_pdfs = all_pdfs.filter(p => p.id !== pdf.id);
render_file_picker_list();
});
row.append(name_el, rename_btn, select_btn, del_btn);
return row;
}));
if (all_pdfs.length === 0) {
const note = document.createElement('p');
note.className = 'section-empty-note';
note.textContent = 'No files uploaded yet.';
list_el.replaceChildren(note);
}
}
// ---------------------------------------------------------------------------
// Dialog: Component
// ---------------------------------------------------------------------------
let component_dialog_callback = null;
function open_component_dialog(comp = null) {
@@ -1298,6 +1421,42 @@ function open_component_dialog(comp = null) {
desc_input.value = comp?.description ?? '';
const active_fields = new Map(Object.entries(comp?.fields ?? {}));
const active_file_ids = new Set(comp?.file_ids ?? []);
const file_rows_el = qs(dlg, '#c-file-rows');
function rebuild_file_rows() {
const linked = all_pdfs.filter(p => active_file_ids.has(p.id));
file_rows_el.replaceChildren(...linked.map(pdf => {
const row = document.createElement('div');
row.className = 'c-field-input-row';
const name_el = document.createElement('span');
name_el.className = 'c-field-input-label';
name_el.textContent = pdf.display_name;
const remove_btn = document.createElement('button');
remove_btn.type = 'button';
remove_btn.className = 'btn-icon btn-danger';
remove_btn.textContent = '✕';
remove_btn.title = 'Unlink file';
remove_btn.addEventListener('click', () => {
active_file_ids.delete(pdf.id);
rebuild_file_rows();
});
row.append(name_el, remove_btn);
return row;
}));
}
rebuild_file_rows();
qs(dlg, '#c-link-file').addEventListener('click', () => {
open_file_picker((pdf) => {
active_file_ids.add(pdf.id);
rebuild_file_rows();
});
});
function rebuild_field_rows() {
field_rows_el.replaceChildren(...[...active_fields.entries()].map(([fid, val]) => {
@@ -1393,7 +1552,7 @@ function open_component_dialog(comp = null) {
for (const [fid, val] of active_fields.entries()) {
if (val.trim()) fields[fid] = val.trim();
}
const body = { name, description: desc_input.value.trim(), fields };
const body = { name, description: desc_input.value.trim(), fields, file_ids: [...active_file_ids] };
if (comp) {
const result = await api.update_component(comp.id, body);
const idx = all_components.findIndex(c => c.id === comp.id);
@@ -1689,7 +1848,7 @@ async function init() {
const html = await fetch('/templates.html').then(r => r.text());
document.body.insertAdjacentHTML('beforeend', html);
for (const id of ['t-dialog-component', 't-dialog-inventory', 't-dialog-field', 't-dialog-confirm']) {
for (const id of ['t-dialog-component', 't-dialog-inventory', 't-dialog-field', 't-dialog-confirm', 't-dialog-file-picker']) {
document.body.appendChild(document.getElementById(id).content.cloneNode(true));
}
@@ -1729,6 +1888,27 @@ async function init() {
document.getElementById('dialog-field').close();
});
document.getElementById('fp-cancel').addEventListener('click', () => {
document.getElementById('dialog-file-picker').close();
});
qs(document.getElementById('dialog-file-picker'), '#fp-upload-btn').addEventListener('click', async () => {
const file_input = document.getElementById('fp-file-input');
const name_input = document.getElementById('fp-upload-name');
const file = file_input.files[0];
if (!file) return;
try {
const result = await api.upload_pdf(file, name_input.value.trim() || null);
all_pdfs.push(result.pdf);
all_pdfs.sort((a, b) => a.display_name.localeCompare(b.display_name));
file_input.value = '';
name_input.value = '';
render_file_picker_list();
} catch (err) {
alert(err.message);
}
});
document.getElementById('confirm-ok').addEventListener('click', async () => {
try {
await confirm_callback?.();

View File

@@ -44,6 +44,21 @@ export const create_component_template = (body) => req('POST', '/api/component-
export const update_component_template = (id, body) => req('PUT', `/api/component-templates/${id}`, body);
export const delete_component_template = (id) => req('DELETE', `/api/component-templates/${id}`);
// PDF files
export const get_pdfs = () => req('GET', '/api/pdfs');
export const rename_pdf = (id, display_name) => req('PUT', `/api/pdfs/${id}`, { display_name });
export const delete_pdf = (id) => req('DELETE', `/api/pdfs/${id}`);
export async function upload_pdf(file, display_name) {
const form = new FormData();
form.append('file', file);
if (display_name) form.append('display_name', display_name);
const res = await fetch('/api/pdfs', { method: 'POST', body: form });
const data = await res.json();
if (!data.ok) throw new Error(data.error ?? 'Upload failed');
return data;
}
// Grid images
export const get_grids = () => req('GET', '/api/grid-images');
export const get_grid = (id) => req('GET', `/api/grid-images/${id}`);

View File

@@ -1506,3 +1506,78 @@ nav {
padding: 0.25rem 0.6rem;
font-size: 0.82rem;
}
/* ===== FILE PICKER DIALOG ===== */
.fp-list {
display: flex;
flex-direction: column;
gap: 0.3rem;
max-height: 40vh;
overflow-y: auto;
margin-bottom: 1rem;
}
.fp-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.35rem 0.5rem;
border-radius: 4px;
background: var(--surface-raised);
}
.fp-name {
flex: 1;
font-size: 0.9rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fp-rename-input {
flex: 1;
font-size: 0.9rem;
background: var(--bg);
color: var(--text);
border: 1px solid var(--border-focus);
border-radius: 4px;
padding: 0.2rem 0.4rem;
}
.fp-upload-section {
border-top: 1px solid var(--border);
padding-top: 1rem;
margin-top: 0.25rem;
}
.fp-upload-row {
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
}
.fp-upload-row input[type=text] {
flex: 1;
min-width: 140px;
}
/* ===== DETAIL FILE LINKS ===== */
.detail-files-list {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.detail-file-link {
color: var(--accent);
text-decoration: none;
font-size: 0.9rem;
padding: 0.2rem 0;
}
.detail-file-link:hover {
text-decoration: underline;
}

View File

@@ -57,6 +57,11 @@
</div>
</div>
<div class="detail-block">
<div class="detail-block-label">Files</div>
<div class="detail-files-list"></div>
</div>
<div class="detail-block">
<div class="detail-block-label">
Inventory
@@ -396,6 +401,11 @@
<button type="button" class="btn btn-secondary btn-sm" id="c-new-field">New…</button>
</div>
</div>
<div class="form-section-label">Files</div>
<div id="c-file-rows"></div>
<div class="form-row">
<button type="button" class="btn btn-secondary btn-sm" id="c-link-file">Link file…</button>
</div>
<div class="dialog-actions">
<button type="button" class="btn btn-secondary" id="c-cancel">Cancel</button>
<button type="submit" class="btn btn-primary" id="c-save">Save</button>
@@ -553,6 +563,25 @@
</dialog>
</template>
<!-- ===== DIALOG: FILE PICKER ===== -->
<template id="t-dialog-file-picker">
<dialog id="dialog-file-picker" class="app-dialog app-dialog-wide">
<h2 class="dialog-title">Files</h2>
<div id="fp-list" class="fp-list"></div>
<div class="fp-upload-section">
<div class="form-section-label">Upload new PDF</div>
<div class="fp-upload-row">
<input type="file" id="fp-file-input" accept=".pdf,application/pdf">
<input type="text" id="fp-upload-name" placeholder="Display name (optional)" autocomplete="off">
<button type="button" class="btn btn-primary btn-sm" id="fp-upload-btn">Upload</button>
</div>
</div>
<div class="dialog-actions">
<button type="button" class="btn btn-secondary" id="fp-cancel">Close</button>
</div>
</dialog>
</template>
<!-- ===== CELL INVENTORY OVERLAY ===== -->
<template id="t-cell-inventory">
<div class="cell-inventory-overlay" id="cell-inventory-overlay">