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:
@@ -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() {
|
||||
|
||||
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?.();
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
70
server.mjs
70
server.mjs
@@ -16,9 +16,11 @@ import {
|
||||
list_source_images, get_source_image, add_source_image, delete_source_image,
|
||||
list_grid_images, get_grid_image, set_grid_image, delete_grid_image,
|
||||
list_component_templates, get_component_template, set_component_template, delete_component_template,
|
||||
list_pdfs, get_pdf, set_pdf, delete_pdf,
|
||||
} from './lib/storage.mjs';
|
||||
|
||||
mkdirSync('./data/images', { recursive: true });
|
||||
mkdirSync('./data/pdfs', { recursive: true });
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
@@ -39,6 +41,23 @@ const upload = multer({
|
||||
limits: { fileSize: 20 * 1024 * 1024 },
|
||||
});
|
||||
|
||||
const pdf_upload = multer({
|
||||
storage: multer.diskStorage({
|
||||
destination: './data/pdfs',
|
||||
filename: (req, file, cb) => cb(null, generate_id() + '.pdf'),
|
||||
}),
|
||||
limits: { fileSize: 50 * 1024 * 1024 },
|
||||
fileFilter: (req, file, cb) => {
|
||||
if (file.mimetype === 'application/pdf' || extname(file.originalname).toLowerCase() === '.pdf') {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Only PDF files are allowed'));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
app.use('/pdf', express.static('./data/pdfs'));
|
||||
|
||||
function remove_image_file(img_id) {
|
||||
try { unlinkSync(join('./data/images', img_id)); } catch {}
|
||||
}
|
||||
@@ -116,11 +135,12 @@ app.get('/api/components/:id', (req, res) => {
|
||||
app.put('/api/components/:id', (req, res) => {
|
||||
const existing = get_component(req.params.id);
|
||||
if (!existing) return fail(res, 'not found', 404);
|
||||
const { name, description, fields } = req.body;
|
||||
const { name, description, fields, file_ids } = req.body;
|
||||
const updated = { ...existing, updated_at: Date.now() };
|
||||
if (name !== undefined) updated.name = name.trim();
|
||||
if (description !== undefined) updated.description = description.trim();
|
||||
if (fields !== undefined) updated.fields = fields;
|
||||
if (file_ids !== undefined) updated.file_ids = file_ids;
|
||||
set_component(updated);
|
||||
ok(res, { component: updated });
|
||||
});
|
||||
@@ -456,6 +476,54 @@ app.delete('/api/component-templates/:id', (req, res) => {
|
||||
ok(res);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PDF files
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
app.get('/api/pdfs', (req, res) => {
|
||||
ok(res, { pdfs: list_pdfs() });
|
||||
});
|
||||
|
||||
app.post('/api/pdfs', pdf_upload.single('file'), (req, res) => {
|
||||
if (!req.file) return fail(res, 'no file uploaded');
|
||||
const display_name = (req.body.display_name?.trim() || req.file.originalname).trim();
|
||||
if (list_pdfs().some(p => p.display_name === display_name)) {
|
||||
try { unlinkSync(join('./data/pdfs', req.file.filename)); } catch {}
|
||||
return fail(res, 'a file with that name already exists');
|
||||
}
|
||||
const pdf = {
|
||||
id: generate_id(),
|
||||
filename: req.file.filename,
|
||||
display_name,
|
||||
original_name: req.file.originalname,
|
||||
size: req.file.size,
|
||||
uploaded_at: Date.now(),
|
||||
};
|
||||
set_pdf(pdf);
|
||||
ok(res, { pdf });
|
||||
});
|
||||
|
||||
app.put('/api/pdfs/:id', (req, res) => {
|
||||
const pdf = get_pdf(req.params.id);
|
||||
if (!pdf) return fail(res, 'not found', 404);
|
||||
const display_name = req.body.display_name?.trim();
|
||||
if (!display_name) return fail(res, 'display_name is required');
|
||||
if (list_pdfs().some(p => p.display_name === display_name && p.id !== pdf.id)) {
|
||||
return fail(res, 'a file with that name already exists');
|
||||
}
|
||||
const updated = { ...pdf, display_name };
|
||||
set_pdf(updated);
|
||||
ok(res, { pdf: updated });
|
||||
});
|
||||
|
||||
app.delete('/api/pdfs/:id', (req, res) => {
|
||||
const pdf = get_pdf(req.params.id);
|
||||
if (!pdf) return fail(res, 'not found', 404);
|
||||
try { unlinkSync(join('./data/pdfs', pdf.filename)); } catch {}
|
||||
delete_pdf(req.params.id);
|
||||
ok(res);
|
||||
});
|
||||
|
||||
// SPA fallback — serve index.html for any non-API, non-asset path
|
||||
const INDEX_HTML = new URL('./public/index.html', import.meta.url).pathname;
|
||||
app.get('/{*path}', (req, res) => res.sendFile(INDEX_HTML));
|
||||
|
||||
Reference in New Issue
Block a user