Added Model Orders and Picture Upload
This commit is contained in:
parent
aeeefc89c1
commit
9467ff5f6b
21
internal/model/order.go
Normal file
21
internal/model/order.go
Normal file
@ -0,0 +1,21 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// OrderImage is one uploaded image belonging to an order.
|
||||
type OrderImage struct {
|
||||
ID string `json:"id"`
|
||||
OriginalName string `json:"original_name,omitempty"`
|
||||
ContentType string `json:"content_type,omitempty"`
|
||||
Filename string `json:"filename"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// Order groups an arbitrary number of customer images for printing.
|
||||
type Order struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Images []OrderImage `json:"images"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@ -2,8 +2,9 @@ package paths
|
||||
|
||||
// Runtime data directories (relative to process working directory).
|
||||
const (
|
||||
DataDir = "data"
|
||||
PlatesDir = "data/plates"
|
||||
SVGTemplateDir = "data/svg_template"
|
||||
ConfigurationsDir = "data/configurations"
|
||||
DataDir = "data"
|
||||
PlatesDir = "data/plates"
|
||||
SVGTemplateDir = "data/svg_template"
|
||||
ConfigurationsDir = "data/configurations"
|
||||
OrdersDir = "data/orders"
|
||||
)
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
internal/server/api/order.go
Normal file
213
internal/server/api/order.go
Normal 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)
|
||||
}
|
||||
}
|
||||
218
internal/store/order.go
Normal file
218
internal/store/order.go
Normal file
@ -0,0 +1,218 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"mime"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"printer.backend/internal/model"
|
||||
"printer.backend/internal/paths"
|
||||
)
|
||||
|
||||
// OrderStore persists orders as JSON files with images in per-order subdirectories.
|
||||
type OrderStore struct {
|
||||
dir string
|
||||
}
|
||||
|
||||
// NewOrderStore creates a store under dir (default data/orders).
|
||||
func NewOrderStore(dir string) *OrderStore {
|
||||
if dir == "" {
|
||||
dir = paths.OrdersDir
|
||||
}
|
||||
return &OrderStore{dir: dir}
|
||||
}
|
||||
|
||||
// List returns all orders sorted by creation time (newest first).
|
||||
func (s *OrderStore) List() ([]model.Order, error) {
|
||||
return listFromDir(s.dir,
|
||||
func(name string) bool { return filepath.Ext(name) == ".json" },
|
||||
"orders dir",
|
||||
func(o model.Order) time.Time { return o.CreatedAt },
|
||||
)
|
||||
}
|
||||
|
||||
// Get returns an order by ID.
|
||||
func (s *OrderStore) Get(id string) (model.Order, error) {
|
||||
path := s.metaPath(id)
|
||||
o, err := readJSON[model.Order](path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return model.Order{}, fmt.Errorf("order not found: %s", id)
|
||||
}
|
||||
return model.Order{}, err
|
||||
}
|
||||
if o.Images == nil {
|
||||
o.Images = []model.OrderImage{}
|
||||
}
|
||||
return o, nil
|
||||
}
|
||||
|
||||
// Save writes a new order and assigns an ID when empty.
|
||||
func (s *OrderStore) Save(o model.Order) (model.Order, error) {
|
||||
if err := ensureDir(s.dir); err != nil {
|
||||
return model.Order{}, err
|
||||
}
|
||||
if o.ID == "" {
|
||||
o.ID = uuid.NewString()
|
||||
}
|
||||
if o.Images == nil {
|
||||
o.Images = []model.OrderImage{}
|
||||
}
|
||||
now := stampNew(&o.CreatedAt)
|
||||
o.CreatedAt = now
|
||||
o.UpdatedAt = now
|
||||
|
||||
if err := writeJSON(s.metaPath(o.ID), o); err != nil {
|
||||
return model.Order{}, fmt.Errorf("write order: %w", err)
|
||||
}
|
||||
return o, nil
|
||||
}
|
||||
|
||||
// Update replaces order metadata (preserves id, images, created_at).
|
||||
func (s *OrderStore) Update(id string, o model.Order) (model.Order, error) {
|
||||
existing, err := s.Get(id)
|
||||
if err != nil {
|
||||
return model.Order{}, err
|
||||
}
|
||||
o.ID = existing.ID
|
||||
o.Images = existing.Images
|
||||
o.CreatedAt = existing.CreatedAt
|
||||
o.UpdatedAt = time.Now().UTC()
|
||||
|
||||
if err := writeJSON(s.metaPath(id), o); err != nil {
|
||||
return model.Order{}, fmt.Errorf("write order: %w", err)
|
||||
}
|
||||
return o, nil
|
||||
}
|
||||
|
||||
// Delete removes an order and all of its images.
|
||||
func (s *OrderStore) Delete(id string) error {
|
||||
if _, err := s.Get(id); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(s.metaPath(id)); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return os.RemoveAll(s.imagesDir(id))
|
||||
}
|
||||
|
||||
// AddImage stores image bytes and appends metadata to the order.
|
||||
func (s *OrderStore) AddImage(orderID, originalName string, data []byte, contentType string) (model.OrderImage, error) {
|
||||
if len(data) == 0 {
|
||||
return model.OrderImage{}, fmt.Errorf("empty image data")
|
||||
}
|
||||
order, err := s.Get(orderID)
|
||||
if err != nil {
|
||||
return model.OrderImage{}, err
|
||||
}
|
||||
|
||||
ext := extensionForImage(originalName, contentType)
|
||||
imageID := uuid.NewString()
|
||||
filename := imageID + ext
|
||||
|
||||
if err := ensureDir(s.imagesDir(orderID)); err != nil {
|
||||
return model.OrderImage{}, err
|
||||
}
|
||||
path := filepath.Join(s.imagesDir(orderID), filename)
|
||||
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||
return model.OrderImage{}, fmt.Errorf("write image: %w", err)
|
||||
}
|
||||
|
||||
img := model.OrderImage{
|
||||
ID: imageID,
|
||||
OriginalName: filepath.Base(originalName),
|
||||
ContentType: contentType,
|
||||
Filename: filename,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
order.Images = append(order.Images, img)
|
||||
order.UpdatedAt = time.Now().UTC()
|
||||
if err := writeJSON(s.metaPath(orderID), order); err != nil {
|
||||
_ = os.Remove(path)
|
||||
return model.OrderImage{}, fmt.Errorf("update order: %w", err)
|
||||
}
|
||||
return img, nil
|
||||
}
|
||||
|
||||
// DeleteImage removes one image from an order.
|
||||
func (s *OrderStore) DeleteImage(orderID, imageID string) error {
|
||||
order, err := s.Get(orderID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
idx := -1
|
||||
var filename string
|
||||
for i, img := range order.Images {
|
||||
if img.ID == imageID {
|
||||
idx = i
|
||||
filename = img.Filename
|
||||
break
|
||||
}
|
||||
}
|
||||
if idx < 0 {
|
||||
return fmt.Errorf("image not found: %s", imageID)
|
||||
}
|
||||
|
||||
path := filepath.Join(s.imagesDir(orderID), filename)
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
order.Images = append(order.Images[:idx], order.Images[idx+1:]...)
|
||||
order.UpdatedAt = time.Now().UTC()
|
||||
return writeJSON(s.metaPath(orderID), order)
|
||||
}
|
||||
|
||||
// ImagePath returns the filesystem path for an order image.
|
||||
func (s *OrderStore) ImagePath(orderID, imageID string) (string, model.OrderImage, error) {
|
||||
order, err := s.Get(orderID)
|
||||
if err != nil {
|
||||
return "", model.OrderImage{}, err
|
||||
}
|
||||
for _, img := range order.Images {
|
||||
if img.ID == imageID {
|
||||
path := filepath.Join(s.imagesDir(orderID), img.Filename)
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
return "", model.OrderImage{}, fmt.Errorf("image file missing: %w", err)
|
||||
}
|
||||
return path, img, nil
|
||||
}
|
||||
}
|
||||
return "", model.OrderImage{}, fmt.Errorf("image not found: %s", imageID)
|
||||
}
|
||||
|
||||
func (s *OrderStore) metaPath(id string) string {
|
||||
return filepath.Join(s.dir, id+".json")
|
||||
}
|
||||
|
||||
func (s *OrderStore) imagesDir(orderID string) string {
|
||||
return filepath.Join(s.dir, orderID)
|
||||
}
|
||||
|
||||
func extensionForImage(originalName, contentType string) string {
|
||||
if ext := filepath.Ext(originalName); ext != "" {
|
||||
return strings.ToLower(ext)
|
||||
}
|
||||
if exts, _ := mime.ExtensionsByType(contentType); len(exts) > 0 {
|
||||
return exts[0]
|
||||
}
|
||||
switch contentType {
|
||||
case "image/jpeg":
|
||||
return ".jpg"
|
||||
case "image/png":
|
||||
return ".png"
|
||||
case "image/webp":
|
||||
return ".webp"
|
||||
case "image/gif":
|
||||
return ".gif"
|
||||
case "image/svg+xml":
|
||||
return ".svg"
|
||||
default:
|
||||
return ".bin"
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user