Web UI: setup wizard, auth pages, library browsing, player, admin, streaming, cover art

This commit is contained in:
2026-08-22 23:42:09 -05:00
parent aa29f13846
commit e0bd8bc3cb
42 changed files with 3012 additions and 8 deletions
+39
View File
@@ -0,0 +1,39 @@
{{define "album_card"}}
<a href="/albums/{{.ID}}" class="vs-card">
<img class="vs-card-art" src="{{if .CoverArtID}}/cover-art/{{.CoverArtID}}?size=300{{else}}/static/img/default-art.svg{{end}}" alt="" loading="lazy">
<div class="vs-card-title">{{.Title}}</div>
<div class="vs-card-subtitle">{{.ArtistName}}</div>
</a>
{{end}}
{{define "artist_card"}}
<a href="/artists/{{.ID}}" class="vs-card">
<img class="vs-card-art" style="border-radius:50%;" src="/static/img/default-art.svg" alt="" loading="lazy">
<div class="vs-card-title">{{.Name}}</div>
</a>
{{end}}
{{define "song_rows"}}
<div class="table-responsive" data-playable-list>
<table class="vs-table vs-table-hover table mb-0">
<tbody>
{{range $i, $s := .}}
<tr class="vs-playable"
data-song-id="{{$s.ID}}"
data-title="{{$s.Title}}"
data-artist="{{$s.ArtistName}}"
data-album="{{$s.AlbumTitle}}"
data-cover="{{$s.CoverArtID}}"
data-starred="{{if $s.Starred}}1{{else}}0{{end}}">
<td class="vs-track-num">{{if $s.TrackNumber}}{{$s.TrackNumber}}{{else}}{{add $i 1}}{{end}}</td>
<td>
<div class="fw-semibold">{{$s.Title}}</div>
{{if $s.AlbumTitle}}<div class="text-secondary small">{{$s.ArtistName}} — {{$s.AlbumTitle}}</div>{{else}}<div class="text-secondary small">{{$s.ArtistName}}</div>{{end}}
</td>
<td class="text-end text-secondary small">{{fmtDuration $s.DurationSeconds}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{end}}
+91
View File
@@ -0,0 +1,91 @@
{{define "footer"}}
</div>
</main>
</div>
{{if .User}}{{template "player"}}{{end}}
{{template "shortcuts_modal"}}
{{template "confirm_delete_modal"}}
<script src="/static/js/bootstrap.bundle.min.js"></script>
<script src="/static/js/app.js"></script>
</body>
</html>
{{end}}
{{define "player"}}
<audio id="vs-audio" preload="metadata"></audio>
<div class="vs-player">
<img id="vs-player-art" class="vs-player-art" src="/static/img/default-art.svg" alt="">
<div class="vs-player-meta">
<div id="vs-player-title" class="vs-player-title">Nothing playing</div>
<div id="vs-player-artist" class="vs-player-artist"></div>
</div>
<div class="vs-player-controls">
<div class="vs-player-buttons">
<button id="vs-prev-btn" type="button" class="vs-player-btn" title="Previous (J)"><i class="bi bi-skip-start-fill"></i></button>
<button id="vs-play-btn" type="button" class="vs-player-btn vs-play" title="Play/Pause (Space)"><i class="bi bi-play-fill"></i></button>
<button id="vs-next-btn" type="button" class="vs-player-btn" title="Next (L)"><i class="bi bi-skip-end-fill"></i></button>
</div>
<div class="vs-player-timeline">
<span id="vs-current-time" class="vs-player-time">0:00</span>
<input id="vs-progress" type="range" class="form-range" min="0" max="100" value="0">
<span id="vs-duration" class="vs-player-time">0:00</span>
</div>
<button id="vs-now-star" type="button" class="vs-star-btn" data-star-toggle data-item-type="song" data-item-id="" title="Favorite">
<i class="bi bi-star"></i>
</button>
<div class="vs-player-volume">
<button id="vs-mute-btn" type="button" class="vs-player-btn" title="Mute (M)"><i class="bi bi-volume-up"></i></button>
<input id="vs-volume" type="range" class="form-range" min="0" max="1" step="0.05" value="1">
</div>
</div>
</div>
{{end}}
{{define "shortcuts_modal"}}
<div class="modal fade" id="vs-shortcuts-modal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-keyboard"></i> Keyboard shortcuts</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<table class="table table-sm mb-0">
<tbody>
<tr><td><kbd>Space</kbd> / <kbd>K</kbd></td><td>Play / Pause</td></tr>
<tr><td><kbd>J</kbd></td><td>Previous track</td></tr>
<tr><td><kbd>L</kbd></td><td>Next track</td></tr>
<tr><td><kbd>←</kbd> / <kbd>→</kbd></td><td>Seek -5s / +5s</td></tr>
<tr><td><kbd>↑</kbd> / <kbd>↓</kbd></td><td>Volume up / down</td></tr>
<tr><td><kbd>M</kbd></td><td>Mute</td></tr>
<tr><td><kbd>/</kbd></td><td>Focus search</td></tr>
<tr><td><kbd>?</kbd></td><td>This help</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
{{end}}
{{define "confirm_delete_modal"}}
<div class="modal fade" id="vs-confirm-delete-modal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-exclamation-triangle text-danger"></i> Confirm</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">Are you sure?</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" data-confirm-action data-bs-dismiss="modal">Delete</button>
</div>
</div>
</div>
</div>
{{end}}
{{define "csrf_field"}}<input type="hidden" name="csrf_token" value="{{.}}">{{end}}
+98
View File
@@ -0,0 +1,98 @@
{{define "header"}}<!doctype html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}} · VibeSonic</title>
<link rel="stylesheet" href="/static/css/bootstrap.min.css">
<link rel="stylesheet" href="/static/css/bootstrap-icons.min.css">
<link rel="stylesheet" href="/static/css/app.css">
<script>
(function () {
try {
var saved = localStorage.getItem("vs-theme");
if (saved === "light" || saved === "dark") {
document.documentElement.setAttribute("data-bs-theme", saved);
}
} catch (e) {}
})();
</script>
</head>
<body>
<script>window.vsCSRFToken = {{.CSRFToken}};</script>
<header class="vs-topbar d-flex align-items-center justify-content-between">
<a href="/" class="vs-brand d-flex align-items-center gap-2">
<i class="bi bi-vinyl-fill"></i>
<span>VibeSonic</span>
</a>
<form action="/search" method="get" class="mx-auto" style="max-width: 420px; width: 100%;">
<input id="vs-search-input" type="search" name="q" class="form-control form-control-sm" placeholder="Search (press /)" autocomplete="off">
</form>
<div class="d-flex align-items-center gap-2">
<button type="button" class="btn vs-icon-btn" title="Toggle theme" data-theme-toggle>
<i class="bi bi-moon-stars"></i>
</button>
{{if .User}}
<div class="dropdown">
<button class="btn vs-icon-btn dropdown-toggle" type="button" data-bs-toggle="dropdown">
<i class="bi bi-person-circle"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><h6 class="dropdown-header">{{.User.Username}}</h6></li>
<li><a class="dropdown-item" href="/settings"><i class="bi bi-gear me-2"></i>Settings</a></li>
{{if .User.IsAdmin}}<li><a class="dropdown-item" href="/admin"><i class="bi bi-shield-lock me-2"></i>Admin</a></li>{{end}}
<li><hr class="dropdown-divider"></li>
<li>
<form action="/logout" method="post" class="px-3">
{{template "csrf_field" .CSRFToken}}
<button type="submit" class="btn btn-sm btn-outline-secondary w-100">Log out</button>
</form>
</li>
</ul>
</div>
{{end}}
</div>
</header>
<div class="vs-shell">
<nav class="vs-sidebar d-flex flex-column">
<ul class="nav flex-column vs-nav">
<li class="nav-item"><a class="nav-link {{if eq .Active "home"}}active{{end}}" href="/"><i class="bi bi-house-door"></i> Home</a></li>
<li class="nav-item"><a class="nav-link {{if eq .Active "search"}}active{{end}}" href="/search"><i class="bi bi-search"></i> Search</a></li>
<li class="vs-nav-label">Library</li>
<li class="nav-item"><a class="nav-link {{if eq .Active "artists"}}active{{end}}" href="/artists"><i class="bi bi-person-badge"></i> Artists</a></li>
<li class="nav-item"><a class="nav-link {{if eq .Active "albums"}}active{{end}}" href="/albums"><i class="bi bi-disc"></i> Albums</a></li>
<li class="nav-item"><a class="nav-link {{if eq .Active "songs"}}active{{end}}" href="/songs"><i class="bi bi-music-note"></i> Songs</a></li>
<li class="nav-item"><a class="nav-link {{if eq .Active "genres"}}active{{end}}" href="/genres"><i class="bi bi-tags"></i> Genres</a></li>
<li class="vs-nav-label">You</li>
<li class="nav-item"><a class="nav-link {{if eq .Active "playlists"}}active{{end}}" href="/playlists"><i class="bi bi-list-ul"></i> Playlists</a></li>
<li class="nav-item"><a class="nav-link {{if eq .Active "favorites"}}active{{end}}" href="/favorites"><i class="bi bi-star"></i> Favorites</a></li>
<li class="nav-item"><a class="nav-link {{if eq .Active "recent"}}active{{end}}" href="/recent"><i class="bi bi-clock-history"></i> Recently Played</a></li>
{{if and .User .User.IsAdmin}}
<li class="vs-nav-label">Admin</li>
<li class="nav-item"><a class="nav-link {{if eq .Active "admin_users"}}active{{end}}" href="/admin/users"><i class="bi bi-people"></i> Users</a></li>
<li class="nav-item"><a class="nav-link {{if eq .Active "admin_folders"}}active{{end}}" href="/admin/folders"><i class="bi bi-folder2-open"></i> Music Folders</a></li>
<li class="nav-item"><a class="nav-link {{if eq .Active "admin_scanner"}}active{{end}}" href="/admin/scanner"><i class="bi bi-arrow-repeat"></i> Scanner</a></li>
<li class="nav-item"><a class="nav-link {{if eq .Active "admin_settings"}}active{{end}}" href="/admin/settings"><i class="bi bi-sliders"></i> System Settings</a></li>
{{end}}
</ul>
<div class="mt-auto px-3 py-2 text-secondary small">
<button type="button" class="btn btn-sm btn-link p-0 text-decoration-none text-secondary" data-bs-toggle="modal" data-bs-target="#vs-shortcuts-modal">
<i class="bi bi-keyboard"></i> Shortcuts
</button>
</div>
</nav>
<main class="vs-main">
<div class="vs-page-header d-flex align-items-center justify-content-between">
<h1 class="h4 mb-0">{{.Title}}</h1>
</div>
<div class="vs-content">
{{if .Flash}}
<div class="alert alert-{{if .FlashKind}}{{.FlashKind}}{{else}}success{{end}} alert-dismissible fade show" role="alert">
{{.Flash}}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{{end}}
{{end}}