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:
186
public/app.mjs
186
public/app.mjs
@@ -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?.();
|
||||
|
||||
Reference in New Issue
Block a user