feat: initialize warpbox.dev project structure and backend

Initialize the repository with the core Go backend architecture and a frontend mockup for warpbox.dev, a self-hosted file-sharing application.

- Set up Go backend modules for configuration, HTTP server, middleware, handlers, and templates.
- Add local development scripts, environment templates, and basic project configuration.
- Include a React-based frontend mockup under the docs directory.
This commit is contained in:
2026-05-25 15:36:49 +03:00
parent 84e5aee87c
commit 9b8ef16474
129 changed files with 19863 additions and 0 deletions

View File

@@ -0,0 +1,60 @@
package main
import (
"context"
"errors"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"warpbox.dev/backend/libs/config"
"warpbox.dev/backend/libs/httpserver"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
cfg, err := config.Load()
if err != nil {
logger.Error("failed to load config", "error", err)
os.Exit(1)
}
server, err := httpserver.New(cfg, logger)
if err != nil {
logger.Error("failed to create server", "error", err)
os.Exit(1)
}
errs := make(chan error, 1)
go func() {
logger.Info("warpbox server starting", "addr", cfg.Addr, "env", cfg.Environment)
errs <- server.ListenAndServe()
}()
shutdown := make(chan os.Signal, 1)
signal.Notify(shutdown, syscall.SIGINT, syscall.SIGTERM)
select {
case err := <-errs:
if err != nil && !errors.Is(err, http.ErrServerClosed) {
logger.Error("server stopped unexpectedly", "error", err)
os.Exit(1)
}
case sig := <-shutdown:
logger.Info("shutdown signal received", "signal", sig.String())
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
logger.Error("graceful shutdown failed", "error", err)
os.Exit(1)
}
logger.Info("server stopped")
}
}

3
backend/go.mod Normal file
View File

@@ -0,0 +1,3 @@
module warpbox.dev/backend
go 1.26

View File

@@ -0,0 +1,118 @@
package config
import (
"fmt"
"math"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
type Config struct {
AppName string
Environment string
Addr string
BaseURL string
StaticDir string
TemplateDir string
ReadTimeout time.Duration
WriteTimeout time.Duration
IdleTimeout time.Duration
MaxUploadSize int64
}
func Load() (Config, error) {
cfg := Config{
AppName: envString("WARPBOX_APP_NAME", "warpbox.dev"),
Environment: envString("WARPBOX_ENV", "development"),
Addr: envString("WARPBOX_ADDR", ":8080"),
BaseURL: strings.TrimRight(envString("WARPBOX_BASE_URL", "http://localhost:8080"), "/"),
StaticDir: envString("WARPBOX_STATIC_DIR", defaultPath("static")),
TemplateDir: envString("WARPBOX_TEMPLATE_DIR", defaultPath("templates")),
ReadTimeout: envDuration("WARPBOX_READ_TIMEOUT", 15*time.Second),
WriteTimeout: envDuration("WARPBOX_WRITE_TIMEOUT", 60*time.Second),
IdleTimeout: envDuration("WARPBOX_IDLE_TIMEOUT", 120*time.Second),
MaxUploadSize: envMegabytes("WARPBOX_MAX_UPLOAD_SIZE_MB", 2048), // 2 GiB default.
}
if cfg.BaseURL == "" {
return Config{}, fmt.Errorf("WARPBOX_BASE_URL cannot be empty")
}
if cfg.MaxUploadSize <= 0 {
return Config{}, fmt.Errorf("WARPBOX_MAX_UPLOAD_SIZE_MB must be positive")
}
return cfg, nil
}
func defaultPath(name string) string {
candidates := []string{
filepath.Join("backend", name),
name,
}
for _, candidate := range candidates {
if info, err := os.Stat(candidate); err == nil && info.IsDir() {
return candidate
}
}
return filepath.Join("backend", name)
}
func envString(key, fallback string) string {
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
return value
}
return fallback
}
func envDuration(key string, fallback time.Duration) time.Duration {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return fallback
}
parsed, err := time.ParseDuration(value)
if err != nil {
return fallback
}
return parsed
}
func envMegabytes(key string, fallback float64) int64 {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return megabytesToBytes(fallback)
}
parsed, err := parseMegabytes(value)
if err != nil {
return megabytesToBytes(fallback)
}
return parsed
}
func parseMegabytes(value string) (int64, error) {
normalized := strings.TrimSpace(value)
normalized = strings.TrimSuffix(normalized, "MB")
normalized = strings.TrimSuffix(normalized, "Mb")
normalized = strings.TrimSuffix(normalized, "mb")
normalized = strings.TrimSpace(normalized)
sizeMB, err := strconv.ParseFloat(normalized, 64)
if err != nil {
return 0, fmt.Errorf("invalid megabyte value %q: %w", value, err)
}
if sizeMB <= 0 {
return 0, fmt.Errorf("megabyte value must be positive")
}
return megabytesToBytes(sizeMB), nil
}
func megabytesToBytes(sizeMB float64) int64 {
return int64(math.Round(sizeMB * 1024 * 1024))
}

View File

@@ -0,0 +1,34 @@
package config
import "testing"
func TestParseMegabytes(t *testing.T) {
tests := map[string]int64{
"0.5": 512 * 1024,
"0.5Mb": 512 * 1024,
"1mb": 1024 * 1024,
"1MB": 1024 * 1024,
"1.5Mb": 1536 * 1024,
" 2 ": 2 * 1024 * 1024,
}
for input, want := range tests {
got, err := parseMegabytes(input)
if err != nil {
t.Fatalf("parseMegabytes(%q) returned error: %v", input, err)
}
if got != want {
t.Fatalf("parseMegabytes(%q) = %d, want %d", input, got, want)
}
}
}
func TestParseMegabytesRejectsInvalidValues(t *testing.T) {
tests := []string{"", "0", "-1", "abc"}
for _, input := range tests {
if _, err := parseMegabytes(input); err == nil {
t.Fatalf("parseMegabytes(%q) returned nil error", input)
}
}
}

View File

@@ -0,0 +1,34 @@
package handlers
import (
"log/slog"
"net/http"
"warpbox.dev/backend/libs/config"
"warpbox.dev/backend/libs/services"
"warpbox.dev/backend/libs/web"
)
type App struct {
cfg config.Config
logger *slog.Logger
renderer *web.Renderer
uploadService *services.UploadService
}
func NewApp(cfg config.Config, logger *slog.Logger, renderer *web.Renderer, uploadService *services.UploadService) *App {
return &App{
cfg: cfg,
logger: logger,
renderer: renderer,
uploadService: uploadService,
}
}
func (a *App) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /", a.Home)
mux.HandleFunc("GET /healthz", a.Health)
mux.HandleFunc("GET /api/v1/health", a.Health)
mux.HandleFunc("POST /api/v1/upload", a.UploadPlaceholder)
mux.Handle("GET /static/", a.Static())
}

View File

@@ -0,0 +1,20 @@
package handlers
import (
"net/http"
"time"
"warpbox.dev/backend/libs/helpers"
)
type healthResponse struct {
Status string `json:"status"`
Time string `json:"time"`
}
func (a *App) Health(w http.ResponseWriter, r *http.Request) {
helpers.WriteJSON(w, http.StatusOK, healthResponse{
Status: "ok",
Time: time.Now().UTC().Format(time.RFC3339),
})
}

View File

@@ -0,0 +1,21 @@
package handlers
import (
"net/http"
"warpbox.dev/backend/libs/web"
)
type homeData struct {
MaxUploadSize string
}
func (a *App) Home(w http.ResponseWriter, r *http.Request) {
a.renderer.Render(w, http.StatusOK, "home.gohtml", web.PageData{
Title: "Upload your files",
Description: "Upload and share files through a self-hosted Warpbox instance.",
Data: homeData{
MaxUploadSize: a.uploadService.MaxUploadSizeLabel(),
},
})
}

View File

@@ -0,0 +1,31 @@
package handlers
import (
"net/http"
"path/filepath"
"strings"
)
func (a *App) Static() http.Handler {
fileServer := http.StripPrefix("/static/", http.FileServer(http.Dir(a.cfg.StaticDir)))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
setStaticCacheHeaders(w, r.URL.Path)
fileServer.ServeHTTP(w, r)
})
}
func setStaticCacheHeaders(w http.ResponseWriter, path string) {
ext := strings.ToLower(filepath.Ext(path))
switch ext {
case ".avif", ".gif", ".ico", ".jpg", ".jpeg", ".png", ".svg", ".webp",
".mp4", ".m4v", ".mov", ".webm", ".mp3", ".ogg",
".eot", ".otf", ".ttf", ".woff", ".woff2":
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
case ".css", ".js":
w.Header().Set("Cache-Control", "public, max-age=86400")
default:
w.Header().Set("Cache-Control", "public, max-age=3600")
}
}

View File

@@ -0,0 +1,26 @@
package handlers
import (
"net/http/httptest"
"testing"
)
func TestSetStaticCacheHeaders(t *testing.T) {
tests := map[string]string{
"/static/css/app.css": "public, max-age=86400",
"/static/js/app.js": "public, max-age=86400",
"/static/img/preview.webp": "public, max-age=31536000, immutable",
"/static/fonts/ui.woff2": "public, max-age=31536000, immutable",
"/static/videos/intro.mp4": "public, max-age=31536000, immutable",
"/static/uploads/file.data": "public, max-age=3600",
}
for path, want := range tests {
response := httptest.NewRecorder()
setStaticCacheHeaders(response, path)
if got := response.Header().Get("Cache-Control"); got != want {
t.Fatalf("Cache-Control for %s = %q, want %q", path, got, want)
}
}
}

View File

@@ -0,0 +1,19 @@
package handlers
import (
"net/http"
"warpbox.dev/backend/libs/helpers"
)
type uploadPlaceholderResponse struct {
Message string `json:"message"`
MaxUploadSize string `json:"maxUploadSize"`
}
func (a *App) UploadPlaceholder(w http.ResponseWriter, r *http.Request) {
helpers.WriteJSON(w, http.StatusNotImplemented, uploadPlaceholderResponse{
Message: "upload storage is not implemented yet; backend base is ready for the upload pipeline",
MaxUploadSize: a.uploadService.MaxUploadSizeLabel(),
})
}

View File

@@ -0,0 +1,18 @@
package helpers
import "fmt"
func FormatBytes(size int64) string {
const unit = 1024
if size < unit {
return fmt.Sprintf("%d B", size)
}
div, exp := int64(unit), 0
for n := size / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %ciB", float64(size)/float64(div), "KMGTPE"[exp])
}

View File

@@ -0,0 +1,19 @@
package helpers
import "testing"
func TestFormatBytes(t *testing.T) {
tests := map[int64]string{
0: "0 B",
512: "512 B",
1024: "1.0 KiB",
1536: "1.5 KiB",
1073741824: "1.0 GiB",
}
for input, want := range tests {
if got := FormatBytes(input); got != want {
t.Fatalf("FormatBytes(%d) = %q, want %q", input, got, want)
}
}
}

View File

@@ -0,0 +1,20 @@
package helpers
import (
"encoding/json"
"net/http"
)
type ErrorResponse struct {
Error string `json:"error"`
}
func WriteJSON(w http.ResponseWriter, status int, payload any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(payload)
}
func WriteJSONError(w http.ResponseWriter, status int, message string) {
WriteJSON(w, status, ErrorResponse{Error: message})
}

View File

@@ -0,0 +1,42 @@
package httpserver
import (
"log/slog"
"net/http"
"warpbox.dev/backend/libs/config"
"warpbox.dev/backend/libs/handlers"
"warpbox.dev/backend/libs/middleware"
"warpbox.dev/backend/libs/services"
"warpbox.dev/backend/libs/web"
)
func New(cfg config.Config, logger *slog.Logger) (*http.Server, error) {
renderer, err := web.NewRenderer(cfg.TemplateDir, cfg.AppName, cfg.BaseURL)
if err != nil {
return nil, err
}
uploadService := services.NewUploadService(cfg.MaxUploadSize)
app := handlers.NewApp(cfg, logger, renderer, uploadService)
router := http.NewServeMux()
app.RegisterRoutes(router)
handler := middleware.Chain(
router,
middleware.Recoverer(logger),
middleware.RequestID,
middleware.SecurityHeaders,
middleware.Gzip,
middleware.Logger(logger),
)
return &http.Server{
Addr: cfg.Addr,
Handler: handler,
ReadTimeout: cfg.ReadTimeout,
WriteTimeout: cfg.WriteTimeout,
IdleTimeout: cfg.IdleTimeout,
}, nil
}

View File

@@ -0,0 +1,12 @@
package middleware
import "net/http"
type Middleware func(http.Handler) http.Handler
func Chain(handler http.Handler, middleware ...Middleware) http.Handler {
for i := len(middleware) - 1; i >= 0; i-- {
handler = middleware[i](handler)
}
return handler
}

View File

@@ -0,0 +1,69 @@
package middleware
import (
"compress/gzip"
"io"
"net/http"
"strings"
)
type gzipResponseWriter struct {
http.ResponseWriter
writer io.Writer
wrote bool
}
func (w *gzipResponseWriter) WriteHeader(status int) {
if w.wrote {
return
}
w.wrote = true
header := w.Header()
header.Del("Content-Length")
header.Del("Accept-Ranges")
header.Set("Content-Encoding", "gzip")
header.Add("Vary", "Accept-Encoding")
w.ResponseWriter.WriteHeader(status)
}
func (w *gzipResponseWriter) Write(data []byte) (int, error) {
if !w.wrote {
w.WriteHeader(http.StatusOK)
}
return w.writer.Write(data)
}
func Gzip(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !acceptsGzip(r) || shouldSkipGzip(r) {
next.ServeHTTP(w, r)
return
}
gz := gzip.NewWriter(w)
defer gz.Close()
next.ServeHTTP(&gzipResponseWriter{
ResponseWriter: w,
writer: gz,
}, r)
})
}
func acceptsGzip(r *http.Request) bool {
return strings.Contains(r.Header.Get("Accept-Encoding"), "gzip")
}
func shouldSkipGzip(r *http.Request) bool {
if r.Method == http.MethodHead || r.Header.Get("Range") != "" {
return true
}
path := r.URL.Path
switch ext := strings.ToLower(path[strings.LastIndex(path, ".")+1:]); ext {
case "br", "gz", "zip", "7z", "rar", "jpg", "jpeg", "png", "gif", "webp", "avif", "mp4", "webm", "mov", "m4v", "mp3", "ogg", "woff", "woff2", "ttf", "otf":
return true
default:
return false
}
}

View File

@@ -0,0 +1,63 @@
package middleware
import (
"io"
"net/http"
"net/http/httptest"
"testing"
)
func TestGzipCompressesEligibleResponses(t *testing.T) {
handler := Gzip(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Length", "11")
_, _ = io.WriteString(w, "hello world")
}))
request := httptest.NewRequest(http.MethodGet, "/page", nil)
request.Header.Set("Accept-Encoding", "gzip")
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if got := response.Header().Get("Content-Encoding"); got != "gzip" {
t.Fatalf("Content-Encoding = %q, want gzip", got)
}
if got := response.Header().Get("Content-Length"); got != "" {
t.Fatalf("Content-Length = %q, want empty for gzipped response", got)
}
if got := response.Header().Get("Vary"); got != "Accept-Encoding" {
t.Fatalf("Vary = %q, want Accept-Encoding", got)
}
}
func TestGzipSkipsRangeAndHeadRequests(t *testing.T) {
handler := Gzip(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = io.WriteString(w, "hello world")
}))
tests := []struct {
name string
method string
rangeHeader string
}{
{name: "range", method: http.MethodGet, rangeHeader: "bytes=0-4"},
{name: "head", method: http.MethodHead},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
request := httptest.NewRequest(tt.method, "/asset.css", nil)
request.Header.Set("Accept-Encoding", "gzip")
if tt.rangeHeader != "" {
request.Header.Set("Range", tt.rangeHeader)
}
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if got := response.Header().Get("Content-Encoding"); got != "" {
t.Fatalf("Content-Encoding = %q, want empty", got)
}
})
}
}

View File

@@ -0,0 +1,54 @@
package middleware
import (
"log/slog"
"net/http"
"time"
)
type statusRecorder struct {
http.ResponseWriter
status int
bytes int
}
func (r *statusRecorder) WriteHeader(status int) {
r.status = status
r.ResponseWriter.WriteHeader(status)
}
func (r *statusRecorder) Write(data []byte) (int, error) {
if r.status == 0 {
r.status = http.StatusOK
}
n, err := r.ResponseWriter.Write(data)
r.bytes += n
return n, err
}
func Logger(logger *slog.Logger) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
recorder := &statusRecorder{ResponseWriter: w}
next.ServeHTTP(recorder, r)
status := recorder.status
if status == 0 {
status = http.StatusOK
}
logger.Info("http request",
"method", r.Method,
"path", r.URL.Path,
"status", status,
"bytes", recorder.bytes,
"duration_ms", time.Since(start).Milliseconds(),
"request_id", RequestIDFromContext(r.Context()),
"remote_addr", r.RemoteAddr,
"user_agent", r.UserAgent(),
)
})
}
}

View File

@@ -0,0 +1,26 @@
package middleware
import (
"log/slog"
"net/http"
"runtime/debug"
)
func Recoverer(logger *slog.Logger) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if recovered := recover(); recovered != nil {
logger.Error("panic recovered",
"error", recovered,
"stack", string(debug.Stack()),
"request_id", RequestIDFromContext(r.Context()),
)
http.Error(w, "internal server error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
}

View File

@@ -0,0 +1,38 @@
package middleware
import (
"context"
"crypto/rand"
"encoding/hex"
"net/http"
)
type requestIDKey struct{}
func RequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestID := r.Header.Get("X-Request-ID")
if requestID == "" {
requestID = newRequestID()
}
w.Header().Set("X-Request-ID", requestID)
ctx := context.WithValue(r.Context(), requestIDKey{}, requestID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func RequestIDFromContext(ctx context.Context) string {
if value, ok := ctx.Value(requestIDKey{}).(string); ok {
return value
}
return ""
}
func newRequestID() string {
var data [16]byte
if _, err := rand.Read(data[:]); err != nil {
return "request-id-unavailable"
}
return hex.EncodeToString(data[:])
}

View File

@@ -0,0 +1,16 @@
package middleware
import "net/http"
func SecurityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
header := w.Header()
header.Set("X-Content-Type-Options", "nosniff")
header.Set("X-Frame-Options", "DENY")
header.Set("Referrer-Policy", "strict-origin-when-cross-origin")
header.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
header.Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self'; style-src 'self'; script-src 'self'; base-uri 'self'; frame-ancestors 'none'")
next.ServeHTTP(w, r)
})
}

View File

@@ -0,0 +1,30 @@
package services
import (
"fmt"
"warpbox.dev/backend/libs/helpers"
)
type UploadService struct {
maxUploadSize int64
}
func NewUploadService(maxUploadSize int64) *UploadService {
return &UploadService{maxUploadSize: maxUploadSize}
}
func (s *UploadService) MaxUploadSize() int64 {
return s.maxUploadSize
}
func (s *UploadService) MaxUploadSizeLabel() string {
return helpers.FormatBytes(s.maxUploadSize)
}
func (s *UploadService) ValidateSize(size int64) error {
if size > s.maxUploadSize {
return fmt.Errorf("file exceeds max upload size of %s", s.MaxUploadSizeLabel())
}
return nil
}

View File

@@ -0,0 +1,79 @@
package web
import (
"html/template"
"net/http"
"path/filepath"
"time"
)
type Renderer struct {
templates map[string]*template.Template
appName string
baseURL string
}
type PageData struct {
AppName string
BaseURL string
Title string
Description string
CurrentYear int
Data any
}
func NewRenderer(templateDir, appName, baseURL string) (*Renderer, error) {
layouts, err := filepath.Glob(filepath.Join(templateDir, "layouts", "*.gohtml"))
if err != nil {
return nil, err
}
partials, err := filepath.Glob(filepath.Join(templateDir, "partials", "*.gohtml"))
if err != nil {
return nil, err
}
pages, err := filepath.Glob(filepath.Join(templateDir, "pages", "*.gohtml"))
if err != nil {
return nil, err
}
templates := make(map[string]*template.Template, len(pages))
for _, page := range pages {
files := append([]string{}, layouts...)
files = append(files, partials...)
files = append(files, page)
parsed, err := template.ParseFiles(files...)
if err != nil {
return nil, err
}
templates[filepath.Base(page)] = parsed
}
return &Renderer{
templates: templates,
appName: appName,
baseURL: baseURL,
}, nil
}
func (r *Renderer) Render(w http.ResponseWriter, status int, page string, data PageData) {
data.AppName = r.appName
data.BaseURL = r.baseURL
data.CurrentYear = time.Now().Year()
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
tmpl, ok := r.templates[page]
if !ok {
http.Error(w, "template not found", http.StatusInternalServerError)
return
}
if err := tmpl.ExecuteTemplate(w, page, data); err != nil {
http.Error(w, "template render error", http.StatusInternalServerError)
}
}

329
backend/static/css/app.css Normal file
View File

@@ -0,0 +1,329 @@
:root {
color-scheme: light;
--background: #ffffff;
--foreground: #09090b;
--card: #ffffff;
--card-foreground: #09090b;
--muted: #f4f4f5;
--muted-foreground: #71717a;
--border: #e4e4e7;
--input: #e4e4e7;
--primary: #18181b;
--primary-foreground: #fafafa;
--ring: #a1a1aa;
--radius: 0.5rem;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
}
* {
box-sizing: border-box;
}
html {
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: var(--background);
color: var(--foreground);
}
body {
min-height: 100vh;
margin: 0;
display: flex;
flex-direction: column;
background: radial-gradient(circle at top, #fafafa 0, var(--background) 34rem);
}
a {
color: inherit;
}
:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
.skip-link {
position: absolute;
left: 1rem;
top: -4rem;
z-index: 10;
padding: 0.75rem 1rem;
border-radius: var(--radius);
background: var(--primary);
color: var(--primary-foreground);
}
.skip-link:focus {
top: 1rem;
}
.site-header {
border-bottom: 1px solid var(--border);
background: rgba(255, 255, 255, 0.84);
backdrop-filter: blur(14px);
}
.nav {
width: min(1180px, calc(100% - 2rem));
min-height: 4.5rem;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.brand,
.nav-links {
display: inline-flex;
align-items: center;
gap: 0.7rem;
}
.brand {
font-weight: 800;
text-decoration: none;
letter-spacing: 0;
}
.brand-mark {
width: 2rem;
height: 2rem;
display: grid;
place-items: center;
border-radius: calc(var(--radius) - 0.125rem);
background: var(--primary);
color: var(--primary-foreground);
font-size: 0.9rem;
}
.nav-links a {
text-decoration: none;
}
main {
flex: 1;
}
.upload-view {
width: min(620px, calc(100% - 2rem));
min-height: calc(100vh - 9rem);
margin: 0 auto;
padding: 3rem 0;
display: flex;
align-items: center;
justify-content: center;
}
h1 {
margin: 0;
color: var(--card-foreground);
font-size: 1.8rem;
line-height: 1.15;
font-weight: 700;
}
.panel-header {
display: grid;
gap: 0.5rem;
text-align: center;
}
.panel-header p {
margin: 0;
color: var(--muted-foreground);
font-size: 0.95rem;
line-height: 1.5;
}
.upload-panel {
width: 100%;
display: grid;
gap: 1rem;
padding: 1.25rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--card);
box-shadow: var(--shadow);
}
.drop-zone {
min-height: 17rem;
display: grid;
place-items: center;
align-content: center;
gap: 0.65rem;
padding: 2rem;
border: 1px dashed var(--input);
border-radius: var(--radius);
background: var(--muted);
text-align: center;
cursor: pointer;
transition: border-color 160ms ease, background 160ms ease;
}
.drop-zone:hover,
.drop-zone.is-dragging {
border-color: var(--primary);
background: #fafafa;
}
.drop-zone input {
position: absolute;
inline-size: 1px;
block-size: 1px;
opacity: 0;
pointer-events: none;
}
.drop-icon {
width: 3rem;
height: 3rem;
display: grid;
place-items: center;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--background);
color: var(--foreground);
font-size: 2rem;
font-weight: 400;
}
.drop-title {
font-size: 1rem;
font-weight: 650;
}
.drop-copy {
color: var(--muted-foreground);
font-size: 0.9rem;
}
.advanced-options {
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.8rem 0.9rem;
}
.advanced-options summary {
color: var(--foreground);
cursor: pointer;
font-size: 0.9rem;
font-weight: 650;
}
.option-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.9rem;
margin-top: 1rem;
}
label span {
display: block;
margin-bottom: 0.35rem;
color: var(--foreground);
font-size: 0.85rem;
font-weight: 600;
}
input,
select,
button {
font: inherit;
}
input,
select {
width: 100%;
min-height: 2.25rem;
border: 1px solid var(--input);
border-radius: calc(var(--radius) - 0.125rem);
padding: 0.6rem 0.75rem;
background: var(--background);
color: var(--foreground);
}
input::placeholder {
color: var(--muted-foreground);
}
.form-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.form-footer p {
margin: 0;
color: var(--muted-foreground);
font-size: 0.85rem;
}
.button,
button {
min-height: 2.25rem;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid transparent;
border-radius: calc(var(--radius) - 0.125rem);
padding: 0.45rem 0.85rem;
color: var(--foreground);
background: transparent;
font: inherit;
font-size: 0.9rem;
font-weight: 600;
line-height: 1;
cursor: pointer;
}
.button-primary {
background: var(--primary);
color: var(--primary-foreground);
}
.button-primary:hover {
background: #27272a;
}
.button-outline {
border-color: var(--border);
background: var(--background);
}
.button-outline:hover,
.button-ghost:hover {
background: var(--muted);
}
.site-footer {
width: min(1180px, calc(100% - 2rem));
margin: 0 auto;
padding: 1.5rem 0;
display: flex;
justify-content: space-between;
gap: 1rem;
color: var(--muted-foreground);
font-size: 0.85rem;
}
@media (max-width: 840px) {
.upload-view {
min-height: calc(100vh - 9rem);
padding: 2.5rem 0;
}
.option-grid,
.form-footer,
.site-footer {
grid-template-columns: 1fr;
flex-direction: column;
align-items: stretch;
}
.drop-zone {
min-height: 14rem;
}
}

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

42
backend/static/js/app.js Normal file
View File

@@ -0,0 +1,42 @@
(function () {
const dropZone = document.querySelector(".drop-zone");
const fileInput = document.querySelector("#file-input");
if (!dropZone || !fileInput) {
return;
}
["dragenter", "dragover"].forEach((eventName) => {
dropZone.addEventListener(eventName, (event) => {
event.preventDefault();
dropZone.classList.add("is-dragging");
});
});
["dragleave", "drop"].forEach((eventName) => {
dropZone.addEventListener(eventName, (event) => {
event.preventDefault();
dropZone.classList.remove("is-dragging");
});
});
dropZone.addEventListener("drop", (event) => {
if (event.dataTransfer && event.dataTransfer.files.length > 0) {
fileInput.files = event.dataTransfer.files;
updateDropLabel(event.dataTransfer.files.length);
}
});
fileInput.addEventListener("change", () => {
updateDropLabel(fileInput.files.length);
});
function updateDropLabel(count) {
const title = dropZone.querySelector(".drop-title");
if (!title) {
return;
}
title.textContent = count === 1 ? "1 file selected" : `${count} files selected`;
}
})();

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,43 @@
{{define "base"}}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{if .Title}}{{.Title}} - {{end}}{{.AppName}}</title>
<meta name="description" content="{{.Description}}">
<meta name="theme-color" content="#ffffff">
<meta property="og:site_name" content="{{.AppName}}">
<meta property="og:title" content="{{if .Title}}{{.Title}}{{else}}{{.AppName}}{{end}}">
<meta property="og:description" content="{{.Description}}">
<meta property="og:type" content="website">
<meta property="og:url" content="{{.BaseURL}}">
<meta name="twitter:card" content="summary_large_image">
<link rel="stylesheet" href="/static/css/app.css">
<script defer src="/static/js/app.js"></script>
</head>
<body>
<a class="skip-link" href="#main">Skip to content</a>
<header class="site-header">
<nav class="nav" aria-label="Main navigation">
<a class="brand" href="/" aria-label="{{.AppName}} home">
<span class="brand-mark" aria-hidden="true">W</span>
<span>{{.AppName}}</span>
</a>
<div class="nav-links">
<a class="button button-ghost" href="/api/v1/health">API</a>
<a class="button button-outline" href="/healthz">Health</a>
</div>
</nav>
</header>
<main id="main">
{{template "content" .}}
</main>
<footer class="site-footer">
<span>&copy; {{.CurrentYear}} {{.AppName}}</span>
</footer>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,46 @@
{{define "home.gohtml"}}{{template "base" .}}{{end}}
{{define "content"}}
<section class="upload-view" aria-labelledby="upload-title">
<form class="upload-panel" action="/api/v1/upload" method="post" enctype="multipart/form-data">
<div class="panel-header">
<h1 id="upload-title">Upload your files</h1>
<p>Drag files here or choose them from your device.</p>
</div>
<label class="drop-zone" for="file-input">
<span class="drop-icon" aria-hidden="true">+</span>
<span class="drop-title">Drop files to upload</span>
<span class="drop-copy">Click to browse</span>
<input id="file-input" name="file" type="file" multiple>
</label>
<details class="advanced-options">
<summary>Advanced options</summary>
<div class="option-grid">
<label>
<span>Expires after</span>
<select name="max_days">
<option value="7">7 days</option>
<option value="1">1 day</option>
<option value="30">30 days</option>
</select>
</label>
<label>
<span>Max downloads</span>
<input type="number" name="max_downloads" min="1" placeholder="Unlimited">
</label>
<label>
<span>Password</span>
<input type="password" name="password" autocomplete="new-password" placeholder="Optional">
</label>
</div>
</details>
<div class="form-footer">
<p>Current max file size: {{.Data.MaxUploadSize}}</p>
<button class="button button-primary" type="submit">Upload files</button>
</div>
</form>
</section>
{{end}}