diff --git a/lib/storage.mjs b/lib/storage.mjs index ddf265e..709251e 100644 --- a/lib/storage.mjs +++ b/lib/storage.mjs @@ -147,6 +147,28 @@ export function delete_component_template(id) { return store.delete(`ct:${id}`); } +// --- PDF files --- + +export function list_pdfs() { + const result = []; + for (const [key] of store.data.entries()) { + if (key.startsWith('pdf:')) result.push(store.get(key)); + } + return result.sort((a, b) => a.display_name.localeCompare(b.display_name)); +} + +export function get_pdf(id) { + return store.get(`pdf:${id}`) ?? null; +} + +export function set_pdf(pdf) { + store.set(`pdf:${pdf.id}`, pdf); +} + +export function delete_pdf(id) { + return store.delete(`pdf:${id}`); +} + // --- Grid images --- export function list_grid_images() { diff --git a/public/app.mjs b/public/app.mjs index b22ec53..8cb9d28 100644 --- a/public/app.mjs +++ b/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?.(); diff --git a/public/lib/api.mjs b/public/lib/api.mjs index badb410..e7bb470 100644 --- a/public/lib/api.mjs +++ b/public/lib/api.mjs @@ -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}`); diff --git a/public/style.css b/public/style.css index 6a283cf..2225620 100644 --- a/public/style.css +++ b/public/style.css @@ -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; +} diff --git a/public/templates.html b/public/templates.html index b77ce55..27f738f 100644 --- a/public/templates.html +++ b/public/templates.html @@ -57,6 +57,11 @@ +
+
Files
+
+
+
Inventory @@ -396,6 +401,11 @@
+
Files
+
+
+ +
@@ -553,6 +563,25 @@ + + +