Added Model Orders and Picture Upload

This commit is contained in:
2026-05-26 17:47:54 +02:00
parent aeeefc89c1
commit 9467ff5f6b
6 changed files with 856 additions and 6 deletions
+397 -2
View File
@@ -245,6 +245,92 @@
padding: 2rem 0;
text-align: center;
}
.order-images {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 1rem;
margin-top: 1.25rem;
}
.order-image-card {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.order-image-thumb {
display: block;
width: 100%;
padding: 0;
border: none;
background: #fff;
cursor: zoom-in;
}
.order-image-thumb img {
width: 100%;
aspect-ratio: 1;
object-fit: contain;
display: block;
background: #fff;
}
.order-lightbox {
position: fixed;
inset: 0;
z-index: 1000;
background: rgba(0, 0, 0, 0.88);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
cursor: zoom-out;
}
.order-lightbox img {
max-width: min(96vw, 1200px);
max-height: 82vh;
object-fit: contain;
background: #fff;
border-radius: 6px;
}
.order-lightbox-caption {
margin-top: 1rem;
color: var(--text);
font-size: 0.9rem;
text-align: center;
max-width: 90vw;
}
.order-lightbox-hint {
margin-top: 0.5rem;
color: var(--muted);
font-size: 0.8rem;
}
.order-image-meta {
padding: 0.5rem 0.65rem;
font-size: 0.75rem;
color: var(--muted);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.order-image-actions {
padding: 0 0.65rem 0.65rem;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.order-image-actions button {
width: 100%;
padding: 0.35rem 0.5rem;
font-size: 0.78rem;
}
[x-cloak] { display: none !important; }
input[type="file"].file-input {
margin-top: 0.75rem;
padding: 0.5rem;
font-size: 0.82rem;
}
@media (max-width: 720px) {
.app { flex-direction: column; }
.sidebar {
@@ -334,6 +420,29 @@
@click="newConfiguration()">+ Neu</button>
</div>
</div>
<div class="nav-section">
<button type="button" class="nav-section-header"
:class="{ active: section === 'orders' }"
@click="openSection('orders')">
Orders
<span class="nav-chevron" :class="{ open: expanded.orders }"></span>
</button>
<div class="nav-children" x-show="expanded.orders">
<template x-if="orders.length === 0 && !ordersLoading">
<p class="nav-empty">Keine Orders</p>
</template>
<template x-for="o in orders" :key="o.id">
<button type="button" class="nav-item"
:class="{ selected: section === 'orders' && orderEditingId === o.id }"
@click="selectOrder(o)"
x-text="sidebarOrderLabel(o)"></button>
</template>
<button type="button" class="nav-item add"
:class="{ selected: section === 'orders' && !orderEditingId }"
@click="newOrder()">+ Neu</button>
</div>
</div>
</nav>
<div class="sidebar-footer">
@@ -517,7 +626,78 @@
<p class="muted" x-show="layoutLoading">Berechne Layout…</p>
</div>
</div>
<!-- Orders -->
<div x-show="section === 'orders'">
<div class="main-header">
<h2 x-text="orderEditingId ? 'Order bearbeiten' : 'Neue Order'"></h2>
<p class="muted" x-show="!orderEditingId">Order anlegen, danach Bilder hochladen.</p>
<p class="muted" x-show="orderEditingId">Beliebig viele Bilder pro Order (PNG, JPEG, WebP, GIF).</p>
</div>
<div class="panel">
<form @submit.prevent="saveOrder()">
<div class="form-grid">
<div>
<label>Name (optional)</label>
<input type="text" x-model="orderForm.name" placeholder="z.B. Kunde Müller">
</div>
</div>
<div class="btn-row">
<button type="submit" :disabled="orderSaving">
<span x-text="orderSaving ? 'Speichere…' : (orderEditingId ? 'Speichern' : 'Anlegen')"></span>
</button>
<button type="button" class="danger" x-show="orderEditingId" @click="deleteOrder(orderEditingId)">Löschen</button>
</div>
<p class="msg-err" x-show="orderError" x-text="orderError"></p>
<p class="msg-ok" x-show="orderSuccess" x-text="orderSuccess"></p>
</form>
<div x-show="orderEditingId" style="margin-top: 1.5rem;">
<label>Bilder hochladen</label>
<input type="file" class="file-input" accept="image/*" multiple
:disabled="orderImageUploading || orderLoading"
@change="uploadOrderImages($event)">
<p class="muted" x-show="orderImageUploading">Lade hoch…</p>
<p class="muted" x-show="orderLoading">Lade Bilder…</p>
<div class="order-images" x-show="orderImages.length > 0">
<template x-for="img in orderImages" :key="img.id">
<div class="order-image-card">
<button type="button" class="order-image-thumb"
@click="openOrderImage(img)"
:title="(img.original_name || img.filename) + ' — Klicken für Vollbild'">
<img :src="orderImageUrl(orderEditingId, img.id)"
:alt="img.original_name || 'Bild'"
loading="lazy"
@error="onOrderImageError($event, img)">
</button>
<p class="order-image-meta" x-text="img.original_name || img.filename"></p>
<div class="order-image-actions">
<button type="button" class="secondary" @click="openOrderImage(img)">Vergrößern</button>
<button type="button" class="danger" @click="deleteOrderImage(img.id)">Entfernen</button>
</div>
</div>
</template>
</div>
<p class="muted" x-show="orderImages.length === 0 && !orderImageUploading && !orderLoading" style="margin-top: 1rem;">
Noch keine Bilder — Dateien oben auswählen.
</p>
</div>
</div>
</div>
</main>
<div class="order-lightbox" x-show="orderLightbox" x-cloak
@click="closeOrderLightbox()"
@keydown.escape.window="closeOrderLightbox()">
<template x-if="orderLightbox">
<div @click.stop style="display: contents;">
<img :src="orderImageUrl(orderLightbox.orderId, orderLightbox.imageId)" :alt="orderLightbox.label">
<p class="order-lightbox-caption" x-text="orderLightbox.label"></p>
<p class="order-lightbox-hint">Klicken oder Esc zum Schließen</p>
</div>
</template>
</div>
</div>
<script>
@@ -527,7 +707,7 @@
apiOk: null,
section: 'plates',
expanded: { plates: true, items: false, configurations: false },
expanded: { plates: true, items: false, configurations: false, orders: false },
plates: [],
platesLoading: false,
@@ -577,6 +757,19 @@
pdfGenerating: false,
_layoutTimer: null,
orders: [],
ordersLoading: false,
orderSaving: false,
orderImageUploading: false,
orderError: '',
orderSuccess: '',
orderEditingId: null,
orderImages: [],
orderForm: { name: '' },
orderLoading: false,
orderImageVersion: 0,
orderLightbox: null,
openSection(name) {
if (this.section === name) {
this.expanded[name] = !this.expanded[name];
@@ -622,6 +815,18 @@
this.cancelConfigEdit();
},
async selectOrder(o) {
this.section = 'orders';
this.expanded.orders = true;
await this.editOrder(o);
},
newOrder() {
this.section = 'orders';
this.expanded.orders = true;
this.cancelOrderEdit();
},
sidebarPlateLabel(p) {
const n = p.name || 'Platte';
return n + ' · ' + this.fmtSize(p.width_mm, p.height_mm);
@@ -631,6 +836,12 @@
return c.name || ('Konfiguration ' + c.id.slice(0, 8));
},
sidebarOrderLabel(o) {
const n = o.name || ('Order ' + o.id.slice(0, 8));
const count = (o.images || []).length;
return n + (count ? ' · ' + count + ' Bild' + (count === 1 ? '' : 'er') : '');
},
apiUrl(path) {
return `${this.apiBase.replace(/\/$/, '')}${path}`;
},
@@ -639,6 +850,31 @@
return `${this.apiUrl('/items/' + id + '/svg')}?t=${Date.now()}`;
},
orderImageUrl(orderId, imageId) {
return `${this.apiUrl('/orders/' + orderId + '/images/' + imageId)}?v=${this.orderImageVersion}`;
},
openOrderImage(img) {
if (!this.orderEditingId || !img?.id) return;
this.orderLightbox = {
orderId: this.orderEditingId,
imageId: img.id,
label: img.original_name || img.filename || 'Bild',
};
},
closeOrderLightbox() {
this.orderLightbox = null;
},
onOrderImageError(event, img) {
const el = event.target;
if (!el || el.dataset.retried) return;
el.dataset.retried = '1';
this.orderImageVersion++;
el.src = this.orderImageUrl(this.orderEditingId, img.id);
},
async init() {
try {
const res = await fetch('/config.json');
@@ -654,7 +890,12 @@
async onApiBaseChange() {
await this.checkAPI();
if (this.apiOk) {
await Promise.all([this.loadPlates(), this.loadItems(), this.loadConfigurations()]);
await Promise.all([
this.loadPlates(),
this.loadItems(),
this.loadConfigurations(),
this.loadOrders(),
]);
}
},
@@ -1072,6 +1313,160 @@
}
},
async loadOrders() {
this.ordersLoading = true;
try {
this.orders = await this.apiJSON('GET', '/orders') || [];
} catch (e) {
this.orderError = String(e.message || e);
} finally {
this.ordersLoading = false;
}
},
async editOrder(o) {
this.orderEditingId = o.id;
this.orderForm = { name: o.name || '' };
this.orderImages = o.images ? [...o.images] : [];
this.orderError = '';
this.orderSuccess = '';
this.orderLoading = true;
try {
const fresh = await this.apiJSON('GET', '/orders/' + o.id);
this.orderForm = { name: fresh.name || '' };
this.orderImages = fresh.images ? [...fresh.images] : [];
this.orderImageVersion++;
} catch (e) {
this.orderError = String(e.message || e);
} finally {
this.orderLoading = false;
}
},
cancelOrderEdit() {
this.orderEditingId = null;
this.orderForm = { name: '' };
this.orderImages = [];
this.orderLightbox = null;
this.orderError = '';
this.orderSuccess = '';
},
async saveOrder() {
this.orderSaving = true;
this.orderError = '';
this.orderSuccess = '';
const body = { name: this.orderForm.name };
try {
let saved;
if (this.orderEditingId) {
saved = await this.apiJSON('PUT', '/orders/' + this.orderEditingId, body);
this.orderSuccess = 'Order aktualisiert.';
} else {
saved = await this.apiJSON('POST', '/orders', body);
this.orderSuccess = 'Order angelegt.';
}
const keepId = saved?.id || this.orderEditingId;
await this.loadOrders();
if (keepId) {
const o = this.orders.find(x => x.id === keepId);
if (o) await this.editOrder(o);
else this.cancelOrderEdit();
} else {
this.cancelOrderEdit();
}
} catch (e) {
this.orderError = String(e.message || e);
} finally {
this.orderSaving = false;
}
},
async uploadOrderImages(event) {
const files = event.target?.files;
if (!files?.length || !this.orderEditingId) return;
this.orderImageUploading = true;
this.orderError = '';
this.orderSuccess = '';
const fd = new FormData();
for (const f of files) {
fd.append('images', f);
}
try {
const res = await fetch(this.apiUrl('/orders/' + this.orderEditingId + '/images'), {
method: 'POST',
body: fd,
});
const text = await res.text();
if (!res.ok) {
let msg = res.statusText;
try {
const err = JSON.parse(text);
if (err.error) msg = err.error;
} catch {
if (text) msg = text;
}
throw new Error(msg);
}
const result = text ? JSON.parse(text) : null;
this.orderImageVersion++;
if (result?.order) {
await this.editOrder(result.order);
} else {
await this.loadOrders();
const o = this.orders.find(x => x.id === this.orderEditingId);
if (o) await this.editOrder(o);
}
const n = (result?.added || []).length;
this.orderSuccess = n === 1 ? '1 Bild hochgeladen.' : n + ' Bilder hochgeladen.';
} catch (e) {
this.orderError = String(e.message || e);
} finally {
this.orderImageUploading = false;
if (event.target) event.target.value = '';
}
},
async deleteOrderImage(imageId) {
if (!this.orderEditingId) return;
if (!confirm('Bild wirklich entfernen?')) return;
this.orderError = '';
try {
const res = await fetch(
this.apiUrl('/orders/' + this.orderEditingId + '/images/' + imageId),
{ method: 'DELETE' },
);
if (!res.ok) {
const text = await res.text();
throw new Error(text || res.statusText);
}
this.orderImageVersion++;
await this.loadOrders();
const o = this.orders.find(x => x.id === this.orderEditingId);
if (o) await this.editOrder(o);
this.orderSuccess = 'Bild entfernt.';
} catch (e) {
this.orderError = String(e.message || e);
}
},
async deleteOrder(id) {
if (!confirm('Order inkl. aller Bilder wirklich löschen?')) return;
this.orderError = '';
try {
const res = await fetch(this.apiUrl('/orders/' + id), { method: 'DELETE' });
if (!res.ok) {
const text = await res.text();
throw new Error(text || res.statusText);
}
await this.loadOrders();
if (this.orderEditingId === id) this.cancelOrderEdit();
} catch (e) {
this.orderError = String(e.message || e);
}
},
refreshLayoutPreview() {
clearTimeout(this._layoutTimer);
this._layoutTimer = setTimeout(() => this.fetchLayoutPreview(), 200);
+2
View File
@@ -16,6 +16,7 @@ func NewHandler() http.Handler {
plates := store.NewPlateStore("")
items := store.NewItemStore("")
configs := store.NewConfigurationStore("")
orders := store.NewOrderStore("")
mux := http.NewServeMux()
mux.HandleFunc("GET /health", health)
@@ -30,6 +31,7 @@ func NewHandler() http.Handler {
mux.HandleFunc("DELETE /items/{id}", deleteItem(items))
mux.HandleFunc("GET /items/{id}/svg", serveItemSVG(items))
registerConfigurationRoutes(mux, configs, plates, items)
registerOrderRoutes(mux, orders)
return withCORS(mux)
}
+213
View File
@@ -0,0 +1,213 @@
package api
import (
"encoding/json"
"errors"
"io"
"mime"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"printer.backend/internal/model"
"printer.backend/internal/store"
)
func registerOrderRoutes(mux *http.ServeMux, orders *store.OrderStore) {
mux.HandleFunc("GET /orders", listOrders(orders))
mux.HandleFunc("POST /orders", saveOrder(orders))
mux.HandleFunc("GET /orders/{id}", getOrder(orders))
mux.HandleFunc("PUT /orders/{id}", updateOrder(orders))
mux.HandleFunc("DELETE /orders/{id}", deleteOrder(orders))
mux.HandleFunc("POST /orders/{id}/images", addOrderImages(orders))
mux.HandleFunc("DELETE /orders/{id}/images/{imageId}", deleteOrderImage(orders))
mux.HandleFunc("GET /orders/{id}/images/{imageId}", serveOrderImage(orders))
}
func listOrders(s *store.OrderStore) http.HandlerFunc {
return func(w http.ResponseWriter, _ *http.Request) {
list, err := s.List()
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
if list == nil {
list = []model.Order{}
}
for i := range list {
if list[i].Images == nil {
list[i].Images = []model.OrderImage{}
}
}
writeJSON(w, http.StatusOK, list)
}
}
type saveOrderRequest struct {
Name string `json:"name"`
}
func saveOrder(s *store.OrderStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req saveOrderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
saved, err := s.Save(model.Order{Name: req.Name})
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusCreated, saved)
}
}
func getOrder(s *store.OrderStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
o, err := s.Get(id)
if err != nil {
writeError(w, http.StatusNotFound, err)
return
}
writeJSON(w, http.StatusOK, o)
}
}
func updateOrder(s *store.OrderStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var req saveOrderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
saved, err := s.Update(id, model.Order{Name: req.Name})
if err != nil {
writeError(w, http.StatusNotFound, err)
return
}
writeJSON(w, http.StatusOK, saved)
}
}
func deleteOrder(s *store.OrderStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if err := s.Delete(id); err != nil {
writeError(w, http.StatusNotFound, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
func addOrderImages(s *store.OrderStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orderID := r.PathValue("id")
if _, err := s.Get(orderID); err != nil {
writeError(w, http.StatusNotFound, err)
return
}
if err := r.ParseMultipartForm(32 << 20); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
var files []*multipart.FileHeader
if r.MultipartForm != nil && len(r.MultipartForm.File["images"]) > 0 {
files = r.MultipartForm.File["images"]
} else if _, fh, err := r.FormFile("image"); err == nil {
files = []*multipart.FileHeader{fh}
} else {
writeError(w, http.StatusBadRequest, errors.New("image or images field required"))
return
}
added := make([]model.OrderImage, 0, len(files))
for _, fh := range files {
data, contentType, err := readUpload(fh)
if err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
img, err := s.AddImage(orderID, fh.Filename, data, contentType)
if err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
added = append(added, img)
}
order, err := s.Get(orderID)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusCreated, map[string]any{
"added": added,
"order": order,
})
}
}
func readUpload(fh *multipart.FileHeader) ([]byte, string, error) {
rc, err := fh.Open()
if err != nil {
return nil, "", err
}
defer rc.Close()
const maxImageSize = 32 << 20
data, err := io.ReadAll(io.LimitReader(rc, maxImageSize+1))
if err != nil {
return nil, "", err
}
if len(data) > maxImageSize {
return nil, "", errors.New("image too large")
}
contentType := http.DetectContentType(data)
return data, contentType, nil
}
func deleteOrderImage(s *store.OrderStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orderID := r.PathValue("id")
imageID := r.PathValue("imageId")
if err := s.DeleteImage(orderID, imageID); err != nil {
writeError(w, http.StatusNotFound, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
func serveOrderImage(s *store.OrderStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orderID := r.PathValue("id")
imageID := r.PathValue("imageId")
path, img, err := s.ImagePath(orderID, imageID)
if err != nil {
writeError(w, http.StatusNotFound, err)
return
}
data, err := os.ReadFile(path)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
contentType := img.ContentType
if contentType == "" {
contentType = mime.TypeByExtension(filepath.Ext(path))
}
if contentType == "" || contentType == "application/octet-stream" {
contentType = http.DetectContentType(data)
}
w.Header().Set("Content-Type", contentType)
w.Header().Set("Cache-Control", "no-cache")
_, _ = w.Write(data)
}
}