Web UI: setup wizard, auth pages, library browsing, player, admin, streaming, cover art
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
{{define "admin_folders"}}{{template "header" .}}
|
||||
<div class="card mb-4" style="max-width: 600px;">
|
||||
<div class="card-body">
|
||||
<h2 class="h6">Add music folder</h2>
|
||||
<form method="post" action="/admin/folders">
|
||||
{{template "csrf_field" .CSRFToken}}
|
||||
<div class="row g-2">
|
||||
<div class="col-4"><input type="text" name="name" class="form-control form-control-sm" placeholder="Name" required></div>
|
||||
<div class="col-5"><input type="text" name="path" class="form-control form-control-sm" placeholder="/music" required></div>
|
||||
<div class="col-3">
|
||||
<select name="kind" class="form-select form-select-sm">
|
||||
<option value="music">Music</option>
|
||||
<option value="audiobooks">Audiobooks</option>
|
||||
<option value="podcasts">Podcasts</option>
|
||||
<option value="video">Video</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-sm mt-2">Add folder</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class="table vs-table table-sm">
|
||||
<thead><tr><th>Name</th><th>Path</th><th>Kind</th><th>Status</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Data}}
|
||||
<tr>
|
||||
<td>{{.Name}}</td>
|
||||
<td class="text-secondary">{{.Path}}</td>
|
||||
<td>{{.Kind}}</td>
|
||||
<td>{{if .Enabled}}<span class="text-success">Enabled</span>{{else}}<span class="text-secondary">Disabled</span>{{end}}</td>
|
||||
<td class="text-end">
|
||||
<form method="post" action="/admin/folders/{{.ID}}/scan" class="d-inline">
|
||||
{{template "csrf_field" $.CSRFToken}}
|
||||
<button type="submit" class="btn btn-sm btn-outline-primary">Scan now</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/folders/{{.ID}}/toggle" class="d-inline">
|
||||
{{template "csrf_field" $.CSRFToken}}
|
||||
<button type="submit" class="btn btn-sm btn-outline-secondary">{{if .Enabled}}Disable{{else}}Enable{{end}}</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/folders/{{.ID}}/delete" class="d-inline" onsubmit="return confirm('Remove this folder? Its indexed songs will be deleted.');">
|
||||
{{template "csrf_field" $.CSRFToken}}
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger">Remove</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{template "footer" .}}{{end}}
|
||||
@@ -0,0 +1,28 @@
|
||||
{{define "admin_scanner"}}{{template "header" .}}
|
||||
<table class="table vs-table table-sm">
|
||||
<thead><tr><th>Folder</th><th>Status</th><th>Seen</th><th>Added</th><th>Updated</th><th>Removed</th><th>Last full scan</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Data}}
|
||||
<tr>
|
||||
<td>{{.Folder.Name}}</td>
|
||||
<td>
|
||||
{{if eq .State.Status "scanning"}}<span class="text-primary"><i class="bi bi-arrow-repeat"></i> Scanning…</span>
|
||||
{{else if eq .State.Status "error"}}<span class="text-danger" title="{{.State.ErrorMessage}}"><i class="bi bi-exclamation-triangle"></i> Error</span>
|
||||
{{else}}<span class="text-success">Idle</span>{{end}}
|
||||
</td>
|
||||
<td>{{.State.FilesSeen}}</td>
|
||||
<td>{{.State.FilesAdded}}</td>
|
||||
<td>{{.State.FilesUpdated}}</td>
|
||||
<td>{{.State.FilesRemoved}}</td>
|
||||
<td class="text-secondary small">{{if .State.LastFullScanAt.Valid}}{{.State.LastFullScanAt.String}}{{else}}never{{end}}</td>
|
||||
<td class="text-end">
|
||||
<form method="post" action="/admin/folders/{{.Folder.ID}}/scan">
|
||||
{{template "csrf_field" $.CSRFToken}}
|
||||
<button type="submit" class="btn btn-sm btn-outline-primary" {{if eq .State.Status "scanning"}}disabled{{end}}>Scan now</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{template "footer" .}}{{end}}
|
||||
@@ -0,0 +1,39 @@
|
||||
{{define "admin_settings"}}{{template "header" .}}
|
||||
<div class="card" style="max-width: 600px;">
|
||||
<div class="card-body">
|
||||
<form method="post" action="/admin/settings">
|
||||
{{template "csrf_field" .CSRFToken}}
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Scan interval</label>
|
||||
<input type="text" class="form-control" name="scan_interval" value="{{.Data.ScanInterval}}" placeholder="1h">
|
||||
<div class="form-text">Go duration syntax, e.g. 30m, 1h, 6h.</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">ffmpeg path</label>
|
||||
<input type="text" class="form-control" name="ffmpeg_path" value="{{.Data.FFmpegPath}}">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">ffprobe path</label>
|
||||
<input type="text" class="form-control" name="ffprobe_path" value="{{.Data.FFprobePath}}">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-sm">Save</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-3" style="max-width: 600px;">
|
||||
<div class="card-body">
|
||||
<h2 class="h6">External authentication</h2>
|
||||
<p class="text-secondary small mb-2">
|
||||
Configured via <code>VIBESONIC_EXTAUTH_*</code> environment variables (restart required to change).
|
||||
</p>
|
||||
<table class="table table-sm vs-table mb-0">
|
||||
<tbody>
|
||||
<tr><td>Enabled</td><td>{{if .Data.ExtAuthEnabled}}Yes{{else}}No{{end}}</td></tr>
|
||||
<tr><td>Trusted sources</td><td>{{if .Data.ExtAuthTrustedSources}}{{.Data.ExtAuthTrustedSources}}{{else}}<span class="text-secondary">none configured</span>{{end}}</td></tr>
|
||||
<tr><td>Header</td><td>{{.Data.ExtAuthHeader}}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{{template "footer" .}}{{end}}
|
||||
@@ -0,0 +1,43 @@
|
||||
{{define "admin_users"}}{{template "header" .}}
|
||||
<div class="card mb-4" style="max-width: 500px;">
|
||||
<div class="card-body">
|
||||
<h2 class="h6">Add user</h2>
|
||||
<form method="post" action="/admin/users">
|
||||
{{template "csrf_field" .CSRFToken}}
|
||||
<div class="row g-2">
|
||||
<div class="col-6"><input type="text" name="username" class="form-control form-control-sm" placeholder="Username" required></div>
|
||||
<div class="col-6"><input type="password" name="password" class="form-control form-control-sm" placeholder="Password" minlength="8" required></div>
|
||||
</div>
|
||||
<div class="form-check mt-2">
|
||||
<input class="form-check-input" type="checkbox" name="is_admin" id="is_admin" value="1">
|
||||
<label class="form-check-label small" for="is_admin">Administrator</label>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-sm mt-2">Add user</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class="table vs-table table-sm">
|
||||
<thead><tr><th>Username</th><th>Email</th><th>Role</th><th>Status</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Data}}
|
||||
<tr>
|
||||
<td>{{.Username}}{{if .ExternalAuth}} <span class="badge text-bg-secondary">external</span>{{end}}</td>
|
||||
<td class="text-secondary">{{.Email}}</td>
|
||||
<td>{{if .IsAdmin}}Admin{{else}}User{{end}}</td>
|
||||
<td>{{if .Disabled}}<span class="text-danger">Disabled</span>{{else}}<span class="text-success">Active</span>{{end}}</td>
|
||||
<td class="text-end">
|
||||
<form method="post" action="/admin/users/{{.ID}}/toggle-disabled" class="d-inline">
|
||||
{{template "csrf_field" $.CSRFToken}}
|
||||
<button type="submit" class="btn btn-sm btn-outline-secondary">{{if .Disabled}}Enable{{else}}Disable{{end}}</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/users/{{.ID}}/delete" class="d-inline" onsubmit="return confirm('Delete this user?');">
|
||||
{{template "csrf_field" $.CSRFToken}}
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{template "footer" .}}{{end}}
|
||||
@@ -0,0 +1,13 @@
|
||||
{{define "album"}}{{template "header" .}}
|
||||
<div class="d-flex gap-3 mb-4">
|
||||
<img class="vs-card-art" style="width:180px;" src="{{if .Data.Album.CoverArtID}}/cover-art/{{.Data.Album.CoverArtID}}?size=400{{else}}/static/img/default-art.svg{{end}}" alt="">
|
||||
<div>
|
||||
<h2 class="h4 mb-1">{{.Data.Album.Title}}</h2>
|
||||
<div class="text-secondary mb-2">{{.Data.Album.ArtistName}}{{if .Data.Album.Year}} · {{.Data.Album.Year}}{{end}}</div>
|
||||
<button type="button" class="btn vs-star-btn {{if .Data.Starred}}active{{end}}" data-star-toggle data-item-type="album" data-item-id="{{.Data.Album.ID}}">
|
||||
{{if .Data.Starred}}<i class="bi bi-star-fill"></i>{{else}}<i class="bi bi-star"></i>{{end}} Favorite
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{{template "song_rows" .Data.Songs}}
|
||||
{{template "footer" .}}{{end}}
|
||||
@@ -0,0 +1,11 @@
|
||||
{{define "albums"}}{{template "header" .}}
|
||||
<div class="vs-grid mb-3">
|
||||
{{range .Data.Albums}}{{template "album_card" .}}{{end}}
|
||||
</div>
|
||||
{{if not .Data.Albums}}<p class="text-secondary">No albums yet.</p>{{end}}
|
||||
{{if .Data.HasMore}}
|
||||
<div class="text-center mt-3">
|
||||
<a class="btn btn-outline-secondary btn-sm" href="?offset={{.Data.NextOffset}}">Load more</a>
|
||||
</div>
|
||||
{{end}}
|
||||
{{template "footer" .}}{{end}}
|
||||
@@ -0,0 +1,6 @@
|
||||
{{define "artist"}}{{template "header" .}}
|
||||
<div class="vs-grid mb-3">
|
||||
{{range .Data.Albums}}{{template "album_card" .}}{{end}}
|
||||
</div>
|
||||
{{if not .Data.Albums}}<p class="text-secondary">No albums for this artist yet.</p>{{end}}
|
||||
{{template "footer" .}}{{end}}
|
||||
@@ -0,0 +1,9 @@
|
||||
{{define "artists"}}{{template "header" .}}
|
||||
{{if not .Data}}
|
||||
<p class="text-secondary">No artists yet.</p>
|
||||
{{else}}
|
||||
<div class="vs-grid">
|
||||
{{range .Data}}{{template "artist_card" .}}{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{template "footer" .}}{{end}}
|
||||
@@ -0,0 +1,10 @@
|
||||
{{define "favorites"}}{{template "header" .}}
|
||||
<h2 class="h6 text-secondary">Artists</h2>
|
||||
{{if .Data.Artists}}<div class="vs-grid mb-4">{{range .Data.Artists}}{{template "artist_card" .}}{{end}}</div>{{else}}<p class="text-secondary small">No favorite artists yet.</p>{{end}}
|
||||
|
||||
<h2 class="h6 text-secondary">Albums</h2>
|
||||
{{if .Data.Albums}}<div class="vs-grid mb-4">{{range .Data.Albums}}{{template "album_card" .}}{{end}}</div>{{else}}<p class="text-secondary small">No favorite albums yet.</p>{{end}}
|
||||
|
||||
<h2 class="h6 text-secondary">Songs</h2>
|
||||
{{if .Data.Songs}}{{template "song_rows" .Data.Songs}}{{else}}<p class="text-secondary small">No favorite songs yet.</p>{{end}}
|
||||
{{template "footer" .}}{{end}}
|
||||
@@ -0,0 +1,9 @@
|
||||
{{define "genres"}}{{template "header" .}}
|
||||
{{if .Data}}
|
||||
<div class="list-group">
|
||||
{{range .Data}}<a class="list-group-item list-group-item-action" href="/songs?genre={{.ID}}">{{.Name}}</a>{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="text-secondary">No genres yet.</p>
|
||||
{{end}}
|
||||
{{template "footer" .}}{{end}}
|
||||
@@ -0,0 +1,17 @@
|
||||
{{define "home"}}{{template "header" .}}
|
||||
{{if not .Data.HasLibrary}}
|
||||
<div class="alert alert-info">
|
||||
Your library is empty so far. <a href="/admin/folders">Add a music folder</a> to get started.
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<h2 class="h6 text-secondary">Recently added</h2>
|
||||
<div class="vs-grid mb-4">
|
||||
{{range .Data.RecentlyAdded}}{{template "album_card" .}}{{end}}
|
||||
</div>
|
||||
|
||||
<h2 class="h6 text-secondary">Recently played</h2>
|
||||
<div class="vs-grid">
|
||||
{{range .Data.RecentlyPlayedAlbums}}{{template "album_card" .}}{{end}}
|
||||
</div>
|
||||
{{template "footer" .}}{{end}}
|
||||
@@ -0,0 +1,33 @@
|
||||
{{define "login"}}<!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>Log in · 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">
|
||||
</head>
|
||||
<body class="d-flex align-items-center justify-content-center" style="min-height:100vh;">
|
||||
<div class="card" style="width: 22rem;">
|
||||
<div class="card-body">
|
||||
<h1 class="h4 text-center mb-3"><i class="bi bi-vinyl-fill"></i> VibeSonic</h1>
|
||||
{{if .Flash}}<div class="alert alert-{{if .FlashKind}}{{.FlashKind}}{{else}}danger{{end}}">{{.Flash}}</div>{{end}}
|
||||
<form method="post" action="/login">
|
||||
{{template "csrf_field" .CSRFToken}}
|
||||
<input type="hidden" name="next" value="{{.Data.Next}}">
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="username">Username</label>
|
||||
<input type="text" class="form-control" id="username" name="username" autofocus required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="password">Password</label>
|
||||
<input type="password" class="form-control" id="password" name="password" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-100">Log in</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -0,0 +1,15 @@
|
||||
{{define "playlist"}}{{template "header" .}}
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h2 class="h5 mb-0">{{.Data.Playlist.Name}}</h2>
|
||||
{{if .Data.Playlist.Description}}<div class="text-secondary small">{{.Data.Playlist.Description}}</div>{{end}}
|
||||
</div>
|
||||
{{if .Data.Owned}}
|
||||
<form method="post" action="/playlists/{{.Data.Playlist.ID}}/delete" onsubmit="return confirm('Delete this playlist?');">
|
||||
{{template "csrf_field" .CSRFToken}}
|
||||
<button type="submit" class="btn btn-outline-danger btn-sm"><i class="bi bi-trash"></i> Delete</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
{{if .Data.Songs}}{{template "song_rows" .Data.Songs}}{{else}}<p class="text-secondary">This playlist is empty.</p>{{end}}
|
||||
{{template "footer" .}}{{end}}
|
||||
@@ -0,0 +1,23 @@
|
||||
{{define "playlists"}}{{template "header" .}}
|
||||
<form method="post" action="/playlists" class="row g-2 mb-4" style="max-width: 480px;">
|
||||
{{template "csrf_field" .CSRFToken}}
|
||||
<div class="col-8">
|
||||
<input type="text" name="name" class="form-control" placeholder="New playlist name" required>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<button type="submit" class="btn btn-primary w-100"><i class="bi bi-plus-lg"></i> Create</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{{if .Data}}
|
||||
<div class="list-group">
|
||||
{{range .Data}}
|
||||
<a class="list-group-item list-group-item-action d-flex justify-content-between align-items-center" href="/playlists/{{.ID}}">
|
||||
<span><i class="bi bi-list-ul me-2"></i>{{.Name}}{{if .IsPublic}} <span class="badge text-bg-secondary">public</span>{{end}}</span>
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="text-secondary">No playlists yet.</p>
|
||||
{{end}}
|
||||
{{template "footer" .}}{{end}}
|
||||
@@ -0,0 +1,3 @@
|
||||
{{define "recent"}}{{template "header" .}}
|
||||
{{if .Data}}{{template "song_rows" .Data}}{{else}}<p class="text-secondary">Nothing played yet.</p>{{end}}
|
||||
{{template "footer" .}}{{end}}
|
||||
@@ -0,0 +1,16 @@
|
||||
{{define "search"}}{{template "header" .}}
|
||||
<form method="get" action="/search" class="mb-4">
|
||||
<input type="search" name="q" class="form-control" placeholder="Search artists, albums, songs..." value="{{.Data.Query}}" autofocus>
|
||||
</form>
|
||||
|
||||
{{if .Data.Query}}
|
||||
<h2 class="h6 text-secondary">Artists</h2>
|
||||
{{if .Data.Artists}}<div class="vs-grid mb-4">{{range .Data.Artists}}{{template "artist_card" .}}{{end}}</div>{{else}}<p class="text-secondary small">No matching artists.</p>{{end}}
|
||||
|
||||
<h2 class="h6 text-secondary">Albums</h2>
|
||||
{{if .Data.Albums}}<div class="vs-grid mb-4">{{range .Data.Albums}}{{template "album_card" .}}{{end}}</div>{{else}}<p class="text-secondary small">No matching albums.</p>{{end}}
|
||||
|
||||
<h2 class="h6 text-secondary">Songs</h2>
|
||||
{{if .Data.Songs}}{{template "song_rows" .Data.Songs}}{{else}}<p class="text-secondary small">No matching songs.</p>{{end}}
|
||||
{{end}}
|
||||
{{template "footer" .}}{{end}}
|
||||
@@ -0,0 +1,83 @@
|
||||
{{define "settings"}}{{template "header" .}}
|
||||
<div class="row g-4" style="max-width: 900px;">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h2 class="h6">Change password</h2>
|
||||
<form method="post" action="/settings/password">
|
||||
{{template "csrf_field" .CSRFToken}}
|
||||
<div class="mb-2">
|
||||
<label class="form-label">Current password</label>
|
||||
<input type="password" class="form-control" name="current_password" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">New password</label>
|
||||
<input type="password" class="form-control" name="new_password" minlength="8" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-sm">Update password</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-3">
|
||||
<div class="card-body">
|
||||
<h2 class="h6">API keys</h2>
|
||||
<p class="text-secondary small">Used by third-party Subsonic clients in later phases.</p>
|
||||
<form method="post" action="/settings/api-keys" class="d-flex gap-2 mb-3">
|
||||
{{template "csrf_field" .CSRFToken}}
|
||||
<input type="text" name="name" class="form-control form-control-sm" placeholder="Key name">
|
||||
<button type="submit" class="btn btn-outline-primary btn-sm text-nowrap">New key</button>
|
||||
</form>
|
||||
{{if .Data.NewAPIKey}}
|
||||
<div class="alert alert-warning small">
|
||||
Copy this key now — it won't be shown again:<br>
|
||||
<code>{{.Data.NewAPIKey}}</code>
|
||||
</div>
|
||||
{{end}}
|
||||
<table class="table table-sm vs-table mb-0">
|
||||
<tbody>
|
||||
{{range .Data.APIKeys}}
|
||||
<tr>
|
||||
<td>{{.Name}}</td>
|
||||
<td class="text-secondary small">{{fmtTime .CreatedAt}}</td>
|
||||
<td class="text-end">
|
||||
<form method="post" action="/settings/api-keys/{{.ID}}/revoke">
|
||||
{{template "csrf_field" $.CSRFToken}}
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger">Revoke</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h2 class="h6">Active sessions</h2>
|
||||
<table class="table table-sm vs-table mb-0">
|
||||
<tbody>
|
||||
{{range .Data.Sessions}}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="small">{{.UserAgent}}</div>
|
||||
<div class="text-secondary small">{{.IPAddress}} · since {{fmtTime .CreatedAt}}</div>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<form method="post" action="/settings/sessions/{{.ID}}/revoke">
|
||||
{{template "csrf_field" $.CSRFToken}}
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger">Revoke</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{template "footer" .}}{{end}}
|
||||
@@ -0,0 +1,51 @@
|
||||
{{define "setup"}}<!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>Setup · 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">
|
||||
</head>
|
||||
<body class="d-flex align-items-center justify-content-center" style="min-height:100vh;">
|
||||
<div class="card" style="width: 28rem;">
|
||||
<div class="card-body">
|
||||
<h1 class="h4 mb-1"><i class="bi bi-vinyl-fill"></i> Welcome to VibeSonic</h1>
|
||||
<p class="text-secondary small mb-3">Let's create your administrator account and your first music folder.</p>
|
||||
{{if .Flash}}<div class="alert alert-{{if .FlashKind}}{{.FlashKind}}{{else}}danger{{end}}">{{.Flash}}</div>{{end}}
|
||||
<form method="post" action="/setup">
|
||||
{{template "csrf_field" .CSRFToken}}
|
||||
|
||||
<h2 class="h6 mt-2">Administrator account</h2>
|
||||
<div class="mb-2">
|
||||
<label class="form-label" for="username">Username</label>
|
||||
<input type="text" class="form-control" id="username" name="username" autofocus required>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label" for="password">Password</label>
|
||||
<input type="password" class="form-control" id="password" name="password" minlength="8" required>
|
||||
</div>
|
||||
|
||||
<h2 class="h6 mt-3">First music folder</h2>
|
||||
<div class="mb-2">
|
||||
<label class="form-label" for="folder_name">Name</label>
|
||||
<input type="text" class="form-control" id="folder_name" name="folder_name" value="Music" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="folder_path">Path (on the server)</label>
|
||||
<input type="text" class="form-control" id="folder_path" name="folder_path" placeholder="/music" required>
|
||||
</div>
|
||||
|
||||
<div class="mb-3 small text-secondary">
|
||||
ffmpeg: {{if .Data.FFmpegFound}}<span class="text-success"><i class="bi bi-check-circle"></i> found at {{.Data.FFmpegPath}}</span>{{else}}<span class="text-warning"><i class="bi bi-exclamation-triangle"></i> not found at {{.Data.FFmpegPath}} — streaming and metadata will be limited</span>{{end}}<br>
|
||||
ffprobe: {{if .Data.FFprobeFound}}<span class="text-success"><i class="bi bi-check-circle"></i> found at {{.Data.FFprobePath}}</span>{{else}}<span class="text-warning"><i class="bi bi-exclamation-triangle"></i> not found at {{.Data.FFprobePath}}</span>{{end}}
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary w-100">Create account and start scanning</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,8 @@
|
||||
{{define "songs"}}{{template "header" .}}
|
||||
{{if .Data.Songs}}{{template "song_rows" .Data.Songs}}{{else}}<p class="text-secondary">No songs yet.</p>{{end}}
|
||||
{{if .Data.HasMore}}
|
||||
<div class="text-center mt-3">
|
||||
<a class="btn btn-outline-secondary btn-sm" href="?offset={{.Data.NextOffset}}">Load more</a>
|
||||
</div>
|
||||
{{end}}
|
||||
{{template "footer" .}}{{end}}
|
||||
Reference in New Issue
Block a user