SysOps: deploy-all — 2026-06-09 10:41 UTC

This commit is contained in:
sysops
2026-06-09 10:41:13 +00:00
parent 69fe67cc0e
commit 21ea3a2c81
82 changed files with 8906 additions and 981 deletions
+333 -105
View File
@@ -1,92 +1,173 @@
{% extends "base.html" %}
{% block title %}Packaging Studio · Foodlinkk{% endblock %}
{% block extra_head %}
<style>
.pack-grid { display: grid; grid-template-columns: 1fr 340px; gap: 1rem; }
@media (max-width: 1024px) { .pack-grid { grid-template-columns: 1fr; } }
.pack-preview {
min-height: 460px;
display: flex;
align-items: center;
justify-content: center;
background: radial-gradient(circle at top, rgba(0,229,255,.08), rgba(0,0,0,.35));
border: 1px solid rgba(0,229,255,.2);
border-radius: 12px;
overflow: auto;
padding: 1rem;
}
.pack-preview svg { width: 100%; max-height: 72vh; }
.pack-tools { display:flex; gap:.5rem; flex-wrap:wrap; margin-top:.75rem; }
.elements-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:.5rem; }
.project-list { max-height: 260px; overflow: auto; display:flex; flex-direction:column; gap:.5rem; }
.project-item { border:1px solid rgba(148,163,184,.15); border-radius:10px; padding:.6rem; background:rgba(0,0,0,.25); }
.project-item small { color: var(--text-secondary); display:block; margin-top:.25rem; }
</style>
<link rel="stylesheet" href="/static/css/packaging-studio.css?v=1" />
{% endblock %}
{% block content %}
<div x-data="packagingStudio()" x-init="init()">
<header class="page-header">
<h1>Packaging Studio</h1>
<p class="subtitle page-tip">Foodlinkk branded box, wrap en labels met live SVG preview.</p>
<div class="pack-studio" x-data="packagingStudio()" x-init="init()">
<header class="pack-hero">
<div>
<h1>📦 Packaging Studio</h1>
<p>Ontwerp verpakkingen met Python SVG-engine · koppel aan project · export naar NAS · email &amp; kopieer naar ander project.</p>
</div>
<div class="pack-toolbar">
<button class="btn btn-sm" @click="preview()" :disabled="loading">👁 Preview</button>
<button class="btn btn-primary btn-sm" @click="save()" :disabled="loading">
<span x-text="packagingId ? '💾 Bijwerken' : '💾 Opslaan'"></span>
</button>
<button class="btn btn-sm" @click="duplicate()" x-show="packagingId" :disabled="loading">📋 Dupliceer</button>
<button class="btn btn-sm" @click="showCopyModal=true" x-show="packagingId">📁 Kopieer naar project</button>
<button class="btn btn-sm" @click="showEmailModal=true" x-show="packagingId">✉ Email</button>
<a class="btn btn-sm btn-ghost" :href="downloadUrl('svg')" x-show="packagingId">SVG</a>
<a class="btn btn-sm btn-ghost" :href="downloadUrl('png')" x-show="packagingId">PNG</a>
<a class="btn btn-sm btn-ghost" :href="downloadUrl('pdf')" x-show="packagingId">PDF</a>
<button class="btn btn-sm btn-ghost" @click="newDesign()"> Nieuw</button>
</div>
</header>
<div class="pack-grid">
<section class="panel">
<div style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.75rem;">
<label>Type
<select class="form-input" x-model="form.type">
<option value="folding_box">Folding box</option>
<option value="wrap">Wrap</option>
<option value="round_label">Round label</option>
</select>
</label>
<label>Barcode value
<input class="form-input" x-model="form.barcode_value" placeholder="8710000000012" />
</label>
<label>Breedte (mm)<input type="number" class="form-input" x-model.number="form.width_mm" min="10" /></label>
<label>Hoogte (mm)<input type="number" class="form-input" x-model.number="form.height_mm" min="10" /></label>
<label>Diepte (mm)<input type="number" class="form-input" x-model.number="form.depth_mm" min="0" /></label>
<div class="pack-layout">
<aside class="pack-panel">
<div class="pack-tabs">
<button class="pack-tab" :class="{active: sideTab==='shape'}" @click="sideTab='shape'">Vorm</button>
<button class="pack-tab" :class="{active: sideTab==='brand'}" @click="sideTab='brand'">Brand</button>
<button class="pack-tab" :class="{active: sideTab==='text'}" @click="sideTab='text'">Tekst</button>
<button class="pack-tab" :class="{active: sideTab==='elements'}" @click="sideTab='elements'">Elementen</button>
<button class="pack-tab" :class="{active: sideTab==='project'}" @click="sideTab='project'">Project</button>
</div>
<h2 style="font-size:1rem;margin:1rem 0 .5rem;">Elementen</h2>
<div class="elements-grid">
<label><input type="checkbox" x-model="form.elements.barcode"> Barcode</label>
<label><input type="checkbox" x-model="form.elements.logo_area"> Logo area</label>
<label><input type="checkbox" x-model="form.elements.fold_lines"> Fold lines</label>
<label><input type="checkbox" x-model="form.elements.cut_lines"> Cut lines</label>
<div x-show="sideTab==='shape'">
<h3>Afmetingen &amp; type</h3>
<div class="pack-field-grid">
<label class="full">Design naam
<input class="form-input" x-model="form.design_name" placeholder="AH Halal Box v1" /></label>
<label class="full">Type
<select class="form-input" x-model="form.type">
<template x-for="t in types" :key="t.id">
<option :value="t.id" x-text="t.label"></option>
</template>
</select></label>
<label>Breedte (mm)<input type="number" class="form-input" x-model.number="form.width_mm" min="10" @input="debouncedPreview()" /></label>
<label>Hoogte (mm)<input type="number" class="form-input" x-model.number="form.height_mm" min="10" @input="debouncedPreview()" /></label>
<label>Diepte (mm)<input type="number" class="form-input" x-model.number="form.depth_mm" min="0" @input="debouncedPreview()" /></label>
<label>Bleed (mm)<input type="number" class="form-input" x-model.number="form.bleed_mm" min="0" max="20" @input="debouncedPreview()" /></label>
<label class="full">Barcode<input class="form-input" x-model="form.barcode_value" @input="debouncedPreview()" /></label>
<label class="full">QR URL<input class="form-input" x-model="form.qr_value" placeholder="https://foodlinkk.nl/product/..." @input="debouncedPreview()" /></label>
</div>
</div>
<div class="pack-tools">
<button class="btn btn-primary" @click="generate()" :disabled="loading">
<span x-show="!loading">Genereer verpakking</span>
<span x-show="loading">Genereren…</span>
</button>
<a class="btn btn-ghost" :href="downloadUrl('svg')" x-show="projectId">Download SVG</a>
<a class="btn btn-ghost" :href="downloadUrl('png')" x-show="projectId">Download PNG</a>
<a class="btn btn-ghost" :href="downloadUrl('pdf')" x-show="projectId">Download PDF</a>
<div x-show="sideTab==='brand'" x-cloak>
<h3>Kleuren &amp; stijl</h3>
<div class="pack-field-grid">
<template x-for="c in colorFields" :key="c.key">
<label class="full" x-text="c.label">
<div class="pack-color-row">
<input class="form-input" x-model="form.brand[c.key]" @input="debouncedPreview()" />
<input type="color" :value="form.brand[c.key]" @input="form.brand[c.key]=$event.target.value; debouncedPreview()" />
</div>
</label>
</template>
</div>
</div>
<div x-show="sideTab==='text'" x-cloak>
<h3>Producttekst</h3>
<div class="pack-field-grid">
<label class="full">Productnaam<input class="form-input" x-model="form.text.product_name" @input="debouncedPreview()" /></label>
<label class="full">Tagline<input class="form-input" x-model="form.text.tagline" @input="debouncedPreview()" /></label>
<label>Netto gewicht<input class="form-input" x-model="form.text.subtitle" placeholder="400g" @input="debouncedPreview()" /></label>
<label>Herkomst<input class="form-input" x-model="form.text.origin" @input="debouncedPreview()" /></label>
<label class="full">Ingrediënten<textarea class="form-input" rows="3" x-model="form.text.ingredients" @input="debouncedPreview()"></textarea></label>
<label class="full">THT / best before<input class="form-input" x-model="form.text.best_before" @input="debouncedPreview()" /></label>
</div>
</div>
<div x-show="sideTab==='elements'" x-cloak>
<h3>Print &amp; productie-elementen</h3>
<div class="pack-elements">
<template x-for="el in elementList" :key="el.key">
<label><input type="checkbox" x-model="form.elements[el.key]" @change="debouncedPreview()"> <span x-text="el.label"></span></label>
</template>
</div>
</div>
<div x-show="sideTab==='project'" x-cloak>
<h3>Project &amp; NAS</h3>
<div class="pack-field-grid">
<label class="full">Bestaand project
<select class="form-input" x-model="save.project_id">
<option value="">— Nieuw project —</option>
<template x-for="p in cockpitProjects" :key="p.id">
<option :value="p.id" x-text="p.name + (p.client_name ? ' · '+p.client_name : '')"></option>
</template>
</select></label>
<label class="full" x-show="!save.project_id">Nieuw projectnaam
<input class="form-input" x-model="save.project_name" placeholder="Jumbo pouch Q3" /></label>
<label class="full" x-show="!save.project_id">Klant
<select class="form-input" x-model="save.client_id">
<option value=""></option>
<template x-for="c in clients" :key="c.id"><option :value="c.id" x-text="c.name"></option></template>
</select></label>
</div>
<p class="page-tip" style="margin-top:0.75rem;font-size:0.72rem">Bij opslaan: SVG, PNG, PDF + manifest → <code>…/packaging/</code> op NAS.</p>
<div x-show="nasInfo.ok" class="pack-nas-badge" style="margin-top:0.5rem">✓ NAS export OK</div>
<div class="muted" style="font-size:0.68rem;margin-top:0.35rem;word-break:break-all" x-show="nasInfo.files?.svg" x-text="nasInfo.files?.svg"></div>
</div>
</aside>
<section class="pack-panel pack-preview-wrap">
<h3>Live preview</h3>
<div class="pack-preview-stage">
<div x-show="svg" x-html="svg"></div>
<p class="page-tip" x-show="!svg && !loading">Klik Preview of pas instellingen aan.</p>
<p class="page-tip" x-show="loading">Genereren…</p>
</div>
<div class="pack-meta-bar">
<span x-text="packagingId ? 'ID: '+packagingId.substring(0,8)+'…' : 'Nog niet opgeslagen'"></span>
<span x-text="form.type + ' · ' + form.width_mm + '×' + form.height_mm + '×' + form.depth_mm + ' mm'"></span>
</div>
</section>
<aside class="panel">
<h2 style="font-size:1rem;margin:0 0 .75rem;">Recente projecten</h2>
<div class="project-list">
<template x-for="p in projects" :key="p.id">
<div class="project-item">
<strong x-text="p.spec.type"></strong>
<small x-text="p.spec.width_mm + '×' + p.spec.height_mm + '×' + p.spec.depth_mm + ' mm'"></small>
<small x-text="(p.created_at || '').replace('T',' ').slice(0,19)"></small>
<button class="btn btn-ghost" style="margin-top:.4rem;" @click="loadProject(p.id)">Open</button>
</div>
<aside class="pack-panel">
<h3>Bibliotheek</h3>
<div class="pack-library">
<template x-for="p in library" :key="p.id">
<article class="pack-design-card" :class="{active: packagingId===p.id}" @click="loadDesign(p.id)">
<strong x-text="p.spec?.design_name || p.spec?.text?.product_name || p.spec?.type || 'Design'"></strong>
<small x-text="(p.project_name||'Los') + (p.client_name ? ' · '+p.client_name : '')"></small>
<small x-text="(p.created_at||'').replace('T',' ').slice(0,16)"></small>
</article>
</template>
<p class="page-tip" x-show="!projects.length">Nog geen packaging projecten.</p>
<p class="page-tip" x-show="!library.length">Nog geen designs opgeslagen.</p>
</div>
</aside>
</div>
<div class="panel" style="margin-top:1rem;">
<div class="pack-preview">
<div x-show="svg" x-html="svg"></div>
<p class="page-tip" x-show="!svg && !loading">Start met Genereren om de preview te zien.</p>
<div class="pack-modal-backdrop" x-show="showEmailModal" x-cloak @click="showEmailModal=false"></div>
<div class="pack-modal" x-show="showEmailModal" x-cloak>
<h3>✉ Design emailen</h3>
<label>Aan (komma-gescheiden)<input class="form-input" x-model="emailForm.to" placeholder="klant@example.com" /></label>
<label style="margin-top:0.5rem;display:block">Onderwerp<input class="form-input" x-model="emailForm.subject" /></label>
<label style="margin-top:0.5rem;display:block">Bericht<textarea class="form-input" rows="4" x-model="emailForm.body"></textarea></label>
<p class="page-tip" style="font-size:0.7rem;margin-top:0.5rem">Downloadlinks voor SVG, PNG en PDF worden automatisch toegevoegd.</p>
<div class="pack-modal-actions">
<button class="btn btn-sm" @click="showEmailModal=false">Annuleer</button>
<button class="btn btn-primary btn-sm" @click="sendEmail()">Verstuur</button>
</div>
</div>
<div class="pack-modal-backdrop" x-show="showCopyModal" x-cloak @click="showCopyModal=false"></div>
<div class="pack-modal" x-show="showCopyModal" x-cloak>
<h3>📁 Kopieer naar ander project</h3>
<label>Doelproject
<select class="form-input" x-model="copyTargetId">
<option value="">— Selecteer —</option>
<template x-for="p in cockpitProjects" :key="'cp'+p.id">
<option :value="p.id" x-text="p.name + (p.client_name ? ' · '+p.client_name : '')"></option>
</template>
</select></label>
<div class="pack-modal-actions">
<button class="btn btn-sm" @click="showCopyModal=false">Annuleer</button>
<button class="btn btn-primary btn-sm" @click="copyToProject()">Kopieer</button>
</div>
</div>
</div>
@@ -94,58 +175,205 @@
{% block scripts %}
<script>
function packagingStudio() {
const DEFAULT_BRAND = {
bg: '#0b1220', panel: '#101a2d', primary: '#00e5ff', secondary: '#ffd700',
accent: '#b8ff3c', text: '#e2e8f0', muted: '#94a3b8', cut_line: '#ef4444',
fold_line: '#60a5fa', bleed: '#f97316', halal: '#22c55e'
};
const DEFAULT_ELEMENTS = {
barcode: true, logo_area: true, fold_lines: true, cut_lines: true,
nutrition_panel: false, ingredients: true, halal_badge: false, window: false,
qr_code: false, glue_tabs: false, bleed: true, dimensions: true
};
return {
loading: false,
sideTab: 'shape',
svg: '',
projectId: '',
projects: [],
packagingId: '',
library: [],
cockpitProjects: [],
clients: [],
types: [],
nasInfo: {},
showEmailModal: false,
showCopyModal: false,
copyTargetId: '',
previewTimer: null,
save: { project_name: '', client_id: '', project_id: '{{ initial_project_id or "" }}' },
emailForm: { to: '', subject: 'Foodlinkk packaging design', body: 'Bijgevoegd vind je ons packaging design.' },
colorFields: [
{ key: 'primary', label: 'Primair' }, { key: 'secondary', label: 'Secundair' },
{ key: 'accent', label: 'Accent' }, { key: 'panel', label: 'Paneel' },
{ key: 'text', label: 'Tekst' }, { key: 'bg', label: 'Achtergrond' }
],
elementList: [
{ key: 'logo_area', label: 'Logo zone' }, { key: 'barcode', label: 'Barcode' },
{ key: 'qr_code', label: 'QR code' }, { key: 'nutrition_panel', label: 'Voedingswaarden' },
{ key: 'ingredients', label: 'Ingrediënten' }, { key: 'halal_badge', label: 'Halal badge' },
{ key: 'window', label: 'Venster' }, { key: 'fold_lines', label: 'Vouwlijnen' },
{ key: 'cut_lines', label: 'Snijlijnen' }, { key: 'glue_tabs', label: 'Lijmtabs' },
{ key: 'bleed', label: 'Bleed marge' }, { key: 'dimensions', label: 'Afmetingen' }
],
form: {
type: 'folding_box',
width_mm: 120,
height_mm: 80,
depth_mm: 40,
barcode_value: '8710000000012',
brand: {},
elements: { barcode: true, logo_area: true, fold_lines: true, cut_lines: true }
width_mm: 120, height_mm: 80, depth_mm: 40, bleed_mm: 3,
barcode_value: '8710000000012', qr_value: '',
design_name: 'Nieuw design',
brand: { ...DEFAULT_BRAND },
text: {
product_name: 'FOODLINKK Premium',
tagline: 'Authentiek · Vers · Halal',
subtitle: '400g', origin: 'Geproduceerd in NL',
ingredients: 'Kip 45%, water, specerijen, zout, ui, knoflook.',
best_before: 'Ten minste houdbaar tot: zie verpakking'
},
elements: { ...DEFAULT_ELEMENTS }
},
async init() { await this.refreshProjects(); },
downloadUrl(format) {
if (!this.projectId) return '#';
return '/api/packaging/download/' + this.projectId + '?format=' + format;
},
async refreshProjects() {
async init() {
await Promise.all([this.refreshLibrary(), this.loadCockpitProjects(), this.loadTypes()]);
try {
const r = await Cockpit.api('/api/packaging/projects?limit=20');
this.projects = r.items || [];
const r = await Cockpit.api('/clients');
this.clients = r.clients || r.items || [];
} catch (e) { this.clients = []; }
const pid = new URLSearchParams(location.search).get('project_id') || this.save.project_id;
if (pid) this.save.project_id = String(pid);
await this.preview();
},
async loadTypes() {
try {
const r = await fetch('/api/packaging/types').then(x => x.json());
this.types = r.items || [];
} catch (e) {
this.projects = [];
this.types = [
{ id: 'folding_box', label: 'Folding box' }, { id: 'wrap', label: 'Wrap' },
{ id: 'round_label', label: 'Rond label' }, { id: 'sleeve', label: 'Sleeve' },
{ id: 'pouch', label: 'Pouch' }, { id: 'tray', label: 'Tray' }
];
}
},
async loadProject(id) {
this.projectId = id;
try {
const data = await fetch('/api/packaging/download/' + id + '?format=svg').then(x => x.text());
this.svg = data;
} catch (e) {
Cockpit.toast('Kon project niet laden', 'error');
payload() {
const body = JSON.parse(JSON.stringify(this.form));
if (this.save.project_id) body.project_id = parseInt(this.save.project_id, 10);
else if (this.save.project_name) {
body.project_name = this.save.project_name;
if (this.save.client_id) body.client_id = parseInt(this.save.client_id, 10);
}
return body;
},
async generate() {
debouncedPreview() {
clearTimeout(this.previewTimer);
this.previewTimer = setTimeout(() => this.preview(), 450);
},
async preview() {
if (this.loading) return;
this.loading = true;
try {
const r = await Cockpit.api('/api/packaging/generate', {
method: 'POST',
body: JSON.stringify(this.form)
});
const r = await Cockpit.api('/api/packaging/preview', { method: 'POST', body: JSON.stringify(this.payload()) });
this.svg = r.svg || '';
this.projectId = r.id || '';
await this.refreshProjects();
Cockpit.toast('Packaging gegenereerd', 'success');
} catch (e) {
Cockpit.toast(e.message || 'Genereren mislukt', 'error');
}
} catch (e) { Cockpit.toast(e.message || 'Preview mislukt', 'error'); }
this.loading = false;
},
async save() {
if (this.loading) return;
this.loading = true;
try {
const url = this.packagingId ? '/api/packaging/' + this.packagingId : '/api/packaging/generate';
const method = this.packagingId ? 'PATCH' : 'POST';
const r = await Cockpit.api(url, { method, body: JSON.stringify(this.payload()) });
this.svg = r.svg || this.svg;
this.packagingId = r.id || this.packagingId;
this.nasInfo = r.nas || {};
await this.refreshLibrary();
await this.loadCockpitProjects();
Cockpit.toast(this.packagingId ? 'Design bijgewerkt' : 'Design opgeslagen', 'success');
} catch (e) { Cockpit.toast(e.message || 'Opslaan mislukt', 'error'); }
this.loading = false;
},
async duplicate() {
if (!this.packagingId) return;
try {
const r = await Cockpit.api('/api/packaging/' + this.packagingId + '/duplicate', { method: 'POST', body: '{}' });
await this.loadDesign(r.id);
Cockpit.toast('Design gedupliceerd', 'success');
} catch (e) { Cockpit.toast(e.message, 'error'); }
},
async copyToProject() {
if (!this.packagingId || !this.copyTargetId) return Cockpit.toast('Selecteer doelproject', 'warn');
try {
const r = await Cockpit.api('/api/packaging/' + this.packagingId + '/copy-to-project', {
method: 'POST',
body: JSON.stringify({ target_project_id: parseInt(this.copyTargetId, 10) })
});
this.showCopyModal = false;
await this.loadDesign(r.id);
Cockpit.toast('Gekopieerd naar project', 'success');
} catch (e) { Cockpit.toast(e.message, 'error'); }
},
async sendEmail() {
if (!this.packagingId) return;
const to = (this.emailForm.to || '').split(',').map(s => s.trim()).filter(Boolean);
if (!to.length) return Cockpit.toast('Vul ontvanger in', 'warn');
try {
await Cockpit.api('/api/packaging/' + this.packagingId + '/email', {
method: 'POST',
body: JSON.stringify({ ...this.emailForm, to })
});
this.showEmailModal = false;
Cockpit.toast('Email verstuurd', 'success');
} catch (e) { Cockpit.toast(e.message || 'Email mislukt — check Settings → Email', 'error'); }
},
newDesign() {
this.packagingId = '';
this.nasInfo = {};
this.form.design_name = 'Nieuw design';
this.preview();
},
downloadUrl(format) {
return this.packagingId ? '/api/packaging/download/' + this.packagingId + '?format=' + format : '#';
},
async refreshLibrary() {
try {
const r = await Cockpit.api('/api/packaging/projects?limit=40');
this.library = r.items || [];
} catch (e) { this.library = []; }
},
async loadCockpitProjects() {
try {
const r = await fetch('/api/projects?limit=80').then(x => x.json());
this.cockpitProjects = r.items || [];
} catch (e) { this.cockpitProjects = []; }
},
async loadDesign(id) {
try {
const r = await Cockpit.api('/api/packaging/' + id);
const item = r.item || {};
const spec = item.spec || {};
this.packagingId = item.id;
this.svg = item.svg || '';
if (spec.type) this.form.type = spec.type;
['width_mm','height_mm','depth_mm','bleed_mm','barcode_value','qr_value','design_name'].forEach(k => {
if (spec[k] !== undefined) this.form[k] = spec[k];
});
this.form.brand = { ...DEFAULT_BRAND, ...(spec.brand || {}) };
this.form.text = { ...this.form.text, ...(spec.text || {}) };
this.form.elements = { ...DEFAULT_ELEMENTS, ...(spec.elements || {}) };
if (item.cockpit_project_id) this.save.project_id = String(item.cockpit_project_id);
Cockpit.toast('Design geladen', 'info');
} catch (e) { Cockpit.toast('Laden mislukt', 'error'); }
}
};
}