1 Commits

Author SHA1 Message Date
830d2a885c refactor(ui): remaster settings and navigation layout
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m8s
- Update navigation labels from "My Account" to "Dashboard" and "Login" to "Sign in", updating tests accordingly.
- Redesign settings forms into structured sections with improved spacing and layout.
- Add CSS styles for tabs, small buttons, and responsive settings sections to enhance the user experience.
2026-05-30 18:17:13 +03:00
11 changed files with 606 additions and 159 deletions

111
CLAUDE.md Normal file
View File

@@ -0,0 +1,111 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Commands
**Go Executable:**
```bash
/home/linuxbrew/.linuxbrew/bin/go
```
**Run dev server:**
```bash
# First-time setup: copy the env template
cp scripts/env/dev.env.example scripts/env/dev.env
# Edit scripts/env/dev.env to set WARPBOX_ADMIN_TOKEN and other values, then:
./scripts/run/dev.sh
```
**Run directly (one-off):**
```bash
cd backend
go run ./cmd/warpbox
```
**Run all tests:**
```bash
cd backend
go test ./...
```
**Run a single test or package:**
```bash
cd backend
go test ./libs/services/... -run TestDeleteTokenVerification
go test ./libs/handlers/... -v
```
**Build:**
```bash
cd backend
go build ./cmd/warpbox
```
## Architecture
Warpbox is a self-hosted file-sharing app. All code lives under `backend/`. There is no frontend build step — the server renders Go templates and serves static assets directly.
### Startup flow
`cmd/warpbox/main.go``config.Load()``httpserver.New()``server.ListenAndServe()`
`httpserver.New()` wires everything together:
1. Creates `web.Renderer` (template engine)
2. Creates `UploadService` (opens bbolt DB, creates `files/` and `db/` dirs)
3. Creates `AuthService` (reuses same `*bbolt.DB`)
4. Creates `SettingsService` (reuses same `*bbolt.DB`)
5. Starts background jobs via `jobs.StartAll`
6. Creates `handlers.App` with all services
7. Registers all routes on a `http.ServeMux`
8. Wraps the mux in middleware chain: `Recoverer → RequestID → SecurityHeaders → Gzip → Logger`
### Services
The three services share a single bbolt database. Each owns distinct buckets:
| Service | Buckets |
|---|---|
| `UploadService` | `boxes` |
| `AuthService` | `users`, `user_emails`, `sessions`, `invites`, `collections` |
| `SettingsService` | `settings`, `usage` |
`UploadService` owns the DB handle. `AuthService` and `SettingsService` receive `uploadService.DB()`.
### Data model
- **Box** — one upload session. Has expiry, optional download limit, optional password (SHA-256 salted hash), optional owner (`OwnerID`), optional collection. Stored as JSON in the `boxes` bucket.
- **File** — belongs to a Box. Stored on disk as `data/files/{boxID}/@each@{fileID}.ext`. Thumbnails at `@thumb@{fileID}.jpg`.
- Box metadata is also written to `data/files/{boxID}/.warpbox.box.json` on every save.
- **User** passwords are hashed with argon2id. Session tokens are SHA-256 hashed before storage.
- **Delete tokens** for anonymous boxes are one-time random IDs, stored only as a SHA-256 hash.
### Handlers
`handlers.App` holds all three services plus config, logger, and renderer. `RegisterRoutes` maps every URL pattern. Handler files are split by concern: `upload.go`, `download.go`, `dashboard.go` (user `/app`), `admin.go`, `auth.go`, `manage.go`, `pages.go`.
### Upload policy enforcement
`SettingsService` stores per-day usage records keyed by `ip:{ip}:{date}` or `user:{userID}:{date}`. The upload handler (`handlers/upload.go`) checks these against `UploadPolicySettings` before accepting a multipart form. Admins bypass all per-upload and daily limits.
### Background jobs
`jobs.StartAll` launches goroutines for:
- **Cleanup** (`WARPBOX_CLEANUP_ENABLED`): deletes expired boxes and boxes that hit their download limit.
- **Thumbnails** (`WARPBOX_THUMBNAIL_ENABLED`): generates JPEG thumbnails for image/video files that don't have one yet.
### Configuration
All config comes from env vars via `config.Load()`. The dev script sources `scripts/env/dev.env`. `WARPBOX_BASE_URL` is required and must not be empty. Size values accept an optional `MB`/`Mb` suffix and support fractions (e.g. `0.5` = 512 KiB).
### Logging
Structured JSONL logs go to `data/logs/{YYYY-MM-DD}.log` via `log/slog`. Every log entry includes `source` (e.g. `"user-upload"`, `"admin"`) and `severity` fields. User-activity events include a numeric `code` field (e.g. `2001` = upload complete, `2101` = box deleted).
### Template rendering
`web.Renderer` parses all templates from `backend/templates/` at startup using `html/template`. Page data is passed as `web.PageData`. The base layout is `templates/layouts/base.html`. The current logged-in user is injected into every page render via `a.currentPublicUser(r)`.
## First-run bootstrap
On a fresh `data/` directory, visit `/register` to create the first admin account. After bootstrap, normal registration is closed. Admins create invite links from `/admin/users`. The `WARPBOX_ADMIN_TOKEN` env var provides emergency fallback access at `/admin/login`.

View File

@@ -387,7 +387,7 @@ func TestAPIDocsHeaderReflectsLoggedInUser(t *testing.T) {
}
body := response.Body.String()
header := body[:strings.Index(body, "<main")]
if !strings.Contains(header, "My Account") || strings.Contains(header, ">Login<") || strings.Contains(header, "Health") {
if !strings.Contains(header, "Dashboard") || strings.Contains(header, "Sign in") || strings.Contains(header, "Health") {
t.Fatalf("api header did not reflect logged-in state: %s", body)
}
}
@@ -404,7 +404,7 @@ func TestAPIDocsHeaderReflectsLoggedOutUser(t *testing.T) {
}
body := response.Body.String()
header := body[:strings.Index(body, "<main")]
if !strings.Contains(header, ">Login<") || !strings.Contains(header, ">API<") || strings.Contains(header, "Health") || strings.Contains(header, "My Account") {
if !strings.Contains(header, "Sign in") || !strings.Contains(header, ">API<") || strings.Contains(header, "Health") || strings.Contains(header, "Dashboard") {
t.Fatalf("api header did not reflect logged-out state: %s", body)
}
}

View File

@@ -337,12 +337,12 @@ h1 {
.settings-form {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.9rem;
gap: 1.5rem;
}
.settings-form-narrow {
grid-template-columns: minmax(0, 1fr);
gap: 0.9rem;
}
.settings-form label {
@@ -1292,4 +1292,239 @@ pre code {
.metric-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.tabs-bar {
flex-direction: column;
align-items: stretch;
}
.settings-section {
grid-template-columns: 1fr;
}
.new-collection-body {
position: static;
width: 100%;
margin-top: 0.5rem;
box-shadow: none;
}
}
/* ── UX remaster ───────────────────────────────────────────── */
.button-sm {
min-height: 1.85rem;
padding: 0.3rem 0.65rem;
font-size: 0.8rem;
}
/* Tab navigation */
.tabs-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
}
.tab-list {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.3rem;
}
.tab {
display: inline-flex;
align-items: center;
height: 2rem;
padding: 0 0.75rem;
border-radius: 999px;
border: 1px solid transparent;
color: var(--muted-foreground);
font-size: 0.84rem;
font-weight: 500;
text-decoration: none;
transition: background 120ms, color 120ms, border-color 120ms;
}
.tab:hover {
background: var(--muted);
color: var(--foreground);
}
.tab.is-active {
border-color: var(--border);
background: var(--muted);
color: var(--foreground);
font-weight: 650;
}
/* Sidebar structure */
.sidebar-sep {
height: 1px;
border: 0;
background: var(--border);
margin: 0.5rem 0;
}
.sidebar-nav {
display: grid;
gap: 0.25rem;
}
/* Inline row edit (details/summary in table cells) */
.row-edit {
margin-top: 0.35rem;
}
.row-edit > summary {
display: inline-flex;
align-items: center;
color: var(--muted-foreground);
font-size: 0.72rem;
cursor: pointer;
list-style: none;
text-decoration: underline;
text-decoration-style: dotted;
text-underline-offset: 2px;
opacity: 0.75;
}
.row-edit > summary::-webkit-details-marker { display: none; }
.row-edit[open] > summary {
opacity: 1;
}
.row-edit-form {
display: flex;
gap: 0.4rem;
align-items: center;
margin-top: 0.4rem;
}
.row-edit-form input,
.row-edit-form select {
width: auto;
flex: 1;
min-width: 8rem;
min-height: 1.9rem;
font-size: 0.8rem;
padding: 0.25rem 0.55rem;
}
/* Badge variants */
.badge-active {
background: rgba(134, 239, 172, 0.12);
color: #86efac;
}
.badge-disabled {
background: rgba(252, 165, 165, 0.1);
color: #fca5a5;
}
.badge-expired {
opacity: 0.55;
}
/* Collection create dropdown */
.new-collection-drop {
position: relative;
flex-shrink: 0;
}
.new-collection-drop > summary {
list-style: none;
cursor: pointer;
}
.new-collection-drop > summary::-webkit-details-marker { display: none; }
.new-collection-body {
position: absolute;
right: 0;
top: calc(100% + 0.5rem);
z-index: 10;
width: 15rem;
padding: 1rem;
background: color-mix(in srgb, var(--card) 97%, #000);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
display: grid;
gap: 0.65rem;
}
.new-collection-body label {
display: grid;
gap: 0.35rem;
color: var(--muted-foreground);
font-size: 0.82rem;
}
/* Copyable URL field */
.copy-field {
display: flex;
gap: 0.5rem;
align-items: center;
margin-top: 0.75rem;
}
.copy-field input {
flex: 1;
min-width: 0;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-size: 0.8rem;
color: var(--muted-foreground);
}
/* Settings sections */
.settings-section {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.9rem;
}
.settings-section-title {
grid-column: 1 / -1;
margin: 0;
padding-bottom: 0.6rem;
border-bottom: 1px solid var(--border);
font-size: 0.875rem;
font-weight: 650;
color: var(--foreground);
}
.settings-section .checkbox-field {
grid-column: 1 / -1;
}
.settings-section label {
display: grid;
gap: 0.35rem;
color: var(--muted-foreground);
font-size: 0.82rem;
}
/* Quota form in admin users table */
.quota-form {
display: flex;
gap: 0.4rem;
align-items: center;
margin: 0;
}
.quota-form input {
width: 6.5rem;
min-width: 0;
}
/* Nav username indicator in header */
.nav-username {
max-width: 8rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@@ -28,11 +28,13 @@
</a>
<div class="nav-links">
{{if .CurrentUser}}
<a class="button button-ghost" href="/app">Dashboard</a>
{{if eq .CurrentUser.Role "admin"}}<a class="button button-ghost" href="/admin">Admin</a>{{end}}
<a class="button button-ghost" href="/api">API</a>
<a class="button button-outline" href="/account/settings">My Account</a>
<a class="button button-outline" href="/account/settings"><span class="nav-username">{{.CurrentUser.Username}}</span></a>
{{else}}
<a class="button button-ghost" href="/login">Login</a>
<a class="button button-ghost" href="/api">API</a>
<a class="button button-outline" href="/login">Sign in</a>
{{end}}
</div>
</nav>
@@ -44,7 +46,7 @@
<footer class="site-footer">
<span>{{.AppName}} · {{.CurrentYear}} · self-hosted</span>
<span class="footer-links">{{if .CurrentUser}}<a href="/api">API</a><a href="/account/settings">My Account</a>{{else}}<a href="/login">Login</a><a href="/api">API</a>{{end}}</span>
<span class="footer-links">{{if .CurrentUser}}<a href="/app">Dashboard</a><a href="/api">API</a><a href="/account/settings">Account</a>{{else}}<a href="/login">Sign in</a><a href="/api">API</a>{{end}}</span>
</footer>
</body>
</html>

View File

@@ -3,11 +3,14 @@
{{define "content"}}
<section class="app-shell" aria-labelledby="account-title">
<aside class="app-sidebar">
<a class="sidebar-link" href="/app">My files</a>
<a class="sidebar-link is-active" href="/account/settings">Account settings</a>
{{if eq .Data.Role "admin"}}<a class="sidebar-link" href="/admin">Admin</a>{{end}}
<nav class="sidebar-nav">
<a class="sidebar-link" href="/app">My Files</a>
<a class="sidebar-link is-active" href="/account/settings">Account</a>
{{if eq .Data.Role "admin"}}<a class="sidebar-link" href="/admin">Admin panel</a>{{end}}
</nav>
<hr class="sidebar-sep">
<form class="sidebar-logout" action="/logout" method="post">
<button class="button button-outline" type="submit">Logout</button>
<button class="button button-outline" type="submit">Sign out</button>
</form>
</aside>

View File

@@ -3,13 +3,19 @@
{{define "content"}}
<section class="app-shell admin-shell" aria-labelledby="admin-title">
<aside class="app-sidebar">
<nav class="sidebar-nav">
<a class="sidebar-link {{if eq .Data.Section "overview"}}is-active{{end}}" href="/admin">Overview</a>
<a class="sidebar-link {{if eq .Data.Section "files"}}is-active{{end}}" href="/admin/files">Files</a>
<a class="sidebar-link" href="/admin/users">Users</a>
<a class="sidebar-link" href="/admin/settings">Settings</a>
<a class="sidebar-link" href="/app">My files</a>
</nav>
<hr class="sidebar-sep">
<nav class="sidebar-nav">
<a class="sidebar-link" href="/app">My Files</a>
</nav>
<hr class="sidebar-sep">
<form class="sidebar-logout" action="/admin/logout" method="post">
<button class="button button-outline" type="submit">Logout</button>
<button class="button button-outline" type="submit">Sign out</button>
</form>
</aside>

View File

@@ -3,13 +3,19 @@
{{define "content"}}
<section class="app-shell admin-shell" aria-labelledby="admin-settings-title">
<aside class="app-sidebar">
<nav class="sidebar-nav">
<a class="sidebar-link" href="/admin">Overview</a>
<a class="sidebar-link" href="/admin/files">Files</a>
<a class="sidebar-link" href="/admin/users">Users</a>
<a class="sidebar-link is-active" href="/admin/settings">Settings</a>
<a class="sidebar-link" href="/app">My files</a>
</nav>
<hr class="sidebar-sep">
<nav class="sidebar-nav">
<a class="sidebar-link" href="/app">My Files</a>
</nav>
<hr class="sidebar-sep">
<form class="sidebar-logout" action="/admin/logout" method="post">
<button class="button button-outline" type="submit">Logout</button>
<button class="button button-outline" type="submit">Sign out</button>
</form>
</aside>
@@ -26,35 +32,43 @@
<div class="table-header">
<div>
<h2>Upload policy</h2>
<p>Values are stored in megabytes. Admin users bypass these upload caps.</p>
<p>Admin users bypass all upload caps. Values are in megabytes.</p>
</div>
</div>
<form class="settings-form" action="/admin/settings" method="post">
<div class="settings-section">
<h3 class="settings-section-title">Anonymous uploads</h3>
<label class="checkbox-field">
<input type="checkbox" name="anonymous_uploads_enabled" {{if .Data.Settings.AnonymousUploadsEnabled}}checked{{end}}>
<span>Allow anonymous uploads</span>
</label>
<label>
<span>Anonymous max upload MB</span>
<span>Max upload size (MB)</span>
<input name="anonymous_max_upload_mb" value="{{.Data.Settings.AnonymousMaxUploadMB}}" required>
</label>
<label>
<span>Anonymous daily upload MB per IP</span>
<span>Daily cap per IP (MB)</span>
<input name="anonymous_daily_upload_mb" value="{{.Data.Settings.AnonymousDailyUploadMB}}" required>
</label>
</div>
<div class="settings-section">
<h3 class="settings-section-title">User limits</h3>
<label>
<span>User daily upload MB</span>
<span>Daily upload cap (MB)</span>
<input name="user_daily_upload_mb" value="{{.Data.Settings.UserDailyUploadMB}}" required>
</label>
<label>
<span>Default user storage MB</span>
<span>Default storage quota (MB)</span>
<input name="default_user_storage_mb" value="{{.Data.Settings.DefaultUserStorageMB}}" required>
</label>
<label>
<span>Usage retention days</span>
<span>Usage retention (days)</span>
<input type="number" name="usage_retention_days" min="1" value="{{.Data.Settings.UsageRetentionDays}}" required>
</label>
</div>
<button class="button button-primary" type="submit">Save settings</button>
</form>
</div>

View File

@@ -3,13 +3,19 @@
{{define "content"}}
<section class="app-shell admin-shell" aria-labelledby="admin-users-title">
<aside class="app-sidebar">
<nav class="sidebar-nav">
<a class="sidebar-link" href="/admin">Overview</a>
<a class="sidebar-link" href="/admin/files">Files</a>
<a class="sidebar-link is-active" href="/admin/users">Users</a>
<a class="sidebar-link" href="/admin/settings">Settings</a>
<a class="sidebar-link" href="/app">My files</a>
</nav>
<hr class="sidebar-sep">
<nav class="sidebar-nav">
<a class="sidebar-link" href="/app">My Files</a>
</nav>
<hr class="sidebar-sep">
<form class="sidebar-logout" action="/admin/logout" method="post">
<button class="button button-outline" type="submit">Logout</button>
<button class="button button-outline" type="submit">Sign out</button>
</form>
</aside>
@@ -30,7 +36,11 @@
</div>
</div>
{{if .Data.LastInviteURL}}
<p class="manage-link"><span>Invite link:</span> <a href="{{.Data.LastInviteURL}}">{{.Data.LastInviteURL}}</a></p>
<div class="copy-field">
<input type="text" value="{{.Data.LastInviteURL}}" readonly id="invite-url-field" aria-label="Invite link">
<button class="button button-outline button-sm" type="button"
onclick="navigator.clipboard.writeText(document.getElementById('invite-url-field').value).then(()=>{this.textContent='Copied!';setTimeout(()=>this.textContent='Copy',2000)})">Copy</button>
</div>
{{end}}
<form class="inline-controls" action="/admin/invites" method="post">
<label><span>Email</span><input type="email" name="email" required></label>
@@ -42,35 +52,55 @@
<div class="card admin-table-card">
<div class="card-content">
<div class="table-header"><h2>Users</h2><p>Disable accounts or create reset links.</p></div>
<div class="table-header">
<h2>Users</h2>
<p>Disable accounts or generate reset links.</p>
</div>
<div class="admin-table-wrap">
<table class="admin-table">
<thead><tr><th>User</th><th>Email</th><th>Role</th><th>Status</th><th>Storage</th><th>Today</th><th>Joined</th><th>Actions</th></tr></thead>
<thead>
<tr>
<th>User</th>
<th>Email</th>
<th>Role</th>
<th>Status</th>
<th>Storage</th>
<th>Today</th>
<th>Joined</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{{range .Data.Users}}
<tr>
<td>{{.Username}}</td>
<td>{{.Email}}</td>
<td>{{.Role}}</td>
<td><span class="badge">{{.Status}}</span></td>
<td><span class="badge {{if eq .Status "active"}}badge-active{{else}}badge-disabled{{end}}">{{.Status}}</span></td>
<td>{{.StorageUsed}} / {{.StorageQuota}}</td>
<td>{{.DailyUsed}}</td>
<td>{{.CreatedAt}}</td>
<td class="table-actions">
{{if eq .Status "disabled"}}
<form action="/admin/users/{{.ID}}/disable?disabled=false" method="post"><button class="button button-outline" type="submit">Reactivate</button></form>
<form action="/admin/users/{{.ID}}/disable?disabled=false" method="post">
<button class="button button-outline button-sm" type="submit">Reactivate</button>
</form>
{{else}}
<form action="/admin/users/{{.ID}}/disable" method="post"><button class="button button-danger" type="submit">Disable</button></form>
<form action="/admin/users/{{.ID}}/disable" method="post">
<button class="button button-danger button-sm" type="submit">Disable</button>
</form>
{{end}}
<form action="/admin/users/{{.ID}}/reset" method="post"><button class="button button-outline" type="submit">Reset link</button></form>
<form action="/admin/users/{{.ID}}/quota" method="post">
<input class="compact-input" name="storage_quota_mb" placeholder="Quota MB">
<button class="button button-outline" type="submit">Quota</button>
<form action="/admin/users/{{.ID}}/reset" method="post">
<button class="button button-outline button-sm" type="submit">Reset link</button>
</form>
<form class="quota-form" action="/admin/users/{{.ID}}/quota" method="post">
<input name="storage_quota_mb" placeholder="Quota MB" title="Override storage quota in MB (leave blank to clear override)">
<button class="button button-outline button-sm" type="submit">Set</button>
</form>
</td>
</tr>
{{else}}
<tr><td colspan="8">No users yet.</td></tr>
<tr><td colspan="8" class="muted-copy">No users yet.</td></tr>
{{end}}
</tbody>
</table>

View File

@@ -3,18 +3,14 @@
{{define "content"}}
<section class="app-shell" aria-labelledby="dashboard-title">
<aside class="app-sidebar">
<a class="sidebar-link is-active" href="/app">Dashboard</a>
<a class="sidebar-link" href="/account/settings">Settings</a>
{{if eq .Data.User.Role "admin"}}<a class="sidebar-link" href="/admin">Admin</a>{{end}}
<nav class="sidebar-nav">
<a class="sidebar-link is-active" href="/app">My Files</a>
<a class="sidebar-link" href="/account/settings">Account</a>
{{if eq .Data.User.Role "admin"}}<a class="sidebar-link" href="/admin">Admin panel</a>{{end}}
</nav>
<hr class="sidebar-sep">
<form class="sidebar-logout" action="/logout" method="post">
<button class="button button-outline" type="submit">Logout</button>
</form>
<form class="collection-create" action="/app/collections" method="post">
<label>
<span>New collection</span>
<input name="name" placeholder="Projects">
</label>
<button class="button button-outline" type="submit">Create</button>
<button class="button button-outline" type="submit">Sign out</button>
</form>
</aside>
@@ -22,46 +18,91 @@
<div class="admin-header">
<div>
<p class="kicker">Personal space</p>
<h1 id="dashboard-title">My files</h1>
<h1 id="dashboard-title">My Files</h1>
<p class="muted-copy">{{.Data.StorageUsed}} used · max file size {{.Data.MaxUploadSize}}</p>
</div>
<a class="button button-primary" href="/">Upload files</a>
</div>
<div class="collection-tabs">
<a class="button {{if not .Data.Selected}}button-primary{{else}}button-outline{{end}}" href="/app">All</a>
<div class="tabs-bar">
<div class="tab-list" role="tablist">
<a class="tab {{if not .Data.Selected}}is-active{{end}}" href="/app">All</a>
{{range .Data.Collections}}
<a class="button {{if eq $.Data.Selected .ID}}button-primary{{else}}button-outline{{end}}" href="/app?collection={{.ID}}">{{.Name}}</a>
<a class="tab {{if eq $.Data.Selected .ID}}is-active{{end}}" href="/app?collection={{.ID}}">{{.Name}}</a>
{{end}}
</div>
<details class="new-collection-drop">
<summary class="button button-outline button-sm">+ Collection</summary>
<div class="new-collection-body">
<form action="/app/collections" method="post">
<label>
<span>Name</span>
<input name="name" placeholder="e.g. Projects" required>
</label>
<button class="button button-primary button-sm" type="submit">Create</button>
</form>
</div>
</details>
</div>
<div class="card admin-table-card">
<div class="card-content">
<div class="table-header"><h2>Owned boxes</h2><p>Collections organize boxes. Shared links remain unlisted.</p></div>
<div class="table-header">
<div>
<h2>Boxes</h2>
<p>Collections organise boxes. Shared links remain unlisted.</p>
</div>
</div>
<div class="admin-table-wrap">
<table class="admin-table">
<thead><tr><th>Title</th><th>Collection</th><th>Files</th><th>Size</th><th>Created</th><th>Expires</th><th>Actions</th></tr></thead>
<thead>
<tr>
<th>Title</th>
<th>Collection</th>
<th>Files</th>
<th>Size</th>
<th>Expires</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{{range .Data.Boxes}}
<tr>
<td class="file-name">{{.Title}}</td>
<td>{{if .CollectionName}}{{.CollectionName}}{{else}}Unsorted{{end}}</td>
<td>
<div class="file-name">{{.Title}}</div>
<details class="row-edit">
<summary>Rename</summary>
<form action="/app/boxes/{{.ID}}/rename" method="post" class="row-edit-form">
<input name="title" placeholder="New title">
<button class="button button-outline button-sm" type="submit">Save</button>
</form>
</details>
</td>
<td>
<div>{{if .CollectionName}}{{.CollectionName}}{{else}}<span class="muted-copy"></span>{{end}}</div>
<details class="row-edit">
<summary>Move</summary>
<form action="/app/boxes/{{.ID}}/move" method="post" class="row-edit-form">
<select name="collection_id">
<option value="">Unsorted</option>
{{range $.Data.Collections}}<option value="{{.ID}}">{{.Name}}</option>{{end}}
</select>
<button class="button button-outline button-sm" type="submit">Move</button>
</form>
</details>
</td>
<td>{{.FileCount}}</td>
<td>{{.Size}}</td>
<td>{{.CreatedAt}}</td>
<td>{{.ExpiresAt}}</td>
<td class="table-actions">
<a class="button button-outline" href="{{.URL}}" target="_blank" rel="noopener noreferrer">Open</a>
<form action="/app/boxes/{{.ID}}/rename" method="post"><input class="compact-input" name="title" placeholder="Rename"><button class="button button-outline" type="submit">Save</button></form>
<form action="/app/boxes/{{.ID}}/move" method="post">
<select name="collection_id"><option value="">Unsorted</option>{{range $.Data.Collections}}<option value="{{.ID}}">{{.Name}}</option>{{end}}</select>
<button class="button button-outline" type="submit">Move</button>
<a class="button button-outline button-sm" href="{{.URL}}" target="_blank" rel="noopener noreferrer">Open</a>
<form action="/app/boxes/{{.ID}}/delete" method="post">
<button class="button button-danger button-sm" type="submit">Delete</button>
</form>
<form action="/app/boxes/{{.ID}}/delete" method="post"><button class="button button-danger" type="submit">Delete</button></form>
</td>
</tr>
{{else}}
<tr><td colspan="7">You have no boxes yet.</td></tr>
<tr><td colspan="6" class="muted-copy">No boxes yet. Upload some files to get started.</td></tr>
{{end}}
</tbody>
</table>

View File

@@ -3,8 +3,13 @@
{{define "content"}}
<section class="upload-view" aria-labelledby="upload-title">
<div class="hero-copy">
{{if .CurrentUser}}
<h1 id="upload-title">Upload files.</h1>
<p>{{.Data.LimitSummary}}</p>
{{else}}
<h1 id="upload-title">Send a file. Get a link.</h1>
<p>Anonymous, self-hosted transfers. No account required.</p>
{{end}}
</div>
<form class="upload-panel card" id="upload-form" action="/api/v1/upload" method="post" enctype="multipart/form-data">

BIN
backend/warpbox Executable file

Binary file not shown.