add finishing touches, dockerfile

This commit is contained in:
2026-07-21 22:55:38 +01:00
parent 1119bf1a3d
commit d33987aef9
13 changed files with 162 additions and 34 deletions
+3
View File
@@ -0,0 +1,3 @@
node_modules
config.json
.env
+1
View File
@@ -2,3 +2,4 @@
*.sh
*.sqlite3
.data
internal/server/static
+25
View File
@@ -0,0 +1,25 @@
FROM node:24-alpine AS node-build
WORKDIR /build
COPY frontend /build
RUN npm ci
RUN npx vite build --outDir dist
FROM golang:1.26-alpine AS go-build
WORKDIR /build
COPY . /build
COPY --from=node-build /build/dist /build/internal/server/static
RUN go get
RUN go build -o zampler
FROM alpine:3.24
COPY --from=go-build /build/zampler /usr/local/bin
ENTRYPOINT ["zampler"]
CMD ["/data", "-l", "trace"]
+2
View File
@@ -22,3 +22,5 @@ dist-ssr
*.njsproj
*.sln
*.sw?
config.json
+1 -1
View File
@@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"build": "vue-tsc -b && vite build --outDir ../internal/server/static",
"preview": "vite preview"
},
"dependencies": {
+9 -6
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue';
import { useBookmarkStore, useFlagStore, useHistoryStore, useRemoteFileStore, useSearchStore } from './stores';
import { useBookmarkStore, useConfigStore, useFlagStore, useHistoryStore, useRemoteFileStore, useSearchStore } from './stores';
import FileGrid from '@/sections/FileGrid.vue';
import SearchBar from '@/sections/SearchBar.vue';
import AudioPlayer from '@/sections/AudioPlayer.vue';
@@ -8,6 +8,7 @@ import BookmarkIcon from './icons/BookmarkIcon.vue';
import ClockIcon from './icons/ClockIcon.vue';
const bookmarks = useBookmarkStore()
const config = useConfigStore()
const flags = useFlagStore()
const history = useHistoryStore()
const search = useSearchStore()
@@ -28,7 +29,6 @@ const files = computed(() => {
files = files.filter(file => file.path.toLowerCase().includes(v))
}
console.log(flags.value.at(0))
if (flags.value.includes("bookmark")) {
const ids = bookmarks.files.map(file => file.id)
files = files.filter(file => ids.includes(file.id))
@@ -43,7 +43,10 @@ onMounted(() => {
history.read()
search.read()
remoteFiles.reload()
config.reload().then(() => {
remoteFiles.setServerUrl(config.value!.serverUrl)
remoteFiles.reload()
})
})
</script>
@@ -61,7 +64,7 @@ onMounted(() => {
<div id="flags" style="grid-area: flags">
<button type="button" v-on:click="flags.toggle('bookmark')" :data-active="flags.value.includes('bookmark')">
<BookmarkIcon />
<span>Bookmarked</span>
<span>Bookmarks</span>
</button>
<button type="button" v-on:click="flags.toggle('history')" :data-active="flags.value.includes('history')">
<ClockIcon />
@@ -73,9 +76,9 @@ onMounted(() => {
<footer class="panel" id="bottom">
<nav>
<a href="https://code.aneur.in/zampler/zampler" target="_blank">Open Source</a>
<a href="https://mit-license.org" target="_blank">&copy; 2026 (MIT)</a>
<a href="https://www.aneur.in" target="_blank">Aneurin</a>
<a href="https://mit-license.org" target="_blank">&copy; 2026 under MIT</a>
<a href="https://code.aneur.in/zampler/zampler" target="_blank">Open Source</a>
</nav>
</footer>
</div>
+4
View File
@@ -1,4 +1,8 @@
declare namespace dto {
export interface Config {
serverUrl: string
}
export interface File {
id: string
path: string
+53 -10
View File
@@ -5,6 +5,38 @@ export const useAudioPlayerStore = useFileStore('audio-player')
export const useBookmarkStore = useLocalFilesStore('bookmarks')
export const useConfigStore = defineStore('config', {
state() {
const busy = false
let value: dto.Config | undefined
return {
busy,
value,
}
},
actions: {
async reload() {
if (this.busy) {
return
}
this.busy = true
try {
const res = await fetch('config.json')
if (!res.ok) {
throw new Error(`Failed to load config (${res.status} ${res.statusText})`)
}
this.value = await res.json()
} finally {
this.busy = false
}
}
}
})
export function useFileStore(key: string) {
return defineStore(`file-${key}`, {
state() {
@@ -104,16 +136,14 @@ export function useLocalFilesStore(key: string) {
export const useRemoteFileStore = defineStore('files', {
state() {
const busy = false
const files: dto.File[] = []
const serverUrl = import.meta.env.VITE_SERVER_URL || 'http://localhost:7777'
const apiUrl = import.meta.env.VITE_API_URL || `${serverUrl}/api`
const serverUrl = ''
return {
apiUrl,
busy,
files,
serverUrl,
files
}
},
@@ -125,12 +155,25 @@ export const useRemoteFileStore = defineStore('files', {
actions: {
async reload() {
const res = await fetch(`${this.apiUrl}/files`)
if (!res.ok) {
throw new Error(`Failed to reload files from API (${res.status} ${res.statusText})`)
if (this.busy) {
return
}
this.files = await res.json()
try {
this.busy = true
const res = await fetch(`${this.serverUrl}/api/files`)
if (!res.ok) {
throw new Error(`Failed to reload files from API (${res.status} ${res.statusText})`)
}
this.files = await res.json()
} finally {
this.busy = false
}
},
setServerUrl(serverUrl: string) {
this.serverUrl = serverUrl
}
}
})
+7 -1
View File
@@ -33,6 +33,8 @@ func (cli *CLI) Cmd() *cobra.Command {
f.StringP("address", "a", "", "HTTP bind address")
f.IntP("port", "p", 7777, "HTTP listen port")
f.StringP("url", "u", "", "frontend URL")
f.StringP("log-level", "l", "info", "logging level (trace, debug, info, warn, error)")
f.StringArrayP("extension", "x", []string{"wav"}, "allowed audio file extensions")
@@ -58,6 +60,7 @@ func (cli *CLI) RunE(cmd *cobra.Command, rootDirs []string) error {
extensions, _ := cmd.Flags().GetStringArray("extension")
host, _ := cmd.Flags().GetString("host")
port, _ := cmd.Flags().GetInt("port")
url, _ := cmd.Flags().GetString("url")
if len(extensions) == 0 {
return errors.New("at least one extension required")
@@ -76,7 +79,7 @@ func (cli *CLI) RunE(cmd *cobra.Command, rootDirs []string) error {
router.Use(middlewares.NewLog(cli.log).Middleware)
// Set up standard HTTP endpoints
srv := server.NewServer(cli.db, cli.log)
srv := server.NewServer(cli.db, cli.log, url)
srv.ConfigureRouter(router)
// Set up API endpoints
@@ -84,6 +87,9 @@ func (cli *CLI) RunE(cmd *cobra.Command, rootDirs []string) error {
apiRouter := router.PathPrefix("/api/").Subrouter()
api.ConfigureRouter(apiRouter)
// Set up static assets
srv.ConfigureStaticAssets(router)
errch := make(chan error)
go func() {
addr := net.JoinHostPort(host, strconv.Itoa(port))
+5
View File
@@ -0,0 +1,5 @@
package dto
type Config struct {
ServerURL string `json:"serverUrl"`
}
+4 -4
View File
@@ -1,8 +1,8 @@
package dto
type File struct {
ID string `json:"id,omitempty"`
Path string `json:"path,omitempty"`
Hash string `json:"hash,omitempty"`
Size int64 `json:"size,omitempty"`
ID string `json:"id"`
Path string `json:"path"`
Hash string `json:"hash"`
Size int64 `json:"size"`
}
+11 -7
View File
@@ -28,7 +28,7 @@ func (l *Log) Middleware(next http.Handler) http.Handler {
func (l *Log) ServeHTTP(w http.ResponseWriter, req *http.Request) {
u, _ := uuid.NewV7()
lw := &logWriter{
lw := &LogWriter{
id: u.String(),
log: l.log.With().Logger(),
next: w,
@@ -39,9 +39,9 @@ func (l *Log) ServeHTTP(w http.ResponseWriter, req *http.Request) {
l.next.ServeHTTP(lw, req)
}
// logWriter implements and wraps http.ResponseWriter to track and log each individual request.
// LogWriter implements and wraps http.ResponseWriter to track and log each individual request.
// This is only used internally by Log.
type logWriter struct {
type LogWriter struct {
id string
log zerolog.Logger
next http.ResponseWriter
@@ -50,11 +50,11 @@ type logWriter struct {
statusCode int
}
func (w *logWriter) Header() http.Header {
func (w *LogWriter) Header() http.Header {
return w.next.Header()
}
func (w *logWriter) Write(b []byte) (int, error) {
func (w *LogWriter) Write(b []byte) (int, error) {
bytes, err := w.next.Write(b)
if w.statusCode == 0 {
@@ -66,12 +66,16 @@ func (w *logWriter) Write(b []byte) (int, error) {
return bytes, err
}
func (w *logWriter) WriteHeader(statusCode int) {
func (w *LogWriter) WriteHeader(statusCode int) {
w.statusCode = statusCode
w.next.WriteHeader(statusCode)
}
func (w *logWriter) logRequest(bytes int) {
func (w *LogWriter) Written() bool {
return w.statusCode > 0
}
func (w *LogWriter) logRequest(bytes int) {
var e *zerolog.Event
if w.statusCode >= 100 && w.statusCode < 400 {
+37 -5
View File
@@ -1,28 +1,60 @@
package server
import (
"embed"
"io/fs"
"net/http"
"strings"
"code.aneur.in/go/rest"
"code.aneur.in/zampler/zampler/internal/dto"
"code.aneur.in/zampler/zampler/internal/samples"
"github.com/gorilla/mux"
"github.com/rs/zerolog"
)
type Server struct {
db *samples.DB
//go:embed static
var staticFiles embed.FS
type Server struct {
url string
db *samples.DB
log zerolog.Logger
static http.Handler
staticFiles fs.FS
}
func (srv *Server) ConfigureRouter(r *mux.Router) {
r.Path("/config.json").Methods(http.MethodGet, http.MethodOptions).HandlerFunc(srv.GetConfig)
r.Path("/files/{path:.+}").Methods(http.MethodGet, http.MethodOptions).HandlerFunc(srv.ReadFileByPath)
r.Path("/file/{id}").Methods(http.MethodGet, http.MethodOptions).HandlerFunc(srv.ReadFile)
}
func NewServer(db *samples.DB, log zerolog.Logger) *Server {
srv := &Server{
db: db,
func (srv *Server) ConfigureStaticAssets(r *mux.Router) {
staticFiles, err := fs.Sub(staticFiles, "static")
if err != nil {
srv.log.Warn().Err(err).Msgf("Directory %q not found, not serving static assets", "internal/server/static")
return
}
srv.staticFiles = staticFiles
srv.static = http.FileServerFS(staticFiles)
r.PathPrefix("/").Methods(http.MethodGet, http.MethodOptions).Handler(srv.static)
}
func (srv *Server) GetConfig(w http.ResponseWriter, req *http.Request) {
rest.WriteResponseJSON(w, http.StatusOK, dto.Config{
ServerURL: strings.TrimRight(srv.url, "/"),
})
}
func NewServer(db *samples.DB, log zerolog.Logger, url string) *Server {
srv := &Server{
url: url,
db: db,
log: log.With().Str("namespace", "server.Server").Logger(),
}