Compare commits
11 commits
15fe9580f7
...
e077473bf0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e077473bf0 | ||
|
|
8bf142b5b1 | ||
|
|
0aedf61569 | ||
|
|
a691186d9a | ||
|
|
41e12d1235 | ||
|
|
10a495c13c | ||
|
|
e1506d5d2c | ||
|
|
135bbd875f | ||
|
|
f435c8aeaf | ||
|
|
de268af814 | ||
|
|
3bdddf69c6 |
6 changed files with 3272 additions and 1425 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -23,3 +23,6 @@ ansible/roles/signage_player/files/morz-agent
|
|||
player/agent/agent-linux-arm64
|
||||
docs/SESSION-MEMORY-*.md
|
||||
player/agent/morz-agent
|
||||
|
||||
# Git worktrees
|
||||
.worktrees/
|
||||
|
|
|
|||
1940
docs/superpowers/plans/2026-03-24-frontend-overhaul.md
Normal file
1940
docs/superpowers/plans/2026-03-24-frontend-overhaul.md
Normal file
File diff suppressed because it is too large
Load diff
192
docs/superpowers/specs/2026-03-24-frontend-overhaul-design.md
Normal file
192
docs/superpowers/specs/2026-03-24-frontend-overhaul-design.md
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
# Frontend Overhaul Design — MORZ Infoboard
|
||||
|
||||
**Date:** 2026-03-24
|
||||
**Status:** Approved
|
||||
**Scope:** All server-rendered HTML templates in `server/backend/internal/httpapi/`
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Overhaul the frontend of the MORZ Infoboard management UI to be professional, visually polished, and fun to use — while keeping the Go server-side rendering architecture and Bulma CSS framework intact. The MORZ red accent color anchors the brand identity throughout. Tenant and screen-user views are simplified to be idiot-proof; the admin section targets computer-literate users. The playlist editor becomes more interactive with inline editing and no-reload updates.
|
||||
|
||||
---
|
||||
|
||||
## 1. Design Tokens & Global Styles
|
||||
|
||||
A shared `<style>` block (or inline in each template) provides CSS custom properties overriding Bulma defaults:
|
||||
|
||||
| Token | Value | Usage |
|
||||
|---|---|---|
|
||||
| `--morz-red` | `#E30613` | Primary accent: buttons, active tabs, badges, focus rings |
|
||||
| `--morz-red-dark` | `#B8000F` | Hover/active state for red elements |
|
||||
| `--bg` | `#F7F8FA` | Page background |
|
||||
| `--surface` | `#FFFFFF` | Cards, panels |
|
||||
| `--shadow` | `0 1px 4px rgba(0,0,0,.08)` | Card elevation |
|
||||
| `--nav-bg` | `#1A1A2E` | Top navbar background |
|
||||
| `--radius-card` | `8px` | Card border radius |
|
||||
| `--radius-btn` | `6px` | Button border radius |
|
||||
|
||||
**Typography:** `system-ui, -apple-system, "Segoe UI", sans-serif` — no web font load, familiar, fast.
|
||||
|
||||
**Bulma overrides:** Set `--bulma-primary` to `--morz-red` so all Bulma primary-colored components (buttons, links, focus) automatically pick up the brand color.
|
||||
|
||||
---
|
||||
|
||||
## 2. Navigation & Layout
|
||||
|
||||
### Top Navbar (all views)
|
||||
- Background: `#1A1A2E` (charcoal)
|
||||
- Left: "**MORZ** Infoboard" wordmark — "MORZ" in MORZ red, "Infoboard" in white
|
||||
- Right: role-dependent links (admin: "Diagnose" link) + logout button (outlined white, small)
|
||||
- Mobile: hamburger collapse
|
||||
|
||||
### Admin layout
|
||||
- Max-width container: `960px`, generous padding
|
||||
- Tab navigation: underline style (red 3px underline on active tab, not Bulma boxed tabs)
|
||||
|
||||
### Tenant/Screen-user layout
|
||||
- Same navbar without Diagnose link
|
||||
- Single-focus pages — no tabs where avoidable
|
||||
|
||||
### Responsive
|
||||
- Cards go single-column below 768px
|
||||
- Manage page: two-column layout stacks vertically on mobile
|
||||
|
||||
---
|
||||
|
||||
## 3. Login Page
|
||||
|
||||
- Full-height centered layout, off-white background
|
||||
- Card: white, `box-shadow`, `border-radius: 8px`, **4px top border in MORZ red**
|
||||
- Above form: "**MORZ** Infoboard" heading — "MORZ" in red
|
||||
- Fields: clean, red focus ring (`outline: 2px solid var(--morz-red)`)
|
||||
- Submit button: solid MORZ red, full-width, `border-radius: 6px`
|
||||
- Error: soft red background banner inside the card (no separate notification)
|
||||
- Password toggle: eye icon preserved
|
||||
|
||||
---
|
||||
|
||||
## 4. Admin Dashboard
|
||||
|
||||
### Screens Tab
|
||||
- Replaces plain striped table with a responsive card grid (3-col desktop, 2-col tablet, 1-col mobile)
|
||||
- Each screen card contains:
|
||||
- Name (bold, `1rem`)
|
||||
- Slug (monospace, muted gray)
|
||||
- Orientation badge (pill: "Querformat" / "Hochformat")
|
||||
- Online status dot (green/yellow/red)
|
||||
- Action buttons: "Playlist" (primary red, small) + "Löschen" (danger outline, small)
|
||||
- Header row above grid: "Neuer Bildschirm" button (red) + "Provisionieren" button (outlined)
|
||||
|
||||
### Users Tab
|
||||
- Stays table-based (dense data, admin-appropriate)
|
||||
- Better spacing, clear "Neuer Benutzer" button top-right
|
||||
- Per-screen user modal: visually cleaned up, same functionality
|
||||
|
||||
### Modals
|
||||
- Existing delete-confirmation and screen-users modals retained, visual polish only
|
||||
- Cards use `--radius-card`, header uses charcoal background
|
||||
|
||||
### Toast Notifications
|
||||
- Existing JS toast system retained
|
||||
- Style update: rounded pill (`border-radius: 24px`), subtle shadow, slides in from top-right with CSS transition
|
||||
|
||||
---
|
||||
|
||||
## 5. Manage / Playlist Editor
|
||||
|
||||
The primary daily-use screen. Two-column desktop layout, stacked on mobile.
|
||||
|
||||
### Left Panel — Playlist (~60% width)
|
||||
- Header: screen name + orientation icon, back-link (← Dashboard or ← Admin), screen-switcher dropdown if user has multiple screens
|
||||
- Each playlist item is a draggable card:
|
||||
- Left: drag handle (⠿ icon, cursor grab)
|
||||
- Type icon (from existing `typeIcon` template func)
|
||||
- **Title**: click to edit inline (contenteditable or small input, saves on blur via fetch POST)
|
||||
- **Duration**: click to edit inline (number input, saves on blur)
|
||||
- **Enabled toggle**: pill switch (green=on, gray=off), instant toggle via fetch POST
|
||||
- **Validity dates**: collapsed by default, expand with a chevron; date inputs shown when expanded
|
||||
- Delete button: appears on card hover (right side, red icon)
|
||||
- Disabled items: 50% opacity
|
||||
- Drag-reorder: SortableJS (already bundled at `/static/Sortable.min.js`), sends reorder fetch on drop
|
||||
- Inline save confirmation: subtle green checkmark icon fades in/out next to the field
|
||||
|
||||
### Right Panel — Media Library (~40% width)
|
||||
- Upload section: collapsed by default, expand with "+ Medium hinzufügen" button; contains drag-and-drop zone (dashed border) + file picker fallback + URL input for web type
|
||||
- Asset grid: cards with thumbnail (image/video) or type icon (PDF/web), title, type badge
|
||||
- Per-card CTA: "Hinzufügen" (red button) → becomes "✓ Hinzugefügt" (gray, disabled) if already in playlist
|
||||
- Delete asset: small trash icon on card hover
|
||||
|
||||
### Interactions (no page reloads)
|
||||
- Toggle enabled → `fetch POST /manage/{screenSlug}/items/{itemId}` (existing update endpoint)
|
||||
- Inline title/duration edit → `fetch POST /manage/{screenSlug}/items/{itemId}` on blur
|
||||
- Reorder → `fetch POST /manage/{screenSlug}/reorder`
|
||||
- Add to playlist → standard form POST (redirect back, which reloads — acceptable)
|
||||
- All other destructive actions retain redirect-based flow for safety
|
||||
|
||||
---
|
||||
|
||||
## 6. Tenant Dashboard
|
||||
|
||||
- Replaces two-tab layout with a single-page card-first design
|
||||
- **Screens section**: large, touch-friendly cards — screen icon, name, status dot, single CTA "Playlist bearbeiten →" (red)
|
||||
- **Media section**: below screens, or accessible via a simple toggle — drag-and-drop upload zone (dashed border), asset grid below
|
||||
- No dense tables anywhere
|
||||
- Status dots live-updated via `setInterval` fetch to player status API
|
||||
|
||||
---
|
||||
|
||||
## 7. Screen Overview (screen_users with multiple screens)
|
||||
|
||||
- Full-page grid of large tappable screen cards
|
||||
- Each card: orientation icon (large), screen name, status dot
|
||||
- One tap → direct link to `/manage/{slug}`
|
||||
- No other UI elements — maximum simplicity
|
||||
|
||||
---
|
||||
|
||||
## 8. Provision Wizard
|
||||
|
||||
Visual polish only, no functional changes:
|
||||
- Step numbers: circles in MORZ red
|
||||
- Code blocks: dark terminal style (already present, refine padding/font)
|
||||
- Copy buttons: icon-only with tooltip, confirmation checkmark on click
|
||||
- Completion card: prominent green checkmark, "Playlist befüllen →" CTA in red
|
||||
|
||||
---
|
||||
|
||||
## 9. Status Indicators
|
||||
|
||||
Live status dots used across all views (Tenant dashboard, Screen overview, Admin screen cards):
|
||||
- Green: online (last heartbeat < 2 min)
|
||||
- Yellow: stale (< 10 min)
|
||||
- Red: offline (> 10 min or never seen)
|
||||
- Implementation: small `setInterval` (30s) fetches `/api/v1/screens/status` (bulk), matches results by `screen_id` field and updates dot color per screen
|
||||
|
||||
---
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
- All templates remain in Go `const` string variables in their respective `templates.go` files
|
||||
- No new build step, no npm, no bundler
|
||||
- Bulma CDN link replaced with local `/static/bulma.min.css` (already present)
|
||||
- Bulma variable overrides via CSS custom properties in each template's `<style>` block
|
||||
- SortableJS already available at `/static/Sortable.min.js`
|
||||
- Inline JS kept minimal and vanilla — no new JS libraries introduced
|
||||
- CSRF tokens flow unchanged through all form POST operations
|
||||
- All existing URL routes and redirect flows preserved exactly
|
||||
|
||||
---
|
||||
|
||||
## Files to Modify
|
||||
|
||||
| File | Changes |
|
||||
|---|---|
|
||||
| `internal/httpapi/manage/templates.go` | `loginTmpl`, `adminTmpl`, `manageTmpl`, `screenOverviewTmpl`, `provisionTmpl` |
|
||||
| `internal/httpapi/tenant/templates.go` | `tenantDashTmpl` |
|
||||
| `internal/httpapi/manage/ui.go` | Inline JS fetch handlers for playlist inline editing (no new Go handlers needed — existing endpoints are sufficient) |
|
||||
|
||||
No database changes. No new API endpoints needed for the visual overhaul (existing update/reorder endpoints cover the inline editing interactions).
|
||||
|
||||
**Important implementation note:** `HandleUpdateItemUI` currently calls `http.Redirect` on success. When called via `fetch` (inline editing), the redirect will be silently followed by the browser and the caller receives the full redirected page — not a clean response. `HandleUpdateItemUI` must be updated to return `204 No Content` when the request is a `fetch` call (detect via `X-Requested-With: fetch` header sent by the JS, or simply always return `204` since the manage page JS never needs the redirect). The existing redirect-based HTML form flow for other callers (add item, delete item) is unaffected.
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -783,6 +783,10 @@ func HandleUpdateItemUI(playlists *store.PlaylistStore, screens *store.ScreenSto
|
|||
return
|
||||
}
|
||||
notifier.NotifyChanged(screenSlug)
|
||||
if r.Header.Get("X-Requested-With") == "fetch" {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/manage/"+screenSlug+"?msg=saved", http.StatusSeeOther)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,33 +1,60 @@
|
|||
package tenant
|
||||
|
||||
const tenantDashTmpl = `<!DOCTYPE html>
|
||||
<html lang="de" data-theme="light">
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="color-scheme" content="light">
|
||||
<title>Mein Dashboard – morz infoboard</title>
|
||||
<title>Dashboard – {{.Tenant.Name}}</title>
|
||||
<link rel="stylesheet" href="/static/bulma.min.css">
|
||||
<style>
|
||||
body { background: #f5f5f5; min-height: 100vh; }
|
||||
.navbar { margin-bottom: 0; }
|
||||
:root { --morz-red:#E30613; --morz-red-dark:#B8000F; --nav-bg:#1A1A2E; --bg:#F7F8FA; --surface:#FFFFFF; --shadow-sm:0 1px 4px rgba(0,0,0,.08); --shadow-md:0 4px 16px rgba(0,0,0,.12); --radius:8px; --radius-btn:6px; }
|
||||
body { background:var(--bg); font-family:system-ui,-apple-system,"Segoe UI",sans-serif; min-height:100vh; }
|
||||
.navbar { background:var(--nav-bg) !important; }
|
||||
.navbar-item { color:rgba(255,255,255,.85) !important; }
|
||||
.morz-brand .accent { color:var(--morz-red); font-weight:800; }
|
||||
.box { border-radius:var(--radius); box-shadow:var(--shadow-sm); }
|
||||
.button.is-primary { background:var(--morz-red) !important; border-color:var(--morz-red) !important; border-radius:var(--radius-btn); }
|
||||
.button.is-primary:hover { background:var(--morz-red-dark) !important; }
|
||||
.button { border-radius:var(--radius-btn) !important; }
|
||||
.input:focus,.select select:focus { border-color:var(--morz-red); box-shadow:0 0 0 3px rgba(227,6,19,.12); outline:none; }
|
||||
/* Tabs */
|
||||
.tabs li.is-active a { border-bottom:3px solid var(--morz-red) !important; color:var(--morz-red) !important; font-weight:600; }
|
||||
.tabs a { border-bottom:3px solid transparent; color:#374151; }
|
||||
.tab-content { display:none; }
|
||||
.tab-content.is-active { display:block; }
|
||||
.progress-bar-wrap { display: none; margin-top: .5rem; }
|
||||
/* Screen cards */
|
||||
.sc-card { background:var(--surface); border-radius:var(--radius); box-shadow:var(--shadow-sm); padding:1.25rem; display:flex; flex-direction:column; gap:.75rem; transition:box-shadow .15s; }
|
||||
.sc-card:hover { box-shadow:var(--shadow-md); }
|
||||
.sc-name { font-weight:700; font-size:1.05rem; display:flex; align-items:center; gap:.5rem; }
|
||||
.sc-sub { font-size:.8rem; color:#6b7280; display:flex; align-items:center; gap:.5rem; }
|
||||
.status-dot { width:10px; height:10px; border-radius:50%; display:inline-block; flex-shrink:0; }
|
||||
.status-dot.online { background:#22c55e; }
|
||||
.status-dot.stale { background:#f59e0b; }
|
||||
.status-dot.offline{ background:#ef4444; }
|
||||
.status-dot.unknown{ background:#9ca3af; }
|
||||
/* Upload zone */
|
||||
.upload-zone { border:2px dashed #d1d5db; border-radius:var(--radius); padding:1.5rem; text-align:center; cursor:pointer; transition:border-color .15s; }
|
||||
.upload-zone:hover,.upload-zone.dragover { border-color:var(--morz-red); background:#fff5f5; }
|
||||
.upload-zone p { color:#9ca3af; font-size:.875rem; margin:.25rem 0; }
|
||||
/* Toast */
|
||||
.morz-toast { position:fixed; top:1rem; right:1rem; z-index:9999; max-width:380px; border-radius:24px; box-shadow:var(--shadow-md); padding:.75rem 1.25rem; display:flex; align-items:center; gap:.75rem; font-size:.9rem; transform:translateX(120%); transition:transform .25s ease; }
|
||||
.morz-toast.show { transform:translateX(0); }
|
||||
.morz-toast.is-success { background:#f0fdf4; color:#166534; border:1px solid #bbf7d0; }
|
||||
.morz-toast.is-danger { background:#fef2f2; color:#991b1b; border:1px solid #fecaca; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<nav class="navbar is-dark" role="navigation" aria-label="main navigation">
|
||||
<nav class="navbar" role="navigation">
|
||||
<div class="navbar-brand">
|
||||
<span class="navbar-item"><strong>📺 Infoboard</strong></span>
|
||||
<span class="navbar-item morz-brand"><span class="accent">MORZ</span> Infoboard</span>
|
||||
</div>
|
||||
<div class="navbar-menu">
|
||||
<div class="navbar-end">
|
||||
<div class="navbar-item">
|
||||
<form method="POST" action="/logout">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<button class="button is-white is-outlined is-small" type="submit">Abmelden</button>
|
||||
<button class="button is-outlined is-small" style="color:rgba(255,255,255,.85);border-color:rgba(255,255,255,.35)" type="submit">Abmelden</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -41,48 +68,34 @@ const tenantDashTmpl = `<!DOCTYPE html>
|
|||
|
||||
{{if .Flash}}
|
||||
<div class="notification is-success is-light mb-4" role="alert">
|
||||
<button class="delete" onclick="this.parentElement.remove()"></button>
|
||||
{{.Flash}}
|
||||
<button class="delete" onclick="this.parentElement.remove()"></button>{{.Flash}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="tabs is-boxed mb-0" id="dash-tabs">
|
||||
<div class="tabs mb-0" id="dash-tabs">
|
||||
<ul>
|
||||
<li class="is-active" data-tab="monitors">
|
||||
<a><span>Meine Monitore</span></a>
|
||||
</li>
|
||||
<li data-tab="media">
|
||||
<a><span>Mediathek</span></a>
|
||||
</li>
|
||||
<li class="is-active" data-tab="monitors"><a>Meine Monitore</a></li>
|
||||
<li data-tab="media"><a>Mediathek</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Tab A: Meine Monitore -->
|
||||
<!-- Tab: Monitors -->
|
||||
<div id="tab-monitors" class="tab-content is-active">
|
||||
<div class="box" style="border-top-left-radius:0">
|
||||
<div class="box" style="border-radius:0 var(--radius) var(--radius) var(--radius)">
|
||||
{{if .Screens}}
|
||||
<div class="columns is-multiline">
|
||||
{{range .Screens}}
|
||||
<div class="column is-4-desktop is-6-tablet">
|
||||
<div class="card">
|
||||
<div class="card-content">
|
||||
<p class="title is-5">
|
||||
<span aria-label="{{if eq .Orientation "portrait"}}Hochformat{{else}}Querformat{{end}}">{{if eq .Orientation "portrait"}}📱{{else}}🖥{{end}}</span>{{/* Fallback: leerer Wert wird als Querformat behandelt */}}
|
||||
<div class="sc-card">
|
||||
<div class="sc-name">
|
||||
{{if eq .Orientation "portrait"}}📱{{else}}🖥{{end}}
|
||||
{{.Name}}
|
||||
</p>
|
||||
<p class="subtitle is-6 has-text-grey">
|
||||
{{if eq .Orientation "portrait"}}Hochformat{{else}}Querformat{{end}}
|
||||
</p>
|
||||
<div id="status-{{.Slug}}" class="mb-3">
|
||||
<span class="tag is-warning">Unbekannt</span>
|
||||
</div>
|
||||
<div class="sc-sub">
|
||||
<span class="status-dot unknown" id="status-{{.Slug}}" title="Unbekannt"></span>
|
||||
<span>{{orientationLabel .Orientation}}</span>
|
||||
</div>
|
||||
<footer class="card-footer">
|
||||
<a class="card-footer-item"
|
||||
href="/manage/{{.Slug}}?from=tenant">
|
||||
Playlist bearbeiten
|
||||
</a>
|
||||
</footer>
|
||||
<a class="button is-primary is-fullwidth" href="/manage/{{.Slug}}?from=tenant">Playlist bearbeiten →</a>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
|
@ -93,94 +106,64 @@ const tenantDashTmpl = `<!DOCTYPE html>
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab B: Mediathek -->
|
||||
<!-- Tab: Media -->
|
||||
<div id="tab-media" class="tab-content">
|
||||
<div class="box" style="border-top-left-radius:0">
|
||||
|
||||
<h2 class="title is-5">Medium hochladen</h2>
|
||||
|
||||
<form id="upload-form" method="POST"
|
||||
action="/tenant/{{.Tenant.Slug}}/upload"
|
||||
enctype="multipart/form-data">
|
||||
<div class="box" style="border-radius:0 var(--radius) var(--radius) var(--radius)">
|
||||
|
||||
<h2 class="title is-5 mb-3">Medium hochladen</h2>
|
||||
<form id="upload-form" method="POST" action="/tenant/{{.Tenant.Slug}}/upload" enctype="multipart/form-data">
|
||||
<div class="field">
|
||||
<label class="label">Typ</label>
|
||||
<div class="control">
|
||||
<div class="select">
|
||||
<select name="type" id="upload-type" onchange="toggleUploadFields()">
|
||||
<option value="image">Bild</option>
|
||||
<option value="video">Video</option>
|
||||
<option value="pdf">PDF</option>
|
||||
<option value="web">Website (URL)</option>
|
||||
<label class="label is-small">Typ</label>
|
||||
<div class="select is-small">
|
||||
<select name="type" id="upload-type" onchange="toggleTypeFields()">
|
||||
<option value="image">🖼 Bild</option>
|
||||
<option value="video">🎬 Video</option>
|
||||
<option value="pdf">📄 PDF</option>
|
||||
<option value="web">🌐 Website (URL)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label">Titel (optional)</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" name="title" placeholder="Wird aus Dateinamen abgeleitet">
|
||||
<label class="label is-small">Titel <span class="has-text-grey">(optional)</span></label>
|
||||
<input class="input is-small" type="text" name="title" placeholder="Aus Dateinamen abgeleitet">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="file-field" class="field">
|
||||
<label class="label">Datei</label>
|
||||
<div class="control">
|
||||
<input class="input" type="file" name="file" id="upload-file"
|
||||
accept="image/*,video/*,application/pdf">
|
||||
<label class="label is-small">Datei</label>
|
||||
<div class="upload-zone" onclick="document.getElementById('upload-file').click()" ondragover="event.preventDefault();this.classList.add('dragover')" ondragleave="this.classList.remove('dragover')" ondrop="handleDrop(event)">
|
||||
<p style="font-size:1.5rem">📂</p>
|
||||
<p>Klicken oder Datei hierher ziehen</p>
|
||||
<p id="drop-fn" style="display:none;color:#374151;font-weight:600"></p>
|
||||
</div>
|
||||
<div class="progress-bar-wrap" id="upload-progress-wrap">
|
||||
<progress class="progress is-info" id="upload-progress" value="0" max="100"></progress>
|
||||
<input type="file" id="upload-file" name="file" accept="image/*,video/*,application/pdf" style="display:none" onchange="var p=document.getElementById('drop-fn');if(this.files[0]){p.textContent=this.files[0].name;p.style.display=''}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="url-field" class="field" style="display:none">
|
||||
<label class="label">URL</label>
|
||||
<div class="control">
|
||||
<input class="input" type="url" name="url" placeholder="https://...">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<div class="control">
|
||||
<button class="button is-primary" type="submit" id="upload-btn">Hochladen</button>
|
||||
<label class="label is-small">URL</label>
|
||||
<input class="input is-small" type="url" name="url" placeholder="https://...">
|
||||
</div>
|
||||
<div id="upload-progress-wrap" class="field" style="display:none">
|
||||
<progress id="upload-progress" class="progress is-primary is-small" value="0" max="100">0%</progress>
|
||||
</div>
|
||||
<div id="upload-error" class="notification is-danger is-light is-small mt-2" style="display:none"></div>
|
||||
<div class="field"><button class="button is-primary is-small" type="button" id="upload-btn" onclick="startUpload()">Hochladen</button></div>
|
||||
</form>
|
||||
|
||||
<div id="upload-error" class="notification is-danger is-light mt-3" style="display:none">
|
||||
<button class="delete" onclick="this.parentElement.style.display='none'"></button>
|
||||
<span id="upload-error-text"></span>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2 class="title is-5">Vorhandene Medien</h2>
|
||||
|
||||
<h2 class="title is-5 mb-3">Vorhandene Medien</h2>
|
||||
{{if .Assets}}
|
||||
<div style="overflow-x:auto">
|
||||
<table class="table is-fullwidth is-hoverable is-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Typ</th>
|
||||
<th>Titel</th>
|
||||
<th>Größe</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<table class="table is-fullwidth is-hoverable">
|
||||
<thead><tr style="font-size:.75rem;text-transform:uppercase;letter-spacing:.04em;color:#6b7280">
|
||||
<th>Typ</th><th>Titel</th><th>Größe</th><th></th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{{range .Assets}}
|
||||
<tr>
|
||||
<td>{{typeIcon .Type}}</td>
|
||||
<td>{{.Title}}</td>
|
||||
<td class="has-text-grey">
|
||||
{{if .SizeBytes}}{{humanSize .SizeBytes}}{{else}}–{{end}}
|
||||
</td>
|
||||
<td class="has-text-grey" style="font-size:.8rem">{{if .SizeBytes}}{{humanSize .SizeBytes}}{{else}}–{{end}}</td>
|
||||
<td>
|
||||
<form method="POST"
|
||||
action="/tenant/{{$.Tenant.Slug}}/media/{{.ID}}/delete"
|
||||
onsubmit="return confirm('Medium löschen? Playlist-Einträge bleiben bestehen, zeigen dann aber nichts an.')">
|
||||
<form method="POST" action="/tenant/{{$.Tenant.Slug}}/media/{{.ID}}/delete"
|
||||
onsubmit="return confirm('Medium löschen?')">
|
||||
<button class="button is-small is-danger is-outlined" type="submit">Löschen</button>
|
||||
</form>
|
||||
</td>
|
||||
|
|
@ -192,152 +175,107 @@ const tenantDashTmpl = `<!DOCTYPE html>
|
|||
{{else}}
|
||||
<p class="has-text-grey">Noch keine Medien hochgeladen.</p>
|
||||
{{end}}
|
||||
|
||||
</div>
|
||||
</div><!-- /tab-media -->
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
// ── Tab-Switching ────────────────────────────────────────────────────────────
|
||||
// ─── Tab switching ─────────────────────────────────────────────
|
||||
(function() {
|
||||
var tabs = document.querySelectorAll('#dash-tabs li[data-tab]');
|
||||
tabs.forEach(function(tab) {
|
||||
tab.addEventListener('click', function() {
|
||||
tabs.forEach(function(t) { t.classList.remove('is-active'); });
|
||||
tab.classList.add('is-active');
|
||||
document.querySelectorAll('.tab-content').forEach(function(c) {
|
||||
c.classList.remove('is-active');
|
||||
});
|
||||
document.querySelectorAll('.tab-content').forEach(function(c) { c.classList.remove('is-active'); });
|
||||
document.getElementById('tab-' + tab.dataset.tab).classList.add('is-active');
|
||||
// Sync URL ohne Reload
|
||||
var url = new URL(window.location.href);
|
||||
url.searchParams.set('tab', tab.dataset.tab);
|
||||
history.replaceState(null, '', url.toString());
|
||||
history.replaceState(null, '', '?tab=' + tab.dataset.tab);
|
||||
});
|
||||
});
|
||||
|
||||
// Beim Laden ggf. Tab aus URL herstellen
|
||||
var tabParam = new URLSearchParams(window.location.search).get('tab');
|
||||
if (tabParam) {
|
||||
var target = document.querySelector('#dash-tabs li[data-tab="' + tabParam + '"]');
|
||||
if (target) target.click();
|
||||
}
|
||||
var tp = new URLSearchParams(window.location.search).get('tab');
|
||||
if (tp) { var t = document.querySelector('#dash-tabs li[data-tab="' + tp + '"]'); if (t) t.click(); }
|
||||
})();
|
||||
|
||||
// ── Upload-Formular Typ-Felder ────────────────────────────────────────────────
|
||||
function toggleUploadFields() {
|
||||
// ─── Upload type toggle ────────────────────────────────────────
|
||||
function toggleTypeFields() {
|
||||
var t = document.getElementById('upload-type').value;
|
||||
document.getElementById('file-field').style.display = (t === 'web') ? 'none' : '';
|
||||
document.getElementById('url-field').style.display = (t === 'web') ? '' : 'none';
|
||||
document.getElementById('file-field').style.display = t === 'web' ? 'none' : '';
|
||||
document.getElementById('url-field').style.display = t === 'web' ? '' : 'none';
|
||||
}
|
||||
|
||||
// ── Upload-Fehleranzeige ──────────────────────────────────────────────────────
|
||||
function showUploadError(msg) {
|
||||
var errDiv = document.getElementById('upload-error');
|
||||
var errText = document.getElementById('upload-error-text');
|
||||
if (!errDiv || !errText) return;
|
||||
errText.textContent = msg;
|
||||
errDiv.style.display = 'block';
|
||||
setTimeout(function() { errDiv.style.display = 'none'; }, 8000);
|
||||
function handleDrop(e) {
|
||||
e.preventDefault(); e.currentTarget.classList.remove('dragover');
|
||||
var inp = document.getElementById('upload-file');
|
||||
if (inp && e.dataTransfer.files.length) {
|
||||
inp.files = e.dataTransfer.files;
|
||||
var p = document.getElementById('drop-fn');
|
||||
p.textContent = e.dataTransfer.files[0].name; p.style.display = '';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Upload-Fortschrittsbalken ─────────────────────────────────────────────────
|
||||
(function() {
|
||||
function startUpload() {
|
||||
var t = document.getElementById('upload-type').value;
|
||||
if (t === 'web') { document.getElementById('upload-form').submit(); return; }
|
||||
var form = document.getElementById('upload-form');
|
||||
if (!form) return;
|
||||
form.addEventListener('submit', function(e) {
|
||||
var t = document.getElementById('upload-type').value;
|
||||
if (t === 'web') return; // kein XHR für URL-Typ
|
||||
var file = document.getElementById('upload-file').files[0];
|
||||
if (!file) return;
|
||||
e.preventDefault();
|
||||
|
||||
var wrap = document.getElementById('upload-progress-wrap');
|
||||
var bar = document.getElementById('upload-progress');
|
||||
var inp = document.getElementById('upload-file');
|
||||
var btn = document.getElementById('upload-btn');
|
||||
var errDiv = document.getElementById('upload-error');
|
||||
if (errDiv) errDiv.style.display = 'none';
|
||||
wrap.style.display = 'block';
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Lädt hoch…';
|
||||
|
||||
var pg = document.getElementById('upload-progress-wrap');
|
||||
var bar = document.getElementById('upload-progress');
|
||||
var err = document.getElementById('upload-error');
|
||||
err.style.display='none';
|
||||
if (!inp.files || !inp.files.length) { err.textContent='Bitte eine Datei auswählen.';err.style.display='';return; }
|
||||
var fd = new FormData(form);
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', form.action);
|
||||
xhr.upload.addEventListener('progress', function(ev) {
|
||||
if (ev.lengthComputable) {
|
||||
bar.value = Math.round(ev.loaded / ev.total * 100);
|
||||
xhr.upload.onprogress = function(e) { if (e.lengthComputable) bar.value=Math.round(e.loaded/e.total*100); };
|
||||
xhr.onloadstart = function() { btn.style.display='none'; pg.style.display=''; bar.value=0; };
|
||||
xhr.onload = function() {
|
||||
if (xhr.status>=200&&xhr.status<400) { window.location.href=window.location.pathname+'?tab=media&flash=uploaded'; }
|
||||
else { pg.style.display='none';btn.style.display='';err.textContent='Upload fehlgeschlagen: '+xhr.responseText;err.style.display=''; }
|
||||
};
|
||||
xhr.onerror = function() { pg.style.display='none';btn.style.display='';err.textContent='Netzwerkfehler.';err.style.display=''; };
|
||||
xhr.open('POST', form.action); xhr.send(fd);
|
||||
}
|
||||
});
|
||||
xhr.addEventListener('load', function() {
|
||||
if (xhr.status >= 200 && xhr.status < 400) {
|
||||
window.location.href = window.location.pathname + '?tab=media&flash=uploaded';
|
||||
} else {
|
||||
showUploadError('Upload fehlgeschlagen: ' + xhr.responseText);
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Hochladen';
|
||||
wrap.style.display = 'none';
|
||||
}
|
||||
});
|
||||
xhr.addEventListener('error', function() {
|
||||
showUploadError('Netzwerkfehler beim Upload.');
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Hochladen';
|
||||
wrap.style.display = 'none';
|
||||
});
|
||||
xhr.send(fd);
|
||||
});
|
||||
})();
|
||||
|
||||
// ── Status-Polling alle 30 s ──────────────────────────────────────────────────
|
||||
// ─── Status polling ────────────────────────────────────────────
|
||||
(function() {
|
||||
function pollStatus() {
|
||||
fetch('/api/v1/screens/status')
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(list) {
|
||||
if (!Array.isArray(list)) return;
|
||||
list.forEach(function(s) {
|
||||
function update() {
|
||||
fetch('/api/v1/screens/status').then(function(r){return r.ok?r.json():null;}).then(function(data){
|
||||
if (!data||!data.screens) return;
|
||||
data.screens.forEach(function(s){
|
||||
var el = document.getElementById('status-' + s.screen_id);
|
||||
if (!el) return;
|
||||
var ok = s.status === 'ok' || s.status === 'online';
|
||||
var cls = ok ? 'is-success' : 'is-danger';
|
||||
var text = ok ? 'Online' : 'Offline';
|
||||
el.innerHTML = '<span class="tag ' + cls + '">' + text + '</span>';
|
||||
var st = s.derived_state || 'unknown';
|
||||
el.className = 'status-dot ' + (st==='online'?'online':st==='degraded'?'stale':'offline');
|
||||
el.title = st;
|
||||
});
|
||||
})
|
||||
.catch(function() { /* ignore */ });
|
||||
}).catch(function(){});
|
||||
}
|
||||
pollStatus();
|
||||
setInterval(pollStatus, 30000);
|
||||
update(); setInterval(update, 30000);
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
// K1: CSRF Double-Submit — füge Token aus Cookie in alle POST-Formulare ein.
|
||||
(function() {
|
||||
function getCookie(name) {
|
||||
var m = document.cookie.match('(?:^|; )' + name + '=([^;]*)');
|
||||
return m ? decodeURIComponent(m[1]) : '';
|
||||
}
|
||||
function injectCSRF() {
|
||||
var token = getCookie('morz_csrf');
|
||||
if (!token) return;
|
||||
document.querySelectorAll('form[method="POST"],form[method="post"]').forEach(function(f) {
|
||||
if (!f.querySelector('input[name="csrf_token"]')) {
|
||||
var inp = document.createElement('input');
|
||||
inp.type = 'hidden'; inp.name = 'csrf_token'; inp.value = token;
|
||||
f.appendChild(inp);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', injectCSRF);
|
||||
} else {
|
||||
injectCSRF();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
// ─── CSRF injection ────────────────────────────────────────────
|
||||
function getCsrf() { var m=document.cookie.match('(?:^|; )morz_csrf=([^;]*)');return m?decodeURIComponent(m[1]):''; }
|
||||
function injectCSRF() {
|
||||
var t=getCsrf();if(!t)return;
|
||||
document.querySelectorAll('form[method="POST"],form[method="post"]').forEach(function(f){
|
||||
if(!f.querySelector('input[name="csrf_token"]')){var i=document.createElement('input');i.type='hidden';i.name='csrf_token';i.value=t;f.appendChild(i);}
|
||||
});
|
||||
}
|
||||
if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',injectCSRF);else injectCSRF();
|
||||
|
||||
// ─── Toast ─────────────────────────────────────────────────────
|
||||
function showToast(msg,type){
|
||||
var t=document.createElement('div');t.className='morz-toast '+(type||'is-success');
|
||||
t.innerHTML=msg+'<button onclick="this.parentElement.remove()" style="background:none;border:none;cursor:pointer;font-size:1rem;margin-left:auto;opacity:.6">✕</button>';
|
||||
document.body.appendChild(t);requestAnimationFrame(function(){t.classList.add('show');});
|
||||
setTimeout(function(){t.classList.remove('show');setTimeout(function(){t.remove();},300);},3500);
|
||||
}
|
||||
(function(){
|
||||
var flash = new URLSearchParams(window.location.search).get('flash');
|
||||
if (flash==='uploaded') showToast('✓ Medium erfolgreich hochgeladen.');
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue