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?.();