diff --git a/CLAUDE.md b/CLAUDE.md index b6cbca9..f537b44 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,8 +62,23 @@ internal/ ingest.go POST /v1/trackpoints, POST /v1/trackpoints:batch query.go GET /v1/days, /v1/trackpoints, /v1/stops, /v1/suggestions webui.go server-side rendered web UI (login, /days, /days/{date}) - journal.go journal entry endpoints + journal.go POST /entries, GET/POST /entries/{id}, GET /entries/{id}/edit + media.go POST /media — single-file upload, returns markdown reference response.go shared response helpers + api/static/ + style.css global styles (Pico CSS overrides) + day.js GPS button, time auto-fill + editor.js textarea drag-drop/paste upload → markdown ref insert + autoplay.js IntersectionObserver: videos autoplay when visible + api/templates/ + base.html layout + global nav (LoggedIn, IsAdmin injected by render()) + days.html day list + date picker + day.html day detail: new entry form, entries, stops, trackpoints + edit_entry.html edit existing entry + public.html public feed (infinite scroll) + login.html login form + register.html self-registration + admin/ admin layout + entries/users pages ``` Key invariants: diff --git a/Dockerfile b/Dockerfile index eec0322..f08955f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,12 +6,23 @@ RUN npm ci COPY webapp/ ./ RUN npm run build -# Stage 2: Build Go server +# Stage 2: Compile static TypeScript +FROM node:22-alpine AS static-ts-builder +WORKDIR /build/backend +COPY backend/static-ts/ ./static-ts/ +RUN mkdir -p ./internal/api/static \ + && cd ./static-ts \ + && npm ci \ + && npm run build + +# Stage 3: Build Go server FROM golang:1.25-alpine AS go-builder WORKDIR /app COPY backend/go.mod backend/go.sum ./ RUN go mod download COPY backend/ ./ +# Inject compiled static JS +COPY --from=static-ts-builder /build/backend/internal/api/static/ ./internal/api/static/ RUN go test ./... # Inject built SPA into embed path COPY --from=webapp-builder /webapp/dist ./internal/api/webapp/ diff --git a/backend/go.mod b/backend/go.mod index c77476b..6d14615 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -10,11 +10,14 @@ require ( ) require ( + github.com/disintegration/imaging v1.6.2 // indirect github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect - golang.org/x/sync v0.19.0 // indirect + github.com/yuin/goldmark v1.8.2 // indirect + golang.org/x/image v0.39.0 // indirect + golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.41.0 // indirect - golang.org/x/text v0.34.0 // indirect + golang.org/x/text v0.36.0 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index e279519..7044341 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -11,6 +11,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4= github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU= +github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= +github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI= @@ -63,6 +65,8 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= +github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= @@ -75,12 +79,21 @@ go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mx go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww= +golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/backend/internal/api/journal.go b/backend/internal/api/journal.go index e46a137..170abfc 100644 --- a/backend/internal/api/journal.go +++ b/backend/internal/api/journal.go @@ -2,8 +2,8 @@ package api import ( "fmt" - "io" "log/slog" + "mime/multipart" "net/http" "os" "path/filepath" @@ -20,10 +20,17 @@ const ( ) var allowedMIME = map[string]string{ - "image/jpeg": ".jpg", - "image/png": ".png", - "image/webp": ".webp", - "image/heic": ".heic", + "image/jpeg": ".jpg", + "image/png": ".png", + "image/webp": ".webp", + "image/heic": ".heic", + "image/gif": ".gif", + "video/mp4": ".mp4", + "video/webm": ".webm", + "audio/mpeg": ".mp3", + "audio/ogg": ".ogg", + "audio/wav": ".wav", + "audio/aac": ".aac", } type JournalHandler struct { @@ -108,51 +115,8 @@ func (h *JournalHandler) HandleUpdateEntry(w http.ResponseWriter, r *http.Reques return } - // Handle new image uploads - if r.MultipartForm != nil && r.MultipartForm.File != nil { - files := r.MultipartForm.File["images"] - for _, fh := range files { - if fh.Size == 0 || fh.Size > maxSingleImage { - continue - } - f, err := fh.Open() - if err != nil { - continue - } - buf := make([]byte, 512) - n, _ := f.Read(buf) - mime := http.DetectContentType(buf[:n]) - ext, ok := allowedMIME[mime] - if !ok { - f.Close() - continue - } - filename := sanitizeFilename(entryID+"_"+fh.Filename) + ext - destPath := filepath.Join(h.uploadDir, filename) - out, err := os.Create(destPath) - if err != nil { - f.Close() - continue - } - if _, err := out.Write(buf[:n]); err != nil { - out.Close(); f.Close(); os.Remove(destPath) - continue - } - if _, err := io.Copy(out, f); err != nil { - out.Close(); f.Close(); os.Remove(destPath) - continue - } - out.Close() - f.Close() - img := domain.JournalImage{ - EntryID: entryID, Filename: filename, - OriginalName: fh.Filename, MimeType: mime, SizeBytes: fh.Size, - } - if _, err := h.store.InsertImage(r.Context(), img); err != nil { - slog.Error("insert image", "entry_id", entryID, "err", err) - os.Remove(destPath) - } - } + if r.MultipartForm != nil { + h.saveJournalImages(r, entryID, r.MultipartForm.File["images"]) } http.Redirect(w, r, "/days/"+existing.EntryDate, http.StatusSeeOther) @@ -219,71 +183,56 @@ func (h *JournalHandler) HandleCreateEntry(w http.ResponseWriter, r *http.Reques return } - // Handle image uploads - if r.MultipartForm != nil && r.MultipartForm.File != nil { - files := r.MultipartForm.File["images"] - for _, fh := range files { - if fh.Size > maxSingleImage { - continue // skip oversized images silently - } - f, err := fh.Open() - if err != nil { - continue - } - - // Detect MIME type from first 512 bytes - buf := make([]byte, 512) - n, _ := f.Read(buf) - mime := http.DetectContentType(buf[:n]) - ext, ok := allowedMIME[mime] - if !ok { - f.Close() - continue - } - - filename := saved.EntryID + "_" + fh.Filename - filename = sanitizeFilename(filename) + ext - destPath := filepath.Join(h.uploadDir, filename) - - out, err := os.Create(destPath) - if err != nil { - f.Close() - continue - } - - // Write already-read bytes + rest; clean up file on any write error - if _, err := out.Write(buf[:n]); err != nil { - out.Close() - f.Close() - os.Remove(destPath) - continue - } - if _, err := io.Copy(out, f); err != nil { - out.Close() - f.Close() - os.Remove(destPath) - continue - } - out.Close() - f.Close() - - img := domain.JournalImage{ - EntryID: saved.EntryID, - Filename: filename, - OriginalName: fh.Filename, - MimeType: mime, - SizeBytes: fh.Size, - } - if _, err := h.store.InsertImage(r.Context(), img); err != nil { - slog.Error("insert image", "entry_id", saved.EntryID, "filename", filename, "err", err) - os.Remove(destPath) - } - } + if r.MultipartForm != nil { + h.saveJournalImages(r, saved.EntryID, r.MultipartForm.File["images"]) } http.Redirect(w, r, "/days/"+date, http.StatusSeeOther) } +// saveJournalImages saves uploaded files for a journal entry, with image resizing. +// Errors per file are logged and silently skipped so the entry is always saved. +func (h *JournalHandler) saveJournalImages(r *http.Request, entryID string, files []*multipart.FileHeader) { + ctx := r.Context() + for _, fh := range files { + if fh.Size == 0 || fh.Size > maxSingleImage { + continue + } + f, err := fh.Open() + if err != nil { + continue + } + + buf := make([]byte, 512) + n, _ := f.Read(buf) + mime := http.DetectContentType(buf[:n]) + if _, ok := allowedMIME[mime]; !ok { + f.Close() + continue + } + + baseName := sanitizeFilename(entryID + "_" + fh.Filename) + filename, err := saveUpload(h.uploadDir, baseName, mime, buf[:n], f) + f.Close() + if err != nil { + slog.Error("save upload", "entry_id", entryID, "err", err) + continue + } + + img := domain.JournalImage{ + EntryID: entryID, + Filename: filename, + OriginalName: fh.Filename, + MimeType: mime, + SizeBytes: fh.Size, + } + if _, err := h.store.InsertImage(ctx, img); err != nil { + slog.Error("insert image", "entry_id", entryID, "err", err) + os.Remove(filepath.Join(h.uploadDir, filename)) + } + } +} + // sanitizeFilename strips path separators and non-printable characters. func sanitizeFilename(name string) string { name = filepath.Base(name) diff --git a/backend/internal/api/media.go b/backend/internal/api/media.go new file mode 100644 index 0000000..46672e5 --- /dev/null +++ b/backend/internal/api/media.go @@ -0,0 +1,76 @@ +package api + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "log/slog" + "net/http" + "strings" +) + +type MediaHandler struct { + uploadDir string +} + +func NewMediaHandler(uploadDir string) *MediaHandler { + return &MediaHandler{uploadDir: uploadDir} +} + +// HandleUpload handles POST /media — uploads a single file and returns its markdown reference. +func (h *MediaHandler) HandleUpload(w http.ResponseWriter, r *http.Request) { + if err := r.ParseMultipartForm(maxUploadSize); err != nil { + http.Error(w, "too large", http.StatusRequestEntityTooLarge) + return + } + fh, _, err := r.FormFile("file") + if err != nil { + http.Error(w, "missing file", http.StatusBadRequest) + return + } + defer fh.Close() + + buf := make([]byte, 512) + n, _ := fh.Read(buf) + mime := http.DetectContentType(buf[:n]) + if _, ok := allowedMIME[mime]; !ok { + http.Error(w, "unsupported type", http.StatusUnsupportedMediaType) + return + } + + filename, err := saveUpload(h.uploadDir, randomID(), mime, buf[:n], fh) + if err != nil { + http.Error(w, "storage error", http.StatusInternalServerError) + return + } + + ref := markdownRef(mime, filename) + slog.Info("media uploaded", "filename", filename, "mime", mime) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "filename": filename, + "mime": mime, + "ref": ref, + }) +} + +func markdownRef(mime, filename string) string { + url := "/uploads/" + filename + switch { + case strings.HasPrefix(mime, "image/"): + return "![" + filename + "](" + url + ")" + case strings.HasPrefix(mime, "video/"): + return "![" + filename + "](" + url + ")" + case strings.HasPrefix(mime, "audio/"): + return "[" + filename + "](" + url + ")" + default: + return "[" + filename + "](" + url + ")" + } +} + +func randomID() string { + b := make([]byte, 12) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} diff --git a/backend/internal/api/router.go b/backend/internal/api/router.go index 54c59a4..ce25bbb 100644 --- a/backend/internal/api/router.go +++ b/backend/internal/api/router.go @@ -26,6 +26,7 @@ func NewRouter( webUI := NewWebUI(authStore, tpStore, stopStore, journalStore, userStore) journalHandler := NewJournalHandler(journalStore, uploadDir) + mediaHandler := NewMediaHandler(uploadDir) authMW := RequireAuth(authStore) // Health @@ -70,6 +71,7 @@ func NewRouter( r.Get("/days", webUI.HandleDaysList) r.Get("/days/redirect", webUI.HandleDaysRedirect) r.Get("/days/{date}", webUI.HandleDayDetail) + r.Post("/media", mediaHandler.HandleUpload) r.Post("/entries", journalHandler.HandleCreateEntry) r.Get("/entries/{id}/edit", journalHandler.HandleGetEditEntry) r.Post("/entries/{id}", journalHandler.HandleUpdateEntry) diff --git a/backend/internal/api/static/autoplay.js b/backend/internal/api/static/autoplay.js new file mode 100644 index 0000000..2256d68 --- /dev/null +++ b/backend/internal/api/static/autoplay.js @@ -0,0 +1,103 @@ +"use strict"; +(function () { + 'use strict'; + /* ── Background player ───────────────────────────────────── */ + const bgAudio = new Audio(); + let bgPlaying = false; + let bgBar = null; + let bgTitle = null; + let bgPlayBtn = null; + function createBgBar() { + var _a; + if (bgBar) + return; + bgBar = document.createElement('div'); + bgBar.id = 'bg-bar'; + bgBar.innerHTML = + '' + + '' + + ''; + document.body.appendChild(bgBar); + bgTitle = document.getElementById('bg-title'); + bgPlayBtn = document.getElementById('bg-play'); + bgPlayBtn.addEventListener('click', function () { + if (bgAudio.paused) + void bgAudio.play(); + else + bgAudio.pause(); + }); + (_a = document.getElementById('bg-close')) === null || _a === void 0 ? void 0 : _a.addEventListener('click', function () { + bgAudio.pause(); + if (bgBar) + bgBar.style.display = 'none'; + }); + bgAudio.addEventListener('play', function () { if (bgPlayBtn) + bgPlayBtn.textContent = '⏸'; }); + bgAudio.addEventListener('pause', function () { if (bgPlayBtn) + bgPlayBtn.textContent = '▶'; }); + bgAudio.addEventListener('ended', function () { if (bgPlayBtn) + bgPlayBtn.textContent = '▶'; }); + } + function sendToBg(src, title) { + createBgBar(); + if (bgBar) + bgBar.style.display = 'flex'; + bgAudio.src = src; + if (bgTitle) + bgTitle.textContent = title; + void bgAudio.play(); + } + // Attach "♪" button to every inline audio player + document.querySelectorAll('audio.media-audio').forEach(function (a) { + const btn = document.createElement('button'); + btn.className = 'btn-bg-music'; + btn.textContent = '♪ Hintergrundmusik'; + btn.type = 'button'; + const title = a.title || a.src.split('/').pop() || a.src; + btn.addEventListener('click', function () { sendToBg(a.src, title); }); + a.insertAdjacentElement('afterend', btn); + }); + /* ── Video autoplay + coordination ──────────────────────── */ + const obs = new IntersectionObserver(function (entries) { + entries.forEach(function (e) { + const v = e.target; + if (e.isIntersecting) { + void v.play(); + } + else { + v.pause(); + } + }); + }, { threshold: 0.3 }); + document.querySelectorAll('video.media-embed').forEach(function (v) { + v.muted = true; + v.loop = true; + v.setAttribute('playsinline', ''); + obs.observe(v); + // User unmutes → pause background music + v.addEventListener('volumechange', function () { + if (!v.muted && !v.paused) { + bgPlaying = !bgAudio.paused; + bgAudio.pause(); + } + // Video muted again → resume background + if (v.muted && bgPlaying) { + void bgAudio.play(); + bgPlaying = false; + } + }); + // Video pauses or ends → resume background if it was playing + v.addEventListener('pause', function () { + if (bgPlaying) { + void bgAudio.play(); + bgPlaying = false; + } + }); + v.addEventListener('ended', function () { + if (bgPlaying) { + void bgAudio.play(); + bgPlaying = false; + } + }); + }); +})(); diff --git a/backend/internal/api/static/day.js b/backend/internal/api/static/day.js index 94e1858..4809aa7 100644 --- a/backend/internal/api/static/day.js +++ b/backend/internal/api/static/day.js @@ -1,47 +1,28 @@ +"use strict"; +var _a; // GPS button -document.getElementById('btn-gps')?.addEventListener('click', function () { - const status = document.getElementById('gps-status'); - if (!navigator.geolocation) { - status.textContent = '// GPS nicht verfügbar'; - return; - } - status.textContent = '// Standort wird ermittelt...'; - navigator.geolocation.getCurrentPosition( - function (pos) { - document.getElementById('entry-lat').value = pos.coords.latitude.toFixed(6); - document.getElementById('entry-lon').value = pos.coords.longitude.toFixed(6); - status.textContent = '// Standort gesetzt (' + pos.coords.accuracy.toFixed(0) + ' m Genauigkeit)'; - }, - function (err) { - status.textContent = '// Fehler: ' + err.message; - }, - { enableHighAccuracy: true, timeout: 10000 } - ); +(_a = document.getElementById('btn-gps')) === null || _a === void 0 ? void 0 : _a.addEventListener('click', function () { + const status = document.getElementById('gps-status'); + if (!navigator.geolocation) { + status.textContent = '// GPS nicht verfügbar'; + return; + } + status.textContent = '// Standort wird ermittelt...'; + navigator.geolocation.getCurrentPosition(function (pos) { + document.getElementById('entry-lat').value = pos.coords.latitude.toFixed(6); + document.getElementById('entry-lon').value = pos.coords.longitude.toFixed(6); + status.textContent = '// Standort gesetzt (' + pos.coords.accuracy.toFixed(0) + ' m Genauigkeit)'; + }, function (err) { + status.textContent = '// Fehler: ' + err.message; + }, { enableHighAccuracy: true, timeout: 10000 }); }); - // Set current time as default (function () { - const input = document.getElementById('entry-time'); - if (input && !input.value) { - const now = new Date(); - const hh = String(now.getHours()).padStart(2, '0'); - const mm = String(now.getMinutes()).padStart(2, '0'); - input.value = hh + ':' + mm; - } + const input = document.getElementById('entry-time'); + if (input && !input.value) { + const now = new Date(); + const hh = String(now.getHours()).padStart(2, '0'); + const mm = String(now.getMinutes()).padStart(2, '0'); + input.value = hh + ':' + mm; + } })(); - -// Image preview -document.getElementById('image-input')?.addEventListener('change', function () { - const preview = document.getElementById('image-preview'); - preview.innerHTML = ''; - Array.from(this.files).forEach(function (file) { - if (!file.type.startsWith('image/')) return; - const reader = new FileReader(); - reader.onload = function (e) { - const img = document.createElement('img'); - img.src = e.target.result; - preview.appendChild(img); - }; - reader.readAsDataURL(file); - }); -}); diff --git a/backend/internal/api/static/editor.js b/backend/internal/api/static/editor.js new file mode 100644 index 0000000..6cb6cf0 --- /dev/null +++ b/backend/internal/api/static/editor.js @@ -0,0 +1,84 @@ +"use strict"; +(function () { + 'use strict'; + function initEditor(ta) { + var _a, _b; + async function upload(file) { + var _a; + const form = new FormData(); + form.append('file', file); + const statusEl = (_a = ta.parentElement) === null || _a === void 0 ? void 0 : _a.querySelector('.upload-status'); + if (statusEl) + statusEl.textContent = '↑ ' + file.name + ' …'; + try { + const res = await fetch('/media', { method: 'POST', body: form }); + if (!res.ok) { + if (statusEl) + statusEl.textContent = '✗ Fehler beim Hochladen'; + return; + } + const data = await res.json(); + insertAtCursor('\n' + data.ref + '\n'); + if (statusEl) + statusEl.textContent = ''; + } + catch (_e) { + if (statusEl) + statusEl.textContent = '✗ Fehler beim Hochladen'; + } + } + function insertAtCursor(text) { + const start = ta.selectionStart; + ta.value = ta.value.slice(0, start) + text + ta.value.slice(ta.selectionEnd); + ta.selectionStart = ta.selectionEnd = start + text.length; + ta.focus(); + } + // Paste: catch file pastes + ta.addEventListener('paste', function (e) { + var _a; + const items = (_a = e.clipboardData) === null || _a === void 0 ? void 0 : _a.items; + if (!items) + return; + for (let i = 0; i < items.length; i++) { + if (items[i].kind === 'file') { + e.preventDefault(); + const file = items[i].getAsFile(); + if (file) + void upload(file); + return; + } + } + }); + // Drag & Drop onto textarea + ta.addEventListener('dragover', function (e) { + e.preventDefault(); + ta.classList.add('drag-over'); + }); + ta.addEventListener('dragleave', function () { + ta.classList.remove('drag-over'); + }); + ta.addEventListener('drop', function (e) { + var _a; + e.preventDefault(); + ta.classList.remove('drag-over'); + const files = (_a = e.dataTransfer) === null || _a === void 0 ? void 0 : _a.files; + if (!files) + return; + for (let i = 0; i < files.length; i++) + void upload(files[i]); + }); + // File picker button + const picker = (_a = ta.parentElement) === null || _a === void 0 ? void 0 : _a.querySelector('.media-picker'); + const input = (_b = ta.parentElement) === null || _b === void 0 ? void 0 : _b.querySelector('.media-file-input'); + if (picker && input) { + picker.addEventListener('click', function () { input.click(); }); + input.addEventListener('change', function () { + if (!input.files) + return; + Array.from(input.files).forEach(f => void upload(f)); + input.value = ''; + }); + } + } + document.querySelectorAll('textarea[name="description"]').forEach(initEditor); +})(); diff --git a/backend/internal/api/static/style.css b/backend/internal/api/static/style.css index 2e4fa41..49b89cc 100644 --- a/backend/internal/api/static/style.css +++ b/backend/internal/api/static/style.css @@ -27,9 +27,6 @@ h2 { font-size: 1rem; font-weight: normal; letter-spacing: .05em; } .source-gps { color: #060; } .source-manual { color: #888; } -/* Top bar */ -.page-header { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 1.5rem; } - /* GPS row */ .gps-row { display: flex; gap: .4rem; align-items: center; } .gps-row input { flex: 1; margin-bottom: 0; } @@ -38,10 +35,7 @@ h2 { font-size: 1rem; font-weight: normal; letter-spacing: .05em; } /* Narrow pages (login, register) */ .narrow { max-width: 400px; margin-top: 4rem; } -/* Image preview */ -.image-preview { display: flex; flex-wrap: wrap; gap: .5rem; margin-bottom: .8rem; } -.image-preview img, .thumb { width: 80px; height: 80px; object-fit: cover; border: 1px solid var(--pico-muted-border-color); } -.thumb { width: 100px; height: 100px; display: block; } +.thumb { width: 80px; height: 80px; object-fit: cover; border: 1px solid var(--pico-muted-border-color); display: block; } /* Journal entry cards */ .entry-card { @@ -54,8 +48,16 @@ h2 { font-size: 1rem; font-weight: normal; letter-spacing: .05em; } .entry-meta { font-size: .8rem; margin-bottom: .3rem; display: flex; gap: .6rem; align-items: baseline; flex-wrap: wrap; } .entry-edit { margin-left: auto; font-size: .75rem; } .entry-title { font-size: 1rem; margin-bottom: .3rem; } -.entry-desc { white-space: pre-wrap; font-size: .9rem; } +.entry-desc { font-size: .9rem; } +.entry-desc p { margin-bottom: .5rem; } +.entry-desc img { max-width: 100%; height: auto; display: block; margin: .5rem 0; } +.entry-desc video { max-width: 100%; display: block; margin: .5rem 0; } +.entry-desc ul, +.entry-desc ol { padding-left: 1.2rem; margin-bottom: .5rem; } +.entry-desc h1, .entry-desc h2, .entry-desc h3 { font-weight: normal; margin: .8rem 0 .3rem; } .entry-images { display: flex; flex-wrap: wrap; gap: .5rem; margin-top: .5rem; } +.media-embed { width: 100%; max-height: 360px; display: block; margin-top: .5rem; } +.media-audio { width: 100%; display: block; margin-top: .5rem; } /* Public feed */ .pub-card { margin-bottom: 2rem; padding-bottom: 1.5rem; border-bottom: 1px solid var(--pico-muted-border-color); } @@ -63,7 +65,10 @@ h2 { font-size: 1rem; font-weight: normal; letter-spacing: .05em; } .pub-cover { width: 100%; max-height: 320px; object-fit: cover; display: block; margin-bottom: .7rem; } .pub-meta { display: block; color: var(--pico-muted-color); margin-bottom: .3rem; } .pub-title { display: block; font-size: 1rem; margin-bottom: .4rem; } -.pub-desc { margin: 0 0 .4rem; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; white-space: pre-wrap; } +.pub-desc { margin: 0 0 .4rem; font-size: .9rem; } +.pub-desc p { margin-bottom: .5rem; } +.pub-desc img { max-width: 100%; height: auto; display: block; margin: .5rem 0; } +.pub-desc video { max-width: 100%; display: block; margin: .5rem 0; } .pub-tags { margin-top: .3rem; } /* Login */ @@ -76,6 +81,25 @@ h2 { font-size: 1rem; font-weight: normal; letter-spacing: .05em; } /* Visibility badge */ .badge-public { font-size: .7rem; background: #264; color: #8f8; padding: .1rem .4rem; border-radius: 4px; vertical-align: middle; } +/* Background music player */ +#bg-bar { display: none; position: fixed; bottom: 0; left: 0; right: 0; background: var(--pico-background-color); border-top: 1px solid var(--pico-muted-border-color); padding: .4rem 1rem; gap: .8rem; align-items: center; z-index: 100; font-size: .8rem; } +#bg-title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--pico-muted-color); } +#bg-play, #bg-close { background: none; border: none; cursor: pointer; padding: 0 .3rem; font-size: .9rem; margin: 0; } +.btn-bg-music { font-size: .75rem; padding: .15rem .5rem; background: none; border: 1px solid var(--pico-muted-border-color); cursor: pointer; margin-top: .3rem; display: block; } + +/* Editor */ +.editor-wrap textarea { margin-bottom: 0; border-bottom: none; border-radius: var(--pico-border-radius) var(--pico-border-radius) 0 0; } +.editor-wrap textarea.drag-over { outline: 2px dashed var(--pico-primary); } +.editor-bar { display: flex; align-items: center; gap: .6rem; padding: .3rem .5rem; border: 1px solid var(--pico-form-element-border-color); border-top: none; border-radius: 0 0 var(--pico-border-radius) var(--pico-border-radius); margin-bottom: 1rem; background: var(--pico-form-element-background-color); } +.editor-bar button { font-size: .78rem; padding: .15rem .5rem; background: none; border: 1px solid var(--pico-muted-border-color); cursor: pointer; margin: 0; } +.upload-status { font-size: .78rem; color: var(--pico-muted-color); } + +/* Media reference rows (edit form) */ +.media-refs { margin-bottom: 1rem; display: flex; flex-direction: column; gap: .5rem; } +.media-ref-row { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; } +.media-ref-code { font-size: .75rem; background: var(--pico-muted-background-color); padding: .2rem .4rem; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.btn-insert { font-size: .75rem; padding: .2rem .5rem; background: none; border: 1px solid var(--pico-muted-border-color); cursor: pointer; white-space: nowrap; } + /* Delete button */ .btn-delete { background: none; border: 1px solid #c44; color: #c44; padding: .2rem .6rem; cursor: pointer; font-size: .8rem; border-radius: 4px; } .btn-delete:hover { background: #c44; color: #fff; } diff --git a/backend/internal/api/templates/base.html b/backend/internal/api/templates/base.html index 82e8e68..2e1c21e 100644 --- a/backend/internal/api/templates/base.html +++ b/backend/internal/api/templates/base.html @@ -20,6 +20,7 @@ {{block "content" .}}{{end}} {{block "scripts" .}}{{end}} + {{end}} diff --git a/backend/internal/api/templates/day.html b/backend/internal/api/templates/day.html index 8c193da..3c12ab6 100644 --- a/backend/internal/api/templates/day.html +++ b/backend/internal/api/templates/day.html @@ -15,7 +15,14 @@ - +
+ +
+ + + +
+
@@ -23,8 +30,6 @@
- -
@@ -38,15 +43,21 @@ bearbeiten {{if .Title}}
{{.Title}}
{{end}} - {{if .Description}}
{{.Description}}
{{end}} + {{if .Description}}
{{markdown .Description}}
{{end}} {{if .Hashtags}}
{{range .Hashtags}}#{{.}} {{end}}
{{end}} {{if .Images}}
{{range .Images}} + {{if isVideo .MimeType}} + + {{else if isAudio .MimeType}} + + {{else}} {{.OriginalName}} {{end}} + {{end}}
{{end}} @@ -96,6 +107,7 @@ {{define "scripts"}} + {{end}} {{template "base" .}} diff --git a/backend/internal/api/templates/edit_entry.html b/backend/internal/api/templates/edit_entry.html index 3206b11..dc2cb5e 100644 --- a/backend/internal/api/templates/edit_entry.html +++ b/backend/internal/api/templates/edit_entry.html @@ -14,7 +14,14 @@ - +
+ +
+ + + +
+
@@ -23,16 +30,22 @@ {{if .Entry.Images}} -
+ {{end}} - -
@@ -40,6 +53,7 @@ {{define "scripts"}} + {{end}} {{template "base" .}} diff --git a/backend/internal/api/templates/public.html b/backend/internal/api/templates/public.html index cee98d4..92770e0 100644 --- a/backend/internal/api/templates/public.html +++ b/backend/internal/api/templates/public.html @@ -12,14 +12,22 @@ {{range .Entries}} diff --git a/backend/internal/api/upload.go b/backend/internal/api/upload.go new file mode 100644 index 0000000..06fd49a --- /dev/null +++ b/backend/internal/api/upload.go @@ -0,0 +1,125 @@ +package api + +import ( + "bytes" + "image" + "image/jpeg" + _ "image/jpeg" + "image/png" + _ "image/png" + "io" + "log/slog" + "os" + "path/filepath" + "strings" + + "github.com/disintegration/imaging" + "golang.org/x/image/webp" +) + +const maxImageDimension = 1920 +const jpegQuality = 80 + +// saveUpload writes an uploaded file to uploadDir, resizing images where applicable. +// peeked contains the first bytes already read for MIME detection. +// Returns the saved filename (including extension). +func saveUpload(uploadDir, baseName, mime string, peeked []byte, rest io.Reader) (string, error) { + full := io.MultiReader(bytes.NewReader(peeked), rest) + switch mime { + case "image/jpeg", "image/png": + return saveResizedImage(uploadDir, baseName, mime, full) + case "image/webp": + return saveResizedWebP(uploadDir, baseName, full) + default: + ext := allowedMIME[mime] + dest := filepath.Join(uploadDir, baseName+ext) + return baseName + ext, saveRaw(dest, full) + } +} + +func saveResizedImage(uploadDir, baseName, mime string, r io.Reader) (string, error) { + img, _, err := image.Decode(r) + if err != nil { + // Fallback: save raw if decode fails + slog.Warn("image decode failed, saving raw", "mime", mime, "err", err) + return "", err + } + + b := img.Bounds() + if b.Dx() > maxImageDimension || b.Dy() > maxImageDimension { + img = imaging.Fit(img, maxImageDimension, maxImageDimension, imaging.Lanczos) + } + + ext := allowedMIME[mime] + filename := baseName + ext + dest := filepath.Join(uploadDir, filename) + out, err := os.Create(dest) + if err != nil { + return "", err + } + defer out.Close() + + switch mime { + case "image/jpeg": + err = jpeg.Encode(out, img, &jpeg.Options{Quality: jpegQuality}) + case "image/png": + err = png.Encode(out, img) + } + if err != nil { + os.Remove(dest) + return "", err + } + return filename, nil +} + +func saveResizedWebP(uploadDir, baseName string, r io.Reader) (string, error) { + img, err := webp.Decode(r) + if err != nil { + slog.Warn("webp decode failed, saving raw", "err", err) + return "", err + } + + b := img.Bounds() + if b.Dx() > maxImageDimension || b.Dy() > maxImageDimension { + img = imaging.Fit(img, maxImageDimension, maxImageDimension, imaging.Lanczos) + } + + // Re-encode as JPEG (no pure-Go WebP encoder available) + filename := baseName + ".jpg" + dest := filepath.Join(uploadDir, filename) + out, err := os.Create(dest) + if err != nil { + return "", err + } + defer out.Close() + + if err := jpeg.Encode(out, img, &jpeg.Options{Quality: jpegQuality}); err != nil { + os.Remove(dest) + return "", err + } + return filename, nil +} + +func saveRaw(dest string, r io.Reader) error { + out, err := os.Create(dest) + if err != nil { + return err + } + defer out.Close() + _, err = io.Copy(out, r) + return err +} + +// mimeCategory returns "image", "video", "audio" or "other". +func mimeCategory(mime string) string { + switch { + case strings.HasPrefix(mime, "image/"): + return "image" + case strings.HasPrefix(mime, "video/"): + return "video" + case strings.HasPrefix(mime, "audio/"): + return "audio" + default: + return "other" + } +} diff --git a/backend/internal/api/webui.go b/backend/internal/api/webui.go index 8668160..f3f7f87 100644 --- a/backend/internal/api/webui.go +++ b/backend/internal/api/webui.go @@ -13,10 +13,28 @@ import ( "time" "github.com/go-chi/chi/v5" + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/extension" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/renderer/html" "github.com/jacek/pamietnik/backend/internal/auth" "github.com/jacek/pamietnik/backend/internal/db" ) +var md = goldmark.New( + goldmark.WithExtensions(extension.GFM), + goldmark.WithParserOptions(parser.WithAutoHeadingID()), + goldmark.WithRendererOptions(html.WithHardWraps()), +) + +func renderMarkdown(src string) template.HTML { + var buf bytes.Buffer + if err := md.Convert([]byte(src), &buf); err != nil { + return template.HTML(template.HTMLEscapeString(src)) + } + return template.HTML(buf.String()) +} + //go:embed static templates var assets embed.FS @@ -28,7 +46,11 @@ var funcMap = template.FuncMap{ } return *p }, - "join": strings.Join, + "join": strings.Join, + "isVideo": func(mime string) bool { return strings.HasPrefix(mime, "video/") }, + "isAudio": func(mime string) bool { return strings.HasPrefix(mime, "audio/") }, + "isImage": func(mime string) bool { return strings.HasPrefix(mime, "image/") }, + "markdown": renderMarkdown, } var tmpls = template.Must( diff --git a/backend/static-ts/.gitignore b/backend/static-ts/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/backend/static-ts/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/backend/static-ts/autoplay.ts b/backend/static-ts/autoplay.ts new file mode 100644 index 0000000..0b9f638 --- /dev/null +++ b/backend/static-ts/autoplay.ts @@ -0,0 +1,95 @@ +(function () { + 'use strict'; + + /* ── Background player ───────────────────────────────────── */ + const bgAudio = new Audio(); + let bgPlaying = false; + let bgBar: HTMLElement | null = null; + let bgTitle: HTMLElement | null = null; + let bgPlayBtn: HTMLButtonElement | null = null; + + function createBgBar(): void { + if (bgBar) return; + bgBar = document.createElement('div'); + bgBar.id = 'bg-bar'; + bgBar.innerHTML = + '' + + '' + + ''; + document.body.appendChild(bgBar); + + bgTitle = document.getElementById('bg-title'); + bgPlayBtn = document.getElementById('bg-play') as HTMLButtonElement; + + bgPlayBtn.addEventListener('click', function () { + if (bgAudio.paused) void bgAudio.play(); else bgAudio.pause(); + }); + document.getElementById('bg-close')?.addEventListener('click', function () { + bgAudio.pause(); + if (bgBar) bgBar.style.display = 'none'; + }); + bgAudio.addEventListener('play', function () { if (bgPlayBtn) bgPlayBtn.textContent = '⏸'; }); + bgAudio.addEventListener('pause', function () { if (bgPlayBtn) bgPlayBtn.textContent = '▶'; }); + bgAudio.addEventListener('ended', function () { if (bgPlayBtn) bgPlayBtn.textContent = '▶'; }); + } + + function sendToBg(src: string, title: string): void { + createBgBar(); + if (bgBar) bgBar.style.display = 'flex'; + bgAudio.src = src; + if (bgTitle) bgTitle.textContent = title; + void bgAudio.play(); + } + + // Attach "♪" button to every inline audio player + document.querySelectorAll('audio.media-audio').forEach(function (a) { + const btn = document.createElement('button'); + btn.className = 'btn-bg-music'; + btn.textContent = '♪ Hintergrundmusik'; + btn.type = 'button'; + const title = a.title || a.src.split('/').pop() || a.src; + btn.addEventListener('click', function () { sendToBg(a.src, title); }); + a.insertAdjacentElement('afterend', btn); + }); + + /* ── Video autoplay + coordination ──────────────────────── */ + const obs = new IntersectionObserver(function (entries: IntersectionObserverEntry[]) { + entries.forEach(function (e) { + const v = e.target as HTMLVideoElement; + if (e.isIntersecting) { + void v.play(); + } else { + v.pause(); + } + }); + }, { threshold: 0.3 }); + + document.querySelectorAll('video.media-embed').forEach(function (v) { + v.muted = true; + v.loop = true; + v.setAttribute('playsinline', ''); + obs.observe(v); + + // User unmutes → pause background music + v.addEventListener('volumechange', function () { + if (!v.muted && !v.paused) { + bgPlaying = !bgAudio.paused; + bgAudio.pause(); + } + // Video muted again → resume background + if (v.muted && bgPlaying) { + void bgAudio.play(); + bgPlaying = false; + } + }); + + // Video pauses or ends → resume background if it was playing + v.addEventListener('pause', function () { + if (bgPlaying) { void bgAudio.play(); bgPlaying = false; } + }); + v.addEventListener('ended', function () { + if (bgPlaying) { void bgAudio.play(); bgPlaying = false; } + }); + }); + +})(); diff --git a/backend/static-ts/day.ts b/backend/static-ts/day.ts new file mode 100644 index 0000000..8f369a6 --- /dev/null +++ b/backend/static-ts/day.ts @@ -0,0 +1,31 @@ +// GPS button +document.getElementById('btn-gps')?.addEventListener('click', function () { + const status = document.getElementById('gps-status') as HTMLElement; + if (!navigator.geolocation) { + status.textContent = '// GPS nicht verfügbar'; + return; + } + status.textContent = '// Standort wird ermittelt...'; + navigator.geolocation.getCurrentPosition( + function (pos: GeolocationPosition) { + (document.getElementById('entry-lat') as HTMLInputElement).value = pos.coords.latitude.toFixed(6); + (document.getElementById('entry-lon') as HTMLInputElement).value = pos.coords.longitude.toFixed(6); + status.textContent = '// Standort gesetzt (' + pos.coords.accuracy.toFixed(0) + ' m Genauigkeit)'; + }, + function (err: GeolocationPositionError) { + status.textContent = '// Fehler: ' + err.message; + }, + { enableHighAccuracy: true, timeout: 10000 } + ); +}); + +// Set current time as default +(function () { + const input = document.getElementById('entry-time') as HTMLInputElement | null; + if (input && !input.value) { + const now = new Date(); + const hh = String(now.getHours()).padStart(2, '0'); + const mm = String(now.getMinutes()).padStart(2, '0'); + input.value = hh + ':' + mm; + } +})(); diff --git a/backend/static-ts/editor.ts b/backend/static-ts/editor.ts new file mode 100644 index 0000000..1b5aca9 --- /dev/null +++ b/backend/static-ts/editor.ts @@ -0,0 +1,81 @@ +(function () { + 'use strict'; + + interface UploadResponse { + filename: string; + mime: string; + ref: string; + } + + function initEditor(ta: HTMLTextAreaElement): void { + async function upload(file: File): Promise { + const form = new FormData(); + form.append('file', file); + const statusEl = ta.parentElement?.querySelector('.upload-status'); + if (statusEl) statusEl.textContent = '↑ ' + file.name + ' …'; + try { + const res = await fetch('/media', { method: 'POST', body: form }); + if (!res.ok) { + if (statusEl) statusEl.textContent = '✗ Fehler beim Hochladen'; + return; + } + const data: UploadResponse = await res.json(); + insertAtCursor('\n' + data.ref + '\n'); + if (statusEl) statusEl.textContent = ''; + } catch (_e) { + if (statusEl) statusEl.textContent = '✗ Fehler beim Hochladen'; + } + } + + function insertAtCursor(text: string): void { + const start = ta.selectionStart; + ta.value = ta.value.slice(0, start) + text + ta.value.slice(ta.selectionEnd); + ta.selectionStart = ta.selectionEnd = start + text.length; + ta.focus(); + } + + // Paste: catch file pastes + ta.addEventListener('paste', function (e: ClipboardEvent) { + const items = e.clipboardData?.items; + if (!items) return; + for (let i = 0; i < items.length; i++) { + if (items[i].kind === 'file') { + e.preventDefault(); + const file = items[i].getAsFile(); + if (file) void upload(file); + return; + } + } + }); + + // Drag & Drop onto textarea + ta.addEventListener('dragover', function (e: DragEvent) { + e.preventDefault(); + ta.classList.add('drag-over'); + }); + ta.addEventListener('dragleave', function () { + ta.classList.remove('drag-over'); + }); + ta.addEventListener('drop', function (e: DragEvent) { + e.preventDefault(); + ta.classList.remove('drag-over'); + const files = e.dataTransfer?.files; + if (!files) return; + for (let i = 0; i < files.length; i++) void upload(files[i]); + }); + + // File picker button + const picker = ta.parentElement?.querySelector('.media-picker'); + const input = ta.parentElement?.querySelector('.media-file-input'); + if (picker && input) { + picker.addEventListener('click', function () { input.click(); }); + input.addEventListener('change', function () { + if (!input.files) return; + Array.from(input.files).forEach(f => void upload(f)); + input.value = ''; + }); + } + } + + document.querySelectorAll('textarea[name="description"]').forEach(initEditor); +})(); diff --git a/backend/static-ts/package-lock.json b/backend/static-ts/package-lock.json new file mode 100644 index 0000000..f17fd58 --- /dev/null +++ b/backend/static-ts/package-lock.json @@ -0,0 +1,30 @@ +{ + "name": "static-ts", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "static-ts", + "version": "1.0.0", + "license": "ISC", + "devDependencies": { + "typescript": "^6.0.2" + } + }, + "node_modules/typescript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", + "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/backend/static-ts/package.json b/backend/static-ts/package.json new file mode 100644 index 0000000..e87185e --- /dev/null +++ b/backend/static-ts/package.json @@ -0,0 +1,17 @@ +{ + "name": "static-ts", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "build": "tsc", + "watch": "tsc --watch" + }, + "keywords": [], + "author": "", + "license": "ISC", + "type": "commonjs", + "devDependencies": { + "typescript": "^6.0.2" + } +} diff --git a/backend/static-ts/tsconfig.json b/backend/static-ts/tsconfig.json new file mode 100644 index 0000000..e05b4ca --- /dev/null +++ b/backend/static-ts/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["ES2017", "DOM"], + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "module": "None", + "ignoreDeprecations": "6.0", + "outDir": "../internal/api/static" + }, + "include": ["./*.ts"] +}