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

@@ -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));