diff --git a/.gitignore b/.gitignore
index b87b1c3..adbb97d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1 @@
-svg_template/
\ No newline at end of file
+data/
\ No newline at end of file
diff --git a/cmd/generate_template.go b/cmd/generate_template.go
index 13df8bb..9bbcb7f 100644
--- a/cmd/generate_template.go
+++ b/cmd/generate_template.go
@@ -3,8 +3,10 @@ package cmd
import (
"fmt"
"path/filepath"
+ "strings"
"github.com/spf13/cobra"
+ "printer.backend/internal/model"
"printer.backend/internal/svgtemplate"
)
@@ -22,12 +24,27 @@ var generateTemplateCmd = &cobra.Command{
RunE: func(cmd *cobra.Command, args []string) error {
data := svgtemplate.Build(sizeFlag, bleedFlag, marginFlag, paddingFlag)
- outPath := filepath.Join(svgtemplate.OutputDir, filepath.Base(outputFlag))
+ base := filepath.Base(outputFlag)
+ outPath := filepath.Join(svgtemplate.OutputDir, base)
if err := svgtemplate.WriteFile(outputFlag, data); err != nil {
return fmt.Errorf("write svg: %w", err)
}
+ spec := model.ItemSpec{
+ SizeMM: sizeFlag,
+ BleedMM: bleedFlag,
+ MarginMM: marginFlag,
+ PaddingMM: paddingFlag,
+ }
+ name := strings.TrimSuffix(base, filepath.Ext(base))
+ item, err := svgtemplate.WriteMeta(base, spec, name)
+ if err != nil {
+ return fmt.Errorf("write meta: %w", err)
+ }
+
+ metaPath := filepath.Join(svgtemplate.OutputDir, model.MetaFilename(base))
cmd.Printf("Template erfolgreich generiert: %s\n", outPath)
+ cmd.Printf("Item-Metadaten gespeichert: %s (Item %s)\n", metaPath, item.ID)
return nil
},
}
diff --git a/go.mod b/go.mod
index e7c584d..3f6ac74 100644
--- a/go.mod
+++ b/go.mod
@@ -5,6 +5,7 @@ go 1.26.3
require github.com/spf13/cobra v1.10.2
require (
+ github.com/google/uuid v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/spf13/pflag v1.0.9 // indirect
)
diff --git a/go.sum b/go.sum
index a6ee3e0..fd37884 100644
--- a/go.sum
+++ b/go.sum
@@ -1,4 +1,6 @@
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
diff --git a/internal/model/item.go b/internal/model/item.go
new file mode 100644
index 0000000..3814698
--- /dev/null
+++ b/internal/model/item.go
@@ -0,0 +1,25 @@
+package model
+
+import "time"
+
+// ItemSpec holds mask parameters (from template CLI).
+type ItemSpec struct {
+ SizeMM float64 `json:"size_mm"`
+ BleedMM float64 `json:"bleed_mm"`
+ MarginMM float64 `json:"margin_mm"`
+ PaddingMM float64 `json:"padding_mm"`
+}
+
+// Item is a printable product type (SVG mask) placed on a plate.
+type Item struct {
+ ID string `json:"id"`
+ Name string `json:"name,omitempty"`
+ Spec ItemSpec `json:"spec"`
+ SVGTemplate string `json:"svg_template"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
+// MetaFilename returns the sidecar metadata path for an SVG basename.
+func MetaFilename(svgBasename string) string {
+ return svgBasename + ".meta.json"
+}
diff --git a/internal/model/plate.go b/internal/model/plate.go
new file mode 100644
index 0000000..c54eb42
--- /dev/null
+++ b/internal/model/plate.go
@@ -0,0 +1,26 @@
+package model
+
+import "time"
+
+// Plate describes a print bed with outer dimensions and margins on each side.
+type Plate struct {
+ ID string `json:"id"`
+ Name string `json:"name,omitempty"`
+ WidthMM float64 `json:"width_mm"`
+ HeightMM float64 `json:"height_mm"`
+ MarginTop float64 `json:"margin_top_mm"`
+ MarginRight float64 `json:"margin_right_mm"`
+ MarginBottom float64 `json:"margin_bottom_mm"`
+ MarginLeft float64 `json:"margin_left_mm"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
+// PrintableWidth returns the usable width inside the margins.
+func (p Plate) PrintableWidth() float64 {
+ return p.WidthMM - p.MarginLeft - p.MarginRight
+}
+
+// PrintableHeight returns the usable height inside the margins.
+func (p Plate) PrintableHeight() float64 {
+ return p.HeightMM - p.MarginTop - p.MarginBottom
+}
diff --git a/internal/paths/paths.go b/internal/paths/paths.go
new file mode 100644
index 0000000..539988b
--- /dev/null
+++ b/internal/paths/paths.go
@@ -0,0 +1,8 @@
+package paths
+
+// Runtime data directories (relative to process working directory).
+const (
+ DataDir = "data"
+ PlatesDir = "data/plates"
+ SVGTemplateDir = "data/svg_template"
+)
diff --git a/internal/server/admin/static/index.html b/internal/server/admin/static/index.html
index 8651d43..20ce2b3 100644
--- a/internal/server/admin/static/index.html
+++ b/internal/server/admin/static/index.html
@@ -24,8 +24,8 @@
min-height: 100vh;
line-height: 1.5;
}
- .layout {
- max-width: 960px;
+ .page {
+ max-width: 1000px;
margin: 0 auto;
padding: 2rem 1.5rem;
}
@@ -47,6 +47,7 @@
border-radius: 10px;
padding: 1.25rem;
}
+ .card.wide { grid-column: 1 / -1; }
.card h2 {
font-size: 0.75rem;
text-transform: uppercase;
@@ -54,19 +55,6 @@
color: var(--muted);
margin-bottom: 0.75rem;
}
- .status-dot {
- display: inline-block;
- width: 8px;
- height: 8px;
- border-radius: 50%;
- margin-right: 0.5rem;
- vertical-align: middle;
- }
- .status-dot.ok { background: var(--ok); }
- .status-dot.err { background: var(--err); }
- .status-dot.pending { background: var(--muted); animation: pulse 1.2s ease-in-out infinite; }
- @keyframes pulse { 50% { opacity: 0.4; } }
- .value { font-size: 1.1rem; font-weight: 500; }
.muted { color: var(--muted); font-size: 0.85rem; margin-top: 0.5rem; }
button {
margin-top: 1rem;
@@ -80,6 +68,15 @@
}
button:hover { filter: brightness(1.1); }
button:disabled { opacity: 0.5; cursor: not-allowed; }
+ button.danger {
+ background: transparent;
+ border: 1px solid var(--err);
+ color: var(--err);
+ margin-top: 0;
+ padding: 0.35rem 0.65rem;
+ font-size: 0.8rem;
+ }
+ button.danger:hover { background: rgba(239, 68, 68, 0.12); filter: none; }
input {
width: 100%;
margin-top: 0.5rem;
@@ -90,50 +87,216 @@
color: var(--text);
font-size: 0.875rem;
}
- label { font-size: 0.8rem; color: var(--muted); }
- pre {
- margin-top: 0.75rem;
- padding: 0.75rem;
- background: var(--bg);
- border-radius: 6px;
- font-size: 0.75rem;
- overflow-x: auto;
- color: var(--muted);
+ label { font-size: 0.8rem; color: var(--muted); display: block; margin-top: 0.75rem; }
+ label:first-child { margin-top: 0; }
+ .form-grid {
+ display: grid;
+ gap: 0.5rem 1rem;
+ grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
}
+ table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.85rem;
+ margin-top: 0.75rem;
+ }
+ th, td {
+ text-align: left;
+ padding: 0.5rem 0.25rem;
+ border-bottom: 1px solid var(--border);
+ vertical-align: middle;
+ }
+ th { color: var(--muted); font-weight: 500; font-size: 0.75rem; }
+ .empty { color: var(--muted); font-size: 0.85rem; margin-top: 0.5rem; }
+ .msg-err { color: var(--err); font-size: 0.85rem; margin-top: 0.5rem; }
+ .msg-ok { color: var(--ok); font-size: 0.85rem; margin-top: 0.5rem; }
+ .item-grid {
+ display: grid;
+ gap: 1rem;
+ grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
+ margin-top: 1rem;
+ }
+ .item-card {
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ }
+ .item-preview {
+ background: #fff;
+ padding: 0.75rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 160px;
+ }
+ .item-preview img {
+ max-width: 100%;
+ max-height: 140px;
+ width: auto;
+ height: auto;
+ }
+ .item-body {
+ padding: 0.75rem;
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+ }
+ .item-body strong { font-size: 0.9rem; }
+ .item-body span { font-size: 0.8rem; color: var(--muted); }
+ .item-actions {
+ padding: 0 0.75rem 0.75rem;
+ }
+ .row-actions { display: flex; gap: 0.5rem; justify-content: flex-end; }
-
+
- API-Status
-
-
-
-
-
-
+ API
+
+
+ API erreichbar
+ API nicht erreichbar
-
- API-Basis-URL
-
-
-
+
-
- System
-
- Lokale Uhrzeit (Browser)
-
+
+ Platten
+
+ Keine Platten.
+
+
+
+
+ | Name |
+ Größe |
+ Margins |
+ Druckfläche |
+ |
+
+
+
+
+
+ |
+ |
+ |
+ |
+
+
+ |
+
+
+
+
+
+
+
+ Item anlegen
+ Erzeugt SVG-Maske in data/svg_template/.
+
+
+
+
+ Items
+
+ Keine Items.
+
+
+
+
+
+
![]()
+
+
+
+
+
+
+
+
+
+
+
+
@@ -142,19 +305,42 @@
function dashboard() {
return {
apiBase: '',
- apiLoading: false,
apiOk: null,
- apiResponse: '',
- apiCheckedAt: '',
- now: '',
- get apiStatusClass() {
- if (this.apiOk === null) return 'pending';
- return this.apiOk ? 'ok' : 'err';
+ plates: [],
+ platesLoading: false,
+ plateSaving: false,
+ plateError: '',
+ plateSuccess: '',
+ plateForm: {
+ name: '',
+ width_mm: 300,
+ height_mm: 400,
+ margin_top_mm: 10,
+ margin_right_mm: 10,
+ margin_bottom_mm: 10,
+ margin_left_mm: 10,
},
- get apiStatusLabel() {
- if (this.apiOk === null) return 'Noch nicht geprüft';
- return this.apiOk ? 'API erreichbar' : 'API nicht erreichbar';
+
+ items: [],
+ itemsLoading: false,
+ itemSaving: false,
+ itemError: '',
+ itemSuccess: '',
+ itemForm: {
+ name: '',
+ size_mm: 80,
+ bleed_mm: 2,
+ margin_mm: 5,
+ padding_mm: 3,
+ },
+
+ apiUrl(path) {
+ return `${this.apiBase.replace(/\/$/, '')}${path}`;
+ },
+
+ itemSvgUrl(id) {
+ return `${this.apiUrl('/items/' + id + '/svg')}?t=${Date.now()}`;
},
async init() {
@@ -164,35 +350,159 @@
const cfg = await res.json();
this.apiBase = cfg.api_base || this.apiBase;
}
- } catch (_) { /* fallback below */ }
- if (!this.apiBase) {
- this.apiBase = 'http://127.0.0.1:8080';
- }
- this.refreshClock();
- this.checkAPI();
- setInterval(() => this.refreshClock(), 1000);
+ } catch (_) {}
+ if (!this.apiBase) this.apiBase = 'http://127.0.0.1:8080';
+ await this.onApiBaseChange();
},
- refreshClock() {
- this.now = new Date().toLocaleString('de-DE');
+ async onApiBaseChange() {
+ await this.checkAPI();
+ if (this.apiOk) {
+ await Promise.all([this.loadPlates(), this.loadItems()]);
+ }
},
async checkAPI() {
- this.apiLoading = true;
- this.apiResponse = '';
try {
- const res = await fetch(`${this.apiBase.replace(/\/$/, '')}/health`);
- const body = await res.json();
+ const res = await fetch(this.apiUrl('/health'));
this.apiOk = res.ok;
- this.apiResponse = JSON.stringify(body, null, 2);
- } catch (e) {
+ } catch {
this.apiOk = false;
- this.apiResponse = String(e.message || e);
- } finally {
- this.apiLoading = false;
- this.apiCheckedAt = new Date().toLocaleTimeString('de-DE');
}
},
+
+ async apiJSON(method, path, body) {
+ const opts = { method, headers: {} };
+ if (body !== undefined) {
+ opts.headers['Content-Type'] = 'application/json';
+ opts.body = JSON.stringify(body);
+ }
+ const res = await fetch(this.apiUrl(path), opts);
+ 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);
+ }
+ return text ? JSON.parse(text) : null;
+ },
+
+ async loadPlates() {
+ this.platesLoading = true;
+ try {
+ this.plates = await this.apiJSON('GET', '/plates') || [];
+ } catch (e) {
+ this.plateError = String(e.message || e);
+ } finally {
+ this.platesLoading = false;
+ }
+ },
+
+ async savePlate() {
+ this.plateSaving = true;
+ this.plateError = '';
+ this.plateSuccess = '';
+ try {
+ await this.apiJSON('POST', '/plates', {
+ name: this.plateForm.name,
+ width_mm: Number(this.plateForm.width_mm),
+ height_mm: Number(this.plateForm.height_mm),
+ margin_top_mm: Number(this.plateForm.margin_top_mm) || 0,
+ margin_right_mm: Number(this.plateForm.margin_right_mm) || 0,
+ margin_bottom_mm: Number(this.plateForm.margin_bottom_mm) || 0,
+ margin_left_mm: Number(this.plateForm.margin_left_mm) || 0,
+ });
+ this.plateSuccess = 'Platte gespeichert.';
+ await this.loadPlates();
+ } catch (e) {
+ this.plateError = String(e.message || e);
+ } finally {
+ this.plateSaving = false;
+ }
+ },
+
+ async deletePlate(id) {
+ if (!confirm('Platte wirklich löschen?')) return;
+ this.plateError = '';
+ try {
+ const res = await fetch(this.apiUrl('/plates/' + id), { method: 'DELETE' });
+ if (!res.ok) {
+ const text = await res.text();
+ throw new Error(text || res.statusText);
+ }
+ await this.loadPlates();
+ } catch (e) {
+ this.plateError = String(e.message || e);
+ }
+ },
+
+ async loadItems() {
+ this.itemsLoading = true;
+ try {
+ this.items = await this.apiJSON('GET', '/items') || [];
+ } catch (e) {
+ this.itemError = String(e.message || e);
+ } finally {
+ this.itemsLoading = false;
+ }
+ },
+
+ async saveItem() {
+ this.itemSaving = true;
+ this.itemError = '';
+ this.itemSuccess = '';
+ try {
+ await this.apiJSON('POST', '/items', {
+ name: this.itemForm.name,
+ size_mm: Number(this.itemForm.size_mm),
+ bleed_mm: Number(this.itemForm.bleed_mm) || 0,
+ margin_mm: Number(this.itemForm.margin_mm) || 0,
+ padding_mm: Number(this.itemForm.padding_mm) || 0,
+ });
+ this.itemSuccess = 'Item erzeugt.';
+ this.itemForm.name = '';
+ await this.loadItems();
+ } catch (e) {
+ this.itemError = String(e.message || e);
+ } finally {
+ this.itemSaving = false;
+ }
+ },
+
+ async deleteItem(id) {
+ if (!confirm('Item inkl. SVG wirklich löschen?')) return;
+ this.itemError = '';
+ try {
+ const res = await fetch(this.apiUrl('/items/' + id), { method: 'DELETE' });
+ if (!res.ok) {
+ const text = await res.text();
+ throw new Error(text || res.statusText);
+ }
+ await this.loadItems();
+ } catch (e) {
+ this.itemError = String(e.message || e);
+ }
+ },
+
+ labelItem(it) {
+ return it.name || it.svg_template;
+ },
+ fmtSize(w, h) {
+ return `${w} × ${h} mm`;
+ },
+ fmtMargins(p) {
+ return `${p.margin_top_mm} / ${p.margin_right_mm} / ${p.margin_bottom_mm} / ${p.margin_left_mm}`;
+ },
+ fmtPrintable(p) {
+ const w = p.width_mm - p.margin_left_mm - p.margin_right_mm;
+ const h = p.height_mm - p.margin_top_mm - p.margin_bottom_mm;
+ return `${w.toFixed(1)} × ${h.toFixed(1)} mm`;
+ },
};
}
diff --git a/internal/server/api/api.go b/internal/server/api/api.go
index 6b798ca..6854ec5 100644
--- a/internal/server/api/api.go
+++ b/internal/server/api/api.go
@@ -2,15 +2,44 @@ package api
import (
"encoding/json"
+ "errors"
"net/http"
+ "os"
+ "time"
+
+ "printer.backend/internal/model"
+ "printer.backend/internal/store"
)
// NewHandler returns the API HTTP handler (routes under /).
func NewHandler() http.Handler {
+ plates := store.NewPlateStore("")
+ items := store.NewItemStore("")
+
mux := http.NewServeMux()
mux.HandleFunc("GET /health", health)
mux.HandleFunc("GET /", root)
- return mux
+ mux.HandleFunc("GET /plates", listPlates(plates))
+ mux.HandleFunc("POST /plates", savePlate(plates))
+ mux.HandleFunc("DELETE /plates/{id}", deletePlate(plates))
+ mux.HandleFunc("GET /items", listItems(items))
+ mux.HandleFunc("POST /items", saveItem(items))
+ mux.HandleFunc("DELETE /items/{id}", deleteItem(items))
+ mux.HandleFunc("GET /items/{id}/svg", serveItemSVG(items))
+ return withCORS(mux)
+}
+
+func withCORS(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Access-Control-Allow-Origin", "*")
+ w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS")
+ w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
+ if r.Method == http.MethodOptions {
+ w.WriteHeader(http.StatusNoContent)
+ return
+ }
+ next.ServeHTTP(w, r)
+ })
}
func health(w http.ResponseWriter, _ *http.Request) {
@@ -22,6 +51,163 @@ func root(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{
"service": "printer-backend-api",
- "message": "API placeholder — endpoints folgen",
})
}
+
+func listPlates(s *store.PlateStore) http.HandlerFunc {
+ return func(w http.ResponseWriter, _ *http.Request) {
+ plates, err := s.List()
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, err)
+ return
+ }
+ if plates == nil {
+ plates = []model.Plate{}
+ }
+ writeJSON(w, http.StatusOK, plates)
+ }
+}
+
+type savePlateRequest struct {
+ Name string `json:"name"`
+ WidthMM float64 `json:"width_mm"`
+ HeightMM float64 `json:"height_mm"`
+ MarginTop float64 `json:"margin_top_mm"`
+ MarginRight float64 `json:"margin_right_mm"`
+ MarginBottom float64 `json:"margin_bottom_mm"`
+ MarginLeft float64 `json:"margin_left_mm"`
+}
+
+func savePlate(s *store.PlateStore) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req savePlateRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ writeError(w, http.StatusBadRequest, err)
+ return
+ }
+ if req.WidthMM <= 0 || req.HeightMM <= 0 {
+ writeError(w, http.StatusBadRequest, errors.New("width_mm and height_mm must be positive"))
+ return
+ }
+
+ p := model.Plate{
+ Name: req.Name,
+ WidthMM: req.WidthMM,
+ HeightMM: req.HeightMM,
+ MarginTop: req.MarginTop,
+ MarginRight: req.MarginRight,
+ MarginBottom: req.MarginBottom,
+ MarginLeft: req.MarginLeft,
+ CreatedAt: time.Now().UTC(),
+ }
+ saved, err := s.Save(p)
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, err)
+ return
+ }
+ writeJSON(w, http.StatusCreated, saved)
+ }
+}
+
+func deletePlate(s *store.PlateStore) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ if id == "" {
+ writeError(w, http.StatusBadRequest, errors.New("id required"))
+ return
+ }
+ if err := s.Delete(id); err != nil {
+ writeError(w, http.StatusNotFound, err)
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+ }
+}
+
+func listItems(s *store.ItemStore) 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.Item{}
+ }
+ writeJSON(w, http.StatusOK, list)
+ }
+}
+
+type saveItemRequest struct {
+ Name string `json:"name"`
+ SizeMM float64 `json:"size_mm"`
+ BleedMM float64 `json:"bleed_mm"`
+ MarginMM float64 `json:"margin_mm"`
+ PaddingMM float64 `json:"padding_mm"`
+}
+
+func saveItem(s *store.ItemStore) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req saveItemRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ writeError(w, http.StatusBadRequest, err)
+ return
+ }
+ spec := model.ItemSpec{
+ SizeMM: req.SizeMM,
+ BleedMM: req.BleedMM,
+ MarginMM: req.MarginMM,
+ PaddingMM: req.PaddingMM,
+ }
+ saved, err := s.Create(req.Name, spec)
+ if err != nil {
+ writeError(w, http.StatusBadRequest, err)
+ return
+ }
+ writeJSON(w, http.StatusCreated, saved)
+ }
+}
+
+func deleteItem(s *store.ItemStore) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ if id == "" {
+ writeError(w, http.StatusBadRequest, errors.New("id required"))
+ return
+ }
+ if err := s.Delete(id); err != nil {
+ writeError(w, http.StatusNotFound, err)
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+ }
+}
+
+func serveItemSVG(s *store.ItemStore) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ path, err := s.SVGPath(id)
+ if err != nil {
+ writeError(w, http.StatusNotFound, err)
+ return
+ }
+ data, err := os.ReadFile(path)
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, err)
+ return
+ }
+ w.Header().Set("Content-Type", "image/svg+xml")
+ w.Header().Set("Cache-Control", "no-cache")
+ _, _ = w.Write(data)
+ }
+}
+
+func writeJSON(w http.ResponseWriter, code int, v any) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(code)
+ _ = json.NewEncoder(w).Encode(v)
+}
+
+func writeError(w http.ResponseWriter, code int, err error) {
+ writeJSON(w, code, map[string]string{"error": err.Error()})
+}
diff --git a/internal/store/helpers.go b/internal/store/helpers.go
new file mode 100644
index 0000000..c56afb0
--- /dev/null
+++ b/internal/store/helpers.go
@@ -0,0 +1,93 @@
+package store
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "time"
+)
+
+func ensureDir(dir string) error {
+ return os.MkdirAll(dir, 0o755)
+}
+
+func readJSON[T any](path string) (T, error) {
+ var v T
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return v, err
+ }
+ if err := json.Unmarshal(data, &v); err != nil {
+ return v, err
+ }
+ return v, nil
+}
+
+func writeJSON(path string, v any) error {
+ data, err := json.MarshalIndent(v, "", " ")
+ if err != nil {
+ return err
+ }
+ return os.WriteFile(path, data, 0o644)
+}
+
+func removeFile(path, notFoundMsg string) error {
+ if err := os.Remove(path); err != nil {
+ if os.IsNotExist(err) {
+ return fmt.Errorf("%s", notFoundMsg)
+ }
+ return err
+ }
+ return nil
+}
+
+func listFromDir[T any](
+ dir string,
+ match func(name string) bool,
+ readLabel string,
+ createdAt func(T) time.Time,
+) ([]T, error) {
+ if err := ensureDir(dir); err != nil {
+ return nil, err
+ }
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ return nil, fmt.Errorf("read %s: %w", readLabel, err)
+ }
+
+ var out []T
+ for _, e := range entries {
+ if e.IsDir() || !match(e.Name()) {
+ continue
+ }
+ v, err := readJSON[T](filepath.Join(dir, e.Name()))
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, v)
+ }
+
+ sort.Slice(out, func(i, j int) bool {
+ return createdAt(out[i]).After(createdAt(out[j]))
+ })
+ return out, nil
+}
+
+func findByID[T any](list []T, id string, idOf func(T) string) (T, error) {
+ for _, v := range list {
+ if idOf(v) == id {
+ return v, nil
+ }
+ }
+ var zero T
+ return zero, fmt.Errorf("not found: %s", id)
+}
+
+func stampNew(createdAt *time.Time) time.Time {
+ if createdAt.IsZero() {
+ return time.Now().UTC()
+ }
+ return *createdAt
+}
diff --git a/internal/store/item.go b/internal/store/item.go
new file mode 100644
index 0000000..9ee6508
--- /dev/null
+++ b/internal/store/item.go
@@ -0,0 +1,108 @@
+package store
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/google/uuid"
+ "printer.backend/internal/model"
+ "printer.backend/internal/svgtemplate"
+)
+
+// ItemStore manages items (SVG + metadata) in the template directory.
+type ItemStore struct {
+ dir string
+}
+
+// NewItemStore creates a store that scans templateDir for *.meta.json files.
+func NewItemStore(templateDir string) *ItemStore {
+ if templateDir == "" {
+ templateDir = svgtemplate.OutputDir
+ }
+ return &ItemStore{dir: templateDir}
+}
+
+// List returns all items sorted by creation time (newest first).
+func (s *ItemStore) List() ([]model.Item, error) {
+ return listFromDir(s.dir,
+ func(name string) bool { return strings.HasSuffix(name, ".meta.json") },
+ "template dir",
+ func(it model.Item) time.Time { return it.CreatedAt },
+ )
+}
+
+// Get returns an item by ID.
+func (s *ItemStore) Get(id string) (model.Item, error) {
+ list, err := s.List()
+ if err != nil {
+ return model.Item{}, err
+ }
+ item, err := findByID(list, id, func(it model.Item) string { return it.ID })
+ if err != nil {
+ return model.Item{}, fmt.Errorf("item %w", err)
+ }
+ return item, nil
+}
+
+// Create generates SVG + metadata for a new item.
+func (s *ItemStore) Create(name string, spec model.ItemSpec) (model.Item, error) {
+ if spec.SizeMM <= 0 {
+ return model.Item{}, fmt.Errorf("size_mm must be positive")
+ }
+
+ basename := svgBasename(name)
+ data := svgtemplate.Build(spec.SizeMM, spec.BleedMM, spec.MarginMM, spec.PaddingMM)
+ if err := svgtemplate.WriteFile(basename, data); err != nil {
+ return model.Item{}, fmt.Errorf("write svg: %w", err)
+ }
+ displayName := name
+ if displayName == "" {
+ displayName = strings.TrimSuffix(basename, filepath.Ext(basename))
+ }
+ return svgtemplate.WriteMeta(basename, spec, displayName)
+}
+
+// Delete removes an item's SVG and metadata by ID.
+func (s *ItemStore) Delete(id string) error {
+ item, err := s.Get(id)
+ if err != nil {
+ return err
+ }
+ for _, path := range []string{
+ filepath.Join(s.dir, item.SVGTemplate),
+ filepath.Join(s.dir, model.MetaFilename(item.SVGTemplate)),
+ } {
+ if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
+ return err
+ }
+ }
+ return nil
+}
+
+// SVGPath returns the path to an item's SVG file.
+func (s *ItemStore) SVGPath(id string) (string, error) {
+ item, err := s.Get(id)
+ if err != nil {
+ return "", err
+ }
+ path := filepath.Join(s.dir, item.SVGTemplate)
+ if _, err := os.Stat(path); err != nil {
+ return "", fmt.Errorf("svg file missing: %w", err)
+ }
+ return path, nil
+}
+
+func svgBasename(name string) string {
+ base := strings.TrimSpace(name)
+ if base == "" {
+ return uuid.NewString() + ".svg"
+ }
+ base = strings.ReplaceAll(base, " ", "_")
+ if !strings.HasSuffix(strings.ToLower(base), ".svg") {
+ base += ".svg"
+ }
+ return filepath.Base(base)
+}
diff --git a/internal/store/plate.go b/internal/store/plate.go
new file mode 100644
index 0000000..21e46d3
--- /dev/null
+++ b/internal/store/plate.go
@@ -0,0 +1,56 @@
+package store
+
+import (
+ "fmt"
+ "path/filepath"
+ "time"
+
+ "github.com/google/uuid"
+ "printer.backend/internal/model"
+ "printer.backend/internal/paths"
+)
+
+// PlateStore persists plates as JSON files.
+type PlateStore struct {
+ dir string
+}
+
+// NewPlateStore creates a store under dir (default data/plates).
+func NewPlateStore(dir string) *PlateStore {
+ if dir == "" {
+ dir = paths.PlatesDir
+ }
+ return &PlateStore{dir: dir}
+}
+
+// List returns all plates sorted by creation time (newest first).
+func (s *PlateStore) List() ([]model.Plate, error) {
+ return listFromDir(s.dir,
+ func(name string) bool { return filepath.Ext(name) == ".json" },
+ "plates dir",
+ func(p model.Plate) time.Time { return p.CreatedAt },
+ )
+}
+
+// Save writes a new plate and assigns an ID when empty.
+func (s *PlateStore) Save(p model.Plate) (model.Plate, error) {
+ if err := ensureDir(s.dir); err != nil {
+ return model.Plate{}, err
+ }
+ if p.ID == "" {
+ p.ID = uuid.NewString()
+ }
+ p.CreatedAt = stampNew(&p.CreatedAt)
+
+ path := filepath.Join(s.dir, p.ID+".json")
+ if err := writeJSON(path, p); err != nil {
+ return model.Plate{}, fmt.Errorf("write plate: %w", err)
+ }
+ return p, nil
+}
+
+// Delete removes a plate by ID.
+func (s *PlateStore) Delete(id string) error {
+ path := filepath.Join(s.dir, id+".json")
+ return removeFile(path, fmt.Sprintf("plate not found: %s", id))
+}
diff --git a/internal/svgtemplate/meta.go b/internal/svgtemplate/meta.go
new file mode 100644
index 0000000..65f5012
--- /dev/null
+++ b/internal/svgtemplate/meta.go
@@ -0,0 +1,38 @@
+package svgtemplate
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "time"
+
+ "github.com/google/uuid"
+ "printer.backend/internal/model"
+)
+
+// WriteMeta writes item metadata next to the SVG in OutputDir.
+func WriteMeta(svgBasename string, spec model.ItemSpec, name string) (model.Item, error) {
+ if err := os.MkdirAll(OutputDir, 0o755); err != nil {
+ return model.Item{}, err
+ }
+
+ base := filepath.Base(filepath.Clean(svgBasename))
+ item := model.Item{
+ ID: uuid.NewString(),
+ Name: name,
+ Spec: spec,
+ SVGTemplate: base,
+ CreatedAt: time.Now().UTC(),
+ }
+
+ metaPath := filepath.Join(OutputDir, model.MetaFilename(base))
+ data, err := json.MarshalIndent(item, "", " ")
+ if err != nil {
+ return model.Item{}, err
+ }
+ if err := os.WriteFile(metaPath, data, 0o644); err != nil {
+ return model.Item{}, fmt.Errorf("write meta: %w", err)
+ }
+ return item, nil
+}
diff --git a/internal/svgtemplate/template.go b/internal/svgtemplate/template.go
index b5d9ba2..aaa4724 100644
--- a/internal/svgtemplate/template.go
+++ b/internal/svgtemplate/template.go
@@ -5,6 +5,8 @@ import (
"os"
"path/filepath"
"text/template"
+
+ "printer.backend/internal/paths"
)
const svgTemplate = `
@@ -55,7 +57,7 @@ func Write(w io.Writer, data Data) error {
}
// OutputDir is the directory where generated SVG files are written.
-const OutputDir = "svg_template"
+const OutputDir = paths.SVGTemplateDir
// WriteFile renders the SVG template into OutputDir.
func WriteFile(path string, data Data) error {