From df91fe9d3d2a3d77c9b7ffc865be31cfc036e515 Mon Sep 17 00:00:00 2001 From: Daniel Legt Date: Sun, 31 May 2026 15:30:53 +0300 Subject: [PATCH] feat(upload): add dynamic expiry options and modern UI theme - Implement dynamic expiry options on the upload page based on user roles and retention policies. - Add helper functions to build and format expiry options into human-readable labels. - Introduce a new modern theme featuring glassmorphism, gradients, and frosted glass cards. --- backend/libs/handlers/pages.go | 99 +++++++++++-- backend/libs/handlers/upload.go | 6 + backend/libs/services/upload.go | 11 +- backend/static/css/00-base.css | 83 ++++++++++- backend/static/css/15-revamp.css | 214 ++++++++++++++++++++++++++++ backend/static/css/20-upload.css | 23 ++- backend/static/js/05-theme.js | 53 +++++++ backend/static/js/40-upload.js | 22 +++ backend/templates/layouts/base.html | 9 ++ backend/templates/pages/api.html | 1 + backend/templates/pages/home.html | 13 +- 11 files changed, 504 insertions(+), 30 deletions(-) create mode 100644 backend/static/css/15-revamp.css create mode 100644 backend/static/js/05-theme.js diff --git a/backend/libs/handlers/pages.go b/backend/libs/handlers/pages.go index 50220de..cfeda39 100644 --- a/backend/libs/handlers/pages.go +++ b/backend/libs/handlers/pages.go @@ -9,11 +9,18 @@ import ( ) type homeData struct { - MaxUploadSize string - LimitSummary string - Collections []collectionView - IsAdmin bool - AnonymousOpen bool + MaxUploadSize string + LimitSummary string + Collections []collectionView + IsAdmin bool + AnonymousOpen bool + ExpiryOptions []expiryOption + DefaultExpiryMinutes int +} + +type expiryOption struct { + Minutes int + Label string } func (a *App) Home(w http.ResponseWriter, r *http.Request) { @@ -40,20 +47,92 @@ func (a *App) Home(w http.ResponseWriter, r *http.Request) { return } maxUploadSize, limitSummary := a.homeUploadPolicyLabels(settings, user, loggedIn, isAdmin) + expiryOptions, defaultExpiry := a.homeExpiryOptions(settings, user, loggedIn, isAdmin) a.renderPage(w, r, http.StatusOK, "home.html", web.PageData{ Title: "Upload your files", Description: "Upload and share files through a self-hosted Warpbox instance.", CurrentUser: currentUser, Data: homeData{ - MaxUploadSize: maxUploadSize, - LimitSummary: limitSummary, - Collections: collections, - IsAdmin: isAdmin, - AnonymousOpen: settings.AnonymousUploadsEnabled, + MaxUploadSize: maxUploadSize, + LimitSummary: limitSummary, + Collections: collections, + IsAdmin: isAdmin, + AnonymousOpen: settings.AnonymousUploadsEnabled, + ExpiryOptions: expiryOptions, + DefaultExpiryMinutes: defaultExpiry, }, }) } +// homeExpiryOptions builds the expiry ladder offered on the upload form, capped to +// the viewer's effective maximum retention. Admins have no cap (the dropdown is +// still capped at 365 days for sanity; the API accepts any value for admins). +func (a *App) homeExpiryOptions(settings services.UploadPolicySettings, user services.User, loggedIn, isAdmin bool) ([]expiryOption, int) { + maxDays := settings.AnonymousMaxDays + unlimited := false + switch { + case isAdmin: + unlimited = true + case loggedIn: + maxDays = a.settingsService.EffectivePolicyForUser(settings, user).MaxDays + } + return buildExpiryOptions(maxDays, unlimited) +} + +func buildExpiryOptions(maxDays int, unlimited bool) ([]expiryOption, int) { + ladder := []int{60, 720, 1440, 2880, 4320, 7200, 10080, 14400, 20160, 43200, 86400, 129600, 259200, 525600} + + capMinutes := maxDays * 24 * 60 + if unlimited || capMinutes <= 0 { + capMinutes = 525600 + } + + options := make([]expiryOption, 0, len(ladder)+1) + seen := make(map[int]bool) + for _, minutes := range ladder { + if minutes > capMinutes { + break + } + options = append(options, expiryOption{Minutes: minutes, Label: expiryLabel(minutes)}) + seen[minutes] = true + } + // Always offer the exact cap as a final choice (e.g. a 15-day limit). + if !unlimited && !seen[capMinutes] { + options = append(options, expiryOption{Minutes: capMinutes, Label: expiryLabel(capMinutes)}) + } + if len(options) == 0 { + options = append(options, expiryOption{Minutes: capMinutes, Label: expiryLabel(capMinutes)}) + } + + // Default to 24h when available, otherwise the smallest option offered. + defaultMinutes := options[0].Minutes + if seen[1440] { + defaultMinutes = 1440 + } + return options, defaultMinutes +} + +func expiryLabel(minutes int) string { + switch { + case minutes < 60: + return strconv.Itoa(minutes) + " minutes" + case minutes < 1440: + hours := minutes / 60 + if hours == 1 { + return "1 hour" + } + return strconv.Itoa(hours) + " hours" + case minutes == 1440: + return "24 hours" + default: + days := minutes / 1440 + if days == 1 { + return "1 day" + } + return strconv.Itoa(days) + " days" + } +} + func (a *App) homeUploadPolicyLabels(settings services.UploadPolicySettings, user services.User, loggedIn, isAdmin bool) (string, string) { if isAdmin { return "No file size limit", "Admin uploads bypass storage and daily caps." diff --git a/backend/libs/handlers/upload.go b/backend/libs/handlers/upload.go index 0983224..a82ab94 100644 --- a/backend/libs/handlers/upload.go +++ b/backend/libs/handlers/upload.go @@ -79,8 +79,14 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) { helpers.WriteJSONError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("expiration cannot exceed %d days", effectivePolicy.MaxDays)) return } + expiresMinutes := parseInt(r.FormValue("expires_minutes")) + if expiresMinutes > 0 && !isAdminUpload && expiresMinutes > effectivePolicy.MaxDays*24*60 { + helpers.WriteJSONError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("expiration cannot exceed %d days", effectivePolicy.MaxDays)) + return + } result, err := a.uploadService.CreateBox(files, services.UploadOptions{ MaxDays: maxDays, + ExpiresInMinutes: expiresMinutes, MaxDownloads: parseInt(r.FormValue("max_downloads")), Password: r.FormValue("password"), ObfuscateMetadata: r.FormValue("obfuscate_metadata") == "on", diff --git a/backend/libs/services/upload.go b/backend/libs/services/upload.go index 23ebd7d..df70c43 100644 --- a/backend/libs/services/upload.go +++ b/backend/libs/services/upload.go @@ -39,6 +39,7 @@ type UploadService struct { type UploadOptions struct { MaxDays int + ExpiresInMinutes int MaxDownloads int Password string ObfuscateMetadata bool @@ -199,14 +200,20 @@ func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOpti opts.MaxDays = 7 } + now := time.Now().UTC() + expiresAt := now.Add(time.Duration(opts.MaxDays) * 24 * time.Hour) + if opts.ExpiresInMinutes > 0 { + expiresAt = now.Add(time.Duration(opts.ExpiresInMinutes) * time.Minute) + } + box := Box{ ID: randomID(10), OwnerID: strings.TrimSpace(opts.OwnerID), CollectionID: strings.TrimSpace(opts.CollectionID), CreatorIP: strings.TrimSpace(opts.CreatorIP), StorageBackendID: normalizeBackendID(opts.StorageBackendID), - CreatedAt: time.Now().UTC(), - ExpiresAt: time.Now().UTC().Add(time.Duration(opts.MaxDays) * 24 * time.Hour), + CreatedAt: now, + ExpiresAt: expiresAt, MaxDownloads: opts.MaxDownloads, Obfuscate: opts.ObfuscateMetadata && strings.TrimSpace(opts.Password) != "", Files: make([]File, 0, len(files)), diff --git a/backend/static/css/00-base.css b/backend/static/css/00-base.css index 448e1f7..8c2e589 100644 --- a/backend/static/css/00-base.css +++ b/backend/static/css/00-base.css @@ -1,4 +1,41 @@ +/* + * Theme tokens. + * :root holds the default "revamp" (Aurora glass) theme so the page looks right + * even before JS runs or when JS is disabled. [data-theme="classic"] restores the + * original dark-zinc look exactly. The theme attribute is set on by + * /static/js/05-theme.js from localStorage before first paint. + */ :root { + color-scheme: dark; + --background: #0b0b16; + --foreground: #f5f3ff; + --card: #15132b; + --card-foreground: #f5f3ff; + --muted: #1e1b3a; + --muted-foreground: #a8a4cf; + --accent: #2a2550; + --accent-foreground: #f5f3ff; + --border: rgba(168, 150, 255, 0.16); + --input: rgba(168, 150, 255, 0.22); + --primary: #8b5cf6; + --primary-foreground: #ffffff; + --primary-hover: #7c3aed; + --ring: #a78bfa; + --success: #5eead4; + --danger: #fb7185; + --radius: 0.875rem; + --shadow: 0 24px 70px rgba(8, 4, 32, 0.6); + --font-sans: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --header-bg: rgba(11, 11, 22, 0.68); + --body-bg: + radial-gradient(circle at 50% -10%, rgba(139, 92, 246, 0.18), transparent 34rem), + linear-gradient(180deg, #0b0b16 0%, #0a0918 100%); + --surface-1: rgba(139, 92, 246, 0.07); + --surface-1-hover: rgba(139, 92, 246, 0.14); + --surface-2: rgba(139, 92, 246, 0.05); +} + +:root[data-theme="classic"] { color-scheme: dark; --background: #09090b; --foreground: #fafafa; @@ -12,10 +49,20 @@ --input: rgba(255, 255, 255, 0.15); --primary: #f4f4f5; --primary-foreground: #18181b; + --primary-hover: #e4e4e7; --ring: #71717a; --success: #86efac; + --danger: #fca5a5; --radius: 0.625rem; --shadow: 0 24px 70px rgba(0, 0, 0, 0.45); + --font-sans: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --header-bg: rgba(9, 9, 11, 0.84); + --body-bg: + radial-gradient(circle at 50% -10%, rgba(82, 82, 91, 0.32), transparent 34rem), + linear-gradient(180deg, #09090b 0%, #0f0f12 100%); + --surface-1: rgba(39, 39, 42, 0.42); + --surface-1-hover: rgba(39, 39, 42, 0.68); + --surface-2: rgba(39, 39, 42, 0.28); } * { @@ -23,19 +70,18 @@ } html { - font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-family: var(--font-sans); background: var(--background); color: var(--foreground); } body { + position: relative; min-height: 100vh; margin: 0; display: flex; flex-direction: column; - background: - radial-gradient(circle at 50% -10%, rgba(82, 82, 91, 0.32), transparent 34rem), - linear-gradient(180deg, #09090b 0%, #0f0f12 100%); + background: var(--body-bg); } a { @@ -77,7 +123,7 @@ svg { top: 0; z-index: 20; border-bottom: 1px solid var(--border); - background: rgba(9, 9, 11, 0.84); + background: var(--header-bg); backdrop-filter: blur(14px); } @@ -147,7 +193,7 @@ h1 { .hero-copy p, .download-subtitle, .panel-header p { - margin: 0.55rem 0 0; + margin: 0 0 1rem 0; color: var(--muted-foreground); font-size: 0.95rem; line-height: 1.5; @@ -307,7 +353,7 @@ button { } .button-primary:hover { - background: #e4e4e7; + background: var(--primary-hover); } .button-outline { @@ -385,12 +431,35 @@ pre code { margin: 0 auto; padding: 1rem 0; display: flex; + align-items: center; justify-content: space-between; gap: 1rem; color: var(--muted-foreground); font-size: 0.78rem; } +.theme-picker { + display: inline-flex; + align-items: center; + gap: 0.45rem; +} + +.theme-picker > span { + display: block; + margin: 0; + color: var(--muted-foreground); + font-size: 0.78rem; + font-weight: 600; +} + +.theme-picker select { + width: auto; + min-height: 1.9rem; + padding: 0.2rem 0.55rem; + border-radius: calc(var(--radius) - 0.25rem); + font-size: 0.78rem; +} + .footer-links a { text-decoration: none; } diff --git a/backend/static/css/15-revamp.css b/backend/static/css/15-revamp.css new file mode 100644 index 0000000..9cd97bc --- /dev/null +++ b/backend/static/css/15-revamp.css @@ -0,0 +1,214 @@ +/* + * Revamp ("Aurora glass") flourishes. + * + * These rules only apply to the default/revamp theme. They are scoped to + * :root:not([data-theme="classic"]) so they cover both the explicit + * data-theme="revamp" attribute AND the no-JS default (no attribute), while + * never touching the classic theme. Token colours live in 00-base.css; this + * file adds the things a flat token swap can't: the animated aurora backdrop, + * frosted glass, gradient accents, glow and motion. + */ + +:root:not([data-theme="classic"]) { + scroll-behavior: smooth; +} + +/* Animated aurora backdrop ------------------------------------------------ */ +:root:not([data-theme="classic"]) body::before { + content: ""; + position: fixed; + inset: -20vmax; + z-index: -1; + pointer-events: none; + background: + radial-gradient(38vmax 38vmax at 18% 12%, rgba(99, 102, 241, 0.38), transparent 60%), + radial-gradient(34vmax 34vmax at 82% 18%, rgba(34, 211, 238, 0.26), transparent 60%), + radial-gradient(40vmax 40vmax at 70% 88%, rgba(139, 92, 246, 0.34), transparent 62%), + radial-gradient(30vmax 30vmax at 12% 82%, rgba(236, 72, 153, 0.22), transparent 60%); + filter: blur(8px) saturate(125%); + animation: aurora-drift 26s ease-in-out infinite alternate; +} + +:root:not([data-theme="classic"]) body::after { + content: ""; + position: fixed; + inset: 0; + z-index: -1; + pointer-events: none; + /* faint grain/vignette to keep the glow from washing out text */ + background: radial-gradient(circle at 50% 40%, transparent 0, rgba(10, 9, 24, 0.55) 78%); +} + +@keyframes aurora-drift { + 0% { + transform: translate3d(-4%, -2%, 0) rotate(0deg) scale(1.05); + } + 50% { + transform: translate3d(3%, 2%, 0) rotate(8deg) scale(1.12); + } + 100% { + transform: translate3d(2%, -3%, 0) rotate(-6deg) scale(1.08); + } +} + +@media (prefers-reduced-motion: reduce) { + :root:not([data-theme="classic"]) body::before { + animation: none; + } +} + +/* Frosted glass cards ----------------------------------------------------- */ +:root:not([data-theme="classic"]) .card { + background: linear-gradient( + 155deg, + color-mix(in srgb, var(--card) 78%, transparent), + color-mix(in srgb, var(--card) 92%, transparent) + ); + border-color: rgba(168, 150, 255, 0.18); + backdrop-filter: blur(18px) saturate(140%); + -webkit-backdrop-filter: blur(18px) saturate(140%); +} + +/* Sticky header gets the same glassy treatment */ +:root:not([data-theme="classic"]) .site-header { + backdrop-filter: blur(20px) saturate(150%); + -webkit-backdrop-filter: blur(20px) saturate(150%); +} + +/* Brand mark glows */ +:root:not([data-theme="classic"]) .brand-mark { + background: linear-gradient(135deg, #8b5cf6, #6366f1 55%, #22d3ee); + color: #fff; + box-shadow: 0 6px 18px rgba(124, 58, 237, 0.45); +} + +/* Headings get a soft gradient sheen */ +:root:not([data-theme="classic"]) h1 { + background: linear-gradient(120deg, #f5f3ff 0%, #c4b5fd 60%, #67e8f9 100%); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} + +/* Gradient primary buttons ------------------------------------------------ */ +:root:not([data-theme="classic"]) .button-primary, +:root:not([data-theme="classic"]) .button.is-active { + background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 55%, #22d3ee 130%); + color: #fff; + border-color: transparent; + box-shadow: 0 8px 22px rgba(99, 102, 241, 0.38); + transition: transform 140ms ease, box-shadow 160ms ease, filter 160ms ease; +} + +:root:not([data-theme="classic"]) .button-primary:hover { + background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 55%, #22d3ee 130%); + filter: brightness(1.08); + box-shadow: 0 12px 30px rgba(99, 102, 241, 0.5); + transform: translateY(-1px); +} + +:root:not([data-theme="classic"]) .button-primary:active { + transform: translateY(0); +} + +/* Outline / ghost buttons get a subtle lift on hover */ +:root:not([data-theme="classic"]) .button-outline, +:root:not([data-theme="classic"]) .button-ghost { + transition: background 140ms ease, border-color 140ms ease, transform 140ms ease; +} + +:root:not([data-theme="classic"]) .button-outline:hover, +:root:not([data-theme="classic"]) .button-ghost:hover { + border-color: rgba(168, 150, 255, 0.4); + transform: translateY(-1px); +} + +/* Glow focus rings -------------------------------------------------------- */ +:root:not([data-theme="classic"]) :focus-visible { + outline: 2px solid transparent; + box-shadow: 0 0 0 2px var(--background), 0 0 0 4px var(--ring), 0 0 16px rgba(167, 139, 250, 0.55); +} + +:root:not([data-theme="classic"]) input:focus, +:root:not([data-theme="classic"]) select:focus { + border-color: var(--ring); + box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.22); +} + +/* Drop zone: animated, glowing -------------------------------------------- */ +:root:not([data-theme="classic"]) .drop-zone { + border-color: rgba(168, 150, 255, 0.3); + background: + radial-gradient(120% 90% at 50% 0%, rgba(139, 92, 246, 0.1), transparent 70%), + var(--surface-1); + transition: border-color 180ms ease, background 180ms ease, transform 180ms ease, box-shadow 180ms ease; +} + +:root:not([data-theme="classic"]) .drop-zone:hover, +:root:not([data-theme="classic"]) .drop-zone.is-dragging { + border-color: #a78bfa; + box-shadow: 0 0 0 1px rgba(167, 139, 250, 0.4), 0 18px 50px rgba(99, 102, 241, 0.28); + transform: translateY(-2px); +} + +:root:not([data-theme="classic"]) .drop-icon { + color: #c4b5fd; +} + +:root:not([data-theme="classic"]) .drop-zone.is-dragging .drop-icon { + animation: drop-bounce 700ms ease infinite; +} + +@keyframes drop-bounce { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-6px); } +} + +/* Badges pick up a tinted glass look */ +:root:not([data-theme="classic"]) .badge { + background: rgba(139, 92, 246, 0.14); + color: #d6ccff; + border: 1px solid rgba(168, 150, 255, 0.22); +} + +/* File / result rows lift on hover */ +:root:not([data-theme="classic"]) .download-item, +:root:not([data-theme="classic"]) .result-item { + background: color-mix(in srgb, var(--card) 60%, transparent); + border-color: rgba(168, 150, 255, 0.14); + transition: border-color 140ms ease, transform 140ms ease, background 140ms ease; +} + +:root:not([data-theme="classic"]) .download-item:hover { + border-color: rgba(168, 150, 255, 0.34); + transform: translateY(-1px); +} + +/* Thumbnails on the download page */ +:root:not([data-theme="classic"]) .file-emblem { + background: linear-gradient(135deg, rgba(139, 92, 246, 0.25), rgba(34, 211, 238, 0.18)); + color: #d6ccff; + border: 1px solid rgba(168, 150, 255, 0.22); +} + +/* Gentle entrance for primary content cards */ +:root:not([data-theme="classic"]) main > * { + animation: rise-in 420ms ease both; +} + +@keyframes rise-in { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (prefers-reduced-motion: reduce) { + :root:not([data-theme="classic"]) main > * { + animation: none; + } +} diff --git a/backend/static/css/20-upload.css b/backend/static/css/20-upload.css index 690d3a4..f3ae01f 100644 --- a/backend/static/css/20-upload.css +++ b/backend/static/css/20-upload.css @@ -13,6 +13,21 @@ text-align: center; } +.hero-eyebrow { + margin: 0 0 2.5rem 0; + display: inline-flex; + align-items: center; + gap: 0.4rem; + border-radius: 999px; + padding: 0.3rem 0.85rem; + background: var(--surface-1); + border: 1px solid var(--border); + color: var(--muted-foreground); + font-size: 0.76rem; + font-weight: 600; + letter-spacing: 0.01em; +} + .drop-zone { min-height: 19rem; @@ -23,7 +38,7 @@ padding: 2rem; border: 2px dashed var(--border); border-radius: var(--radius); - background: rgba(39, 39, 42, 0.42); + background: var(--surface-1); text-align: center; cursor: pointer; transition: border-color 160ms ease, background 160ms ease; @@ -32,7 +47,7 @@ .drop-zone:hover, .drop-zone.is-dragging { border-color: var(--primary); - background: rgba(39, 39, 42, 0.68); + background: var(--surface-1-hover); } .drop-zone input { @@ -76,7 +91,7 @@ margin-top: 1rem; border: 1px solid var(--border); border-radius: var(--radius); - background: rgba(39, 39, 42, 0.28); + background: var(--surface-2); padding: 0.75rem 0.9rem; } @@ -140,7 +155,7 @@ button { } .button-primary:hover { - background: #e4e4e7; + background: var(--primary-hover); } .button-outline { diff --git a/backend/static/js/05-theme.js b/backend/static/js/05-theme.js new file mode 100644 index 0000000..2b142bc --- /dev/null +++ b/backend/static/js/05-theme.js @@ -0,0 +1,53 @@ +/* + * Theme init + toggle. + * + * Loaded in WITHOUT defer so the first block runs before paint and sets + * the theme attribute, avoiding a flash of the wrong theme. The choice lives in + * localStorage (no cookie, no server round-trip) and applies site-wide. + * + * CSP note: this is an external /static file, so it is allowed under + * script-src 'self'. We only toggle an attribute / class — never inject inline + *