feat(admin): update stylesheet

This commit is contained in:
sentriz
2023-02-21 23:11:54 +00:00
parent 16e6046e85
commit 222256cccb
44 changed files with 691 additions and 1026 deletions

6
go.mod
View File

@@ -8,6 +8,7 @@ require (
github.com/dexterlb/mpvipc v0.0.0-20221227161445-38b9935eae9d
github.com/disintegration/imaging v1.6.2
github.com/dustin/go-humanize v1.0.1
github.com/fatih/structs v1.1.0
github.com/fsnotify/fsnotify v1.6.0
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
github.com/google/uuid v1.3.0
@@ -26,9 +27,10 @@ require (
github.com/oklog/run v1.1.0
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c
github.com/peterbourgon/ff v1.7.1
github.com/philippta/go-template v0.0.0-20220911145045-4556aca435e4
github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be
github.com/sentriz/gormstore v0.0.0-20220105134332-64e31f7f6981
golang.org/x/exp v0.0.0-20230213192124-5e25df0256eb
golang.org/x/exp v0.0.0-20230224173230-c95f2b4c22f2
golang.org/x/net v0.7.0
gopkg.in/gormigrate.v1 v1.6.0
)
@@ -51,7 +53,7 @@ require (
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/mmcdole/goxpp v1.0.0 // indirect
github.com/mmcdole/goxpp v1.1.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
golang.org/x/crypto v0.6.0 // indirect

12
go.sum
View File

@@ -27,6 +27,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5 h1:Yzb9+7DPaBjB8zlTR87/ElzFsnQfuHnVUVqpZZIcV5Y=
github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0=
github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
github.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk=
github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
@@ -111,8 +113,8 @@ github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zx
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/mmcdole/gofeed v1.2.0 h1:kuq7tJnDf0pnsDzF820ukuySHxFimAcizpG15gYHIns=
github.com/mmcdole/gofeed v1.2.0/go.mod h1:TEyTG4gw4Q5Co+Hgahx/Oi3E0JHLM8BXtWC+mkJtRsw=
github.com/mmcdole/goxpp v1.0.0 h1:/eu75G4jwH/LaugmPVB0FFC8LdKw00UMrpo6N7ym45o=
github.com/mmcdole/goxpp v1.0.0/go.mod h1:v+25+lT2ViuQ7mVxcncQ8ch1URund48oH+jhjiwEgS8=
github.com/mmcdole/goxpp v1.1.0 h1:WwslZNF7KNAXTFuzRtn/OKZxFLJAAyOA9w82mDz2ZGI=
github.com/mmcdole/goxpp v1.1.0/go.mod h1:v+25+lT2ViuQ7mVxcncQ8ch1URund48oH+jhjiwEgS8=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -131,6 +133,8 @@ github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwU
github.com/pelletier/go-toml v1.6.0/go.mod h1:5N711Q9dKgbdkxHL+MEfF31hpT7l0S0s/t2kKREewys=
github.com/peterbourgon/ff v1.7.1 h1:xt1lxTG+Nr2+tFtysY7abFgPoH3Lug8CwYJMOmJRXhk=
github.com/peterbourgon/ff v1.7.1/go.mod h1:fYI5YA+3RDqQRExmFbHnBjEeWzh9TrS8rnRpEq7XIg0=
github.com/philippta/go-template v0.0.0-20220911145045-4556aca435e4 h1:SvVjZyjXBOjqjCdMiC9ndyWb709aP5qU4Qbun40GCxA=
github.com/philippta/go-template v0.0.0-20220911145045-4556aca435e4/go.mod h1:Mpa6Hci7lO3vybfdYlWXmH5gEq2vyOmYYjhrlwCTW3w=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be h1:ta7tUOvsPHVHGom5hKW5VXNc2xZIkfCKP8iaqOyYtUQ=
@@ -149,8 +153,8 @@ golang.org/x/crypto v0.0.0-20191205180655-e7c4368fe9dd/go.mod h1:LzIPMQfyMNhhGPh
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.6.0 h1:qfktjS5LUO+fFKeJXZ+ikTRijMmljikvG68fpMMruSc=
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
golang.org/x/exp v0.0.0-20230213192124-5e25df0256eb h1:PaBZQdo+iSDyHT053FjUCgZQ/9uqVwPOcl7KSWhKn6w=
golang.org/x/exp v0.0.0-20230213192124-5e25df0256eb/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
golang.org/x/exp v0.0.0-20230224173230-c95f2b4c22f2 h1:Jvc7gsqn21cJHCmAWx0LiimpP18LZmUxkT5Mp7EZ1mI=
golang.org/x/exp v0.0.0-20230224173230-c95f2b4c22f2/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.5.0 h1:5JMiNunQeQw++mMOz48/ISeNu3Iweh/JaZU8ZLqHRrI=
golang.org/x/image v0.5.0/go.mod h1:FVC7BI/5Ym8R25iw5OLsgshdUBbT1h5jZTpA+mvAdZ4=

View File

@@ -1,18 +0,0 @@
//nolint:gochecknoglobals,golint,stylecheck
package assets
import (
"embed"
)
//go:embed layouts
var Layouts embed.FS
//go:embed pages
var Pages embed.FS
//go:embed partials
var Partials embed.FS
//go:embed static
var Static embed.FS

View File

@@ -1,31 +0,0 @@
{{ define "layout" }}
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>gonic</title>
{{ template "head" }}
</head>
<body>
<div id="content">
<div id="header">
<a href="{{ path "/admin/home" }}">
<img src="{{ path "/admin/static/gonic.png" }}">
</a>
</div>
{{ range $flash := .Flashes }}
<div class="padded flash-{{ $flash.Type }}">
<i class="mdi mdi-alert-circle"></i> {{ $flash.Message }}
</div>
{{ end }}
{{ template "content" . }}
<div class="padded-side text-right">
<span class="text-light">{{ .Version }}</span>
senan kelly, 2020
<span class="text-light">&#124;</span>
<a href="https://github.com/sentriz/gonic">github</a>
</div>
</div>
</body>
</html>
{{ end }}

View File

@@ -1,10 +0,0 @@
{{ define "content" }}
<div class="padded-side text-light text-right">
welcome {{ .User.Name }}
&#124;
<a href="{{ path "/admin/home" }}">home</a>
&#124;
<a href="{{ path "/admin/logout" }}">logout <i class="mdi mdi-logout-variant"></i></a>
</div>
{{ template "user" . }}
{{ end }}

View File

@@ -1,26 +0,0 @@
{{ define "user" }}
<div class="padded box">
<div class="box-title">
<i class="mdi mdi-account-key"></i> changing {{ .SelectedUser.Name }}'s avatar
</div>
{{ if ne (len .SelectedUser.Avatar) 0 }}
<form class="block" action="{{ printf "/admin/delete_avatar_do?user=%s" .SelectedUser.Name | path }}" method="post">
<input type="submit" value="delete avatar">
</form>
{{ end }}
<form
class="block file-upload"
enctype="multipart/form-data"
action="{{ printf "/admin/change_avatar_do?user=%s" .SelectedUser.Name | path }}"
method="post"
>
<div style="position: relative;">
<input style="position: absolute; opacity: 0;" name="avatar" type="file" accept="image/jpeg image/png image/gif" />
<input type="button" value="upload avatar">
</div>
{{ if ne (len .SelectedUser.Avatar) 0 }}
<p><img class="avatar-preview" src="data:image/jpg;base64,{{ .SelectedUser.Avatar | base64 }}"/></p>
{{ end }}
</form>
</div>
{{ end }}

View File

@@ -1,26 +0,0 @@
{{ define "user" }}
<div class="padded box">
<div class="box-title">
<i class="mdi mdi-account-key"></i> changing account avatar
</div>
{{ if ne (len .SelectedUser.Avatar) 0 }}
<form class="block" action="{{ path "/admin/delete_own_avatar_do" }}" method="post">
<input type="submit" value="delete avatar">
</form>
{{ end }}
<form
class="block file-upload"
enctype="multipart/form-data"
action="{{ path "/admin/change_own_avatar_do" }}"
method="post"
>
<div style="position: relative;">
<input style="position: absolute; opacity: 0;" name="avatar" type="file" accept="image/jpeg image/png image/gif" />
<input type="button" value="upload avatar">
</div>
{{ if ne (len .SelectedUser.Avatar) 0 }}
<p><img class="avatar-preview" src="data:image/jpg;base64,{{ .SelectedUser.Avatar | base64 }}"/></p>
{{ end }}
</form>
</div>
{{ end }}

View File

@@ -1,12 +0,0 @@
{{ define "user" }}
<div class="padded box">
<div class="box-title">
<i class="mdi mdi-account-key"></i> changing account password
</div>
<form class="block" action="{{ path "/admin/change_own_password_do" }}" method="post">
<input type="password" id="password_one" name="password_one" placeholder="new password">
<input type="password" id="password_two" name="password_two" placeholder="verify new password">
<input type="submit" value="change">
</form>
</div>
{{ end }}

View File

@@ -1,11 +0,0 @@
{{ define "user" }}
<div class="padded box">
<div class="box-title">
<i class="mdi mdi-account-key"></i> changing account username
</div>
<form class="block" action="{{ path "/admin/change_own_username_do" }}" method="post">
<input type="text" id="username" name="username" placeholder="new username">
<input type="submit" value="change">
</form>
</div>
{{ end }}

View File

@@ -1,12 +0,0 @@
{{ define "user" }}
<div class="padded box">
<div class="box-title">
<i class="mdi mdi-account-key"></i> changing {{ .SelectedUser.Name }}'s password
</div>
<form class="block" action="{{ printf "/admin/change_password_do?user=%s" .SelectedUser.Name | path }}" method="post">
<input type="password" id="password_one" name="password_one" placeholder="new password">
<input type="password" id="password_two" name="password_two" placeholder="verify new password">
<input type="submit" value="change">
</form>
</div>
{{ end }}

View File

@@ -1,11 +0,0 @@
{{ define "user" }}
<div class="padded box">
<div class="box-title">
<i class="mdi mdi-account-key"></i> changing {{ .SelectedUser.Name }}'s username
</div>
<form class="block" action="{{ printf "/admin/change_username_do?user=%s" .SelectedUser.Name | path }}" method="post">
<input type="text" id="username" name="username" placeholder="new username">
<input type="submit" value="change">
</form>
</div>
{{ end }}

View File

@@ -1,14 +0,0 @@
{{ define "user" }}
<div class="padded box">
<div class="box-title">
<i class="mdi mdi-account-remove"></i> deleting user {{ .SelectedUser.Name }}
</div>
<div class="text-right">
are you sure?<br/>
<span class="text-light">their plays, starred, etc. will also be deleted</span>
</div>
<form class="block" action="{{ printf "/admin/delete_user_do?user=%s" .SelectedUser.Name | path }}" method="post">
<input type="submit" value="yes">
</form>
</div>
{{ end }}

View File

@@ -1,280 +0,0 @@
{{ define "user" }}
<div class="padded box">
<div class="box-title">
<i class="mdi mdi-chart-arc"></i> stats
</div>
<div class="block-right">
<table id="stats" class="text-right">
<tr><td>artists:</td> <td>{{ .ArtistCount }}</td></tr>
<tr><td>albums:</td> <td>{{ .AlbumCount }}</td></tr>
<tr><td>tracks:</td> <td>{{ .TrackCount }}</td></tr>
</table>
</div>
</div>
<div class="padded box">
<div class="box-title">
<i class="mdi mdi-lastfm"></i> last.fm
</div>
<div class="box-description text-light">
<p>gonic can scrobble to <a href="https://www.last.fm/" target="_blank">last.fm</a> for any user (but the admin must set a global api key)</p>
</div>
<div class="text-right">
{{ if .CurrentLastFMAPIKey }}
<span class="text-light">current status</span>
{{ if .User.LastFMSession }}
<span>linked</span><br/>
<form action="{{ path "/admin/unlink_lastfm_do" }}" method="post">
<input type="submit" value="unlink">
</form>
{{ else }}
<span class="angry">unlinked</span><br/>
{{ $cbPath := path "/admin/link_lastfm_do" }}
{{ $cbURL := printf "%s%s" .RequestRoot $cbPath }}
<a href="https://www.last.fm/api/auth/?api_key={{ .CurrentLastFMAPIKey }}&cb={{ $cbURL }}">link&#8230;</a>
{{ end }}
{{ else }}
<p class="text-light">api key not set</p>
{{ if not .User.IsAdmin }}
<p class="text-light">please ask your admin to set it</p>
{{ end }}
{{ end }}
{{ if .User.IsAdmin }}
<p><a href="{{ path "/admin/update_lastfm_api_key" }}">update api key&#8230;</a></p>
{{ end }}
</div>
</div>
<div class="padded box">
<div class="box-title">
<i class="mdi mdi-brain"></i> listenbrainz
</div>
<div class="box-description text-light">
<p>gonic can scrobble to <a href="https://listenbrainz.org/" target="_blank">listenbrainz</a> and compatible sites</p>
</div>
<div class="text-right">
<span class="text-light">current status</span>
{{ if .User.ListenBrainzToken }}
<span>linked</span><br/>
<form action="{{ path "/admin/unlink_listenbrainz_do" }}" method="post">
<input type="submit" value="unlink">
</form>
{{ else }}
<span class="angry">unlinked</span>
<form class="block" action="{{ path "/admin/link_listenbrainz_do" }}" method="post">
<input type="text" name="url" placeholder="server addr" value="{{ default .DefaultListenBrainzURL .User.ListenBrainzURL }}">
<input type="text" name="token" placeholder="e199b311abd01f0d" value="{{ .User.ListenBrainzToken }}">
<input type="submit" value="update">
</form>
{{ end }}
</div>
</div>
<div class="padded box">
{{ if .User.IsAdmin }}
{{/* admin panel to manage all users */}}
<div class="box-title">
<i class="mdi mdi-account-multiple"></i> users
</div>
<div class="text-right">
{{ range $user := .AllUsers }}
<i>{{ $user.Name }}</i>
<span class="text-light no-small">{{ $user.CreatedAt | date }}</span>
<span class="text-light">&#124;</span>
<a href="{{ printf "/admin/change_username?user=%s" $user.Name | path }}">username&#8230;</a>
<span class="text-light">&#124;</span>
<a href="{{ printf "/admin/change_password?user=%s" $user.Name | path }}">password&#8230;</a>
<span class="text-light">&#124;</span>
<a href="{{ printf "/admin/change_avatar?user=%s" $user.Name | path }}">change avatar&#8230;</a>
<span class="text-light">&#124;</span>
{{ if $user.IsAdmin }}
<span class="text-light">delete&#8230;</span>
{{ else }}
<a href="{{ printf "/admin/delete_user?user=%s" $user.Name | path }}">delete&#8230;</a>
{{ end }}
<br/>
{{ end }}
<a href="{{ path "/admin/create_user" }}" class="button">create new&#8230;</a>
</div>
{{ else }}
{{/* user panel to manage themselves */}}
<div class="box-title">
<i class="mdi mdi-account"></i> your account
</div>
<div class="text-right">
<a href="{{ path "/admin/change_own_username" }}" class="button">change username&#8230;</a>
<span class="text-light">&#124;</span>
<a href="{{ path "/admin/change_own_password" }}" class="button">change password&#8230;</a>
<span class="text-light">&#124;</span>
<a href="{{ path "/admin/change_own_avatar" }}" class="button">change avatar&#8230;</a>
</div>
{{ end }}
</div>
<div class="padded box">
<div class="box-title">
<i class="mdi mdi-folder-multiple"></i> recent folders
</div>
<div class="block-right text-right">
{{ if eq (len .RecentFolders) 0 }}
<span class="text-light">no folders yet</span>
{{ end }}
<table id="recent-folders">
<colgroup>
<col width="80%" />
<col width="0%" />
</colgroup>
{{ range $folder := .RecentFolders }}
<tr>
<td class="text-right text-trunc">{{ $folder.RightPath }}</td>
<td><span class="text-light" title="{{ $folder.ModifiedAt }}">{{ $folder.ModifiedAt | dateHuman }}</span></td>
</tr>
{{ end }}
</table>
{{- if and (not .IsScanning) (.User.IsAdmin) -}}
{{- if not .LastScanTime.IsZero -}}
<p class="text-light" title="{{ .LastScanTime }}">scanned {{ .LastScanTime | dateHuman }}</p>
{{ end }}
<form action="{{ path "/admin/start_scan_inc_do" }}" method="post">
<input type="submit" title="start a incremental scan" value="scan now">
</form>
<form action="{{ path "/admin/start_scan_full_do" }}" method="post">
<input type="submit" title="start a full scan (takes longer, and shouldn&#39;t usually be necessary)" value="scan full (!)">
</form>
{{ end }}
{{- if .IsScanning }}<p>scan in progress...</p>{{ end }}
</div>
</div>
<div class="padded box">
<div class="box-title">
<i class="mdi mdi-file-music"></i> transcoding device profiles
</div>
<div class="box-description text-light">
<p>you can find your device's client name in the gonic logs.</p>
<p>some common client names are <span class="text-emp">DSub</span>, <span class="text-emp">Jamstash</span>, <span class="text-emp">Soundwaves</span>, or use <span class="text-emp">*</span> as fallback rule for any client.</p>
<p>for more info, see <a href="https://github.com/sentriz/gonic/wiki/transcode-profiles" target="_blank">transcode profiles</a></p>
</div>
<div class="block-right">
<table id="transcode-preferences">
{{ range $pref := .TranscodePreferences }}
<tr>
{{ $formSuffix := kebabcase $pref.Client }}
<form id="transcode-pref-{{ $formSuffix }}" action="{{ printf "/admin/delete_transcode_pref_do?client=%s" $pref.Client | path }}" method="post"></form>
<td>{{ $pref.Client }}</td>
<td>{{ $pref.Profile }}</td>
<td><input form="transcode-pref-{{ $formSuffix }}" type="submit" value="delete"></td>
</tr>
{{ end }}
<tr>
<form id="transcode-pref-add" action="{{ path "/admin/create_transcode_pref_do" }}" method="post"></form>
<td><input form="transcode-pref-add" type="text" name="client" placeholder="client name"></td>
<td><select form="transcode-pref-add" name="profile">
{{ range $profile := .TranscodeProfiles }}
<option value="{{ $profile }}">{{ $profile }}</option>
{{ end }}
</select></td>
<td><input form="transcode-pref-add" type="submit" value="save"></td>
</tr>
</table>
</div>
</div>
{{ if .User.IsAdmin }}
<div class="padded box">
<div class="box-title">
<i class="mdi mdi-rss-box"></i> podcasts
</div>
<div class="box-description text-light">
<p>you can add podcasts rss feeds here</p>
</div>
<div>
<table id="podcast-preferences">
{{ range $pref := .Podcasts }}
<tr>
<form id="podcast-{{ $pref.ID }}-download" action="{{ printf "/admin/download_podcast_do?id=%d" $pref.ID | path }}" method="post"></form>
<form id="podcast-{{ $pref.ID }}-auto-download" action="{{ printf "/admin/update_podcast_do?id=%d" $pref.ID | path }}" method="post"></form>
<form id="podcast-{{ $pref.ID }}-delete" action="{{ printf "/admin/delete_podcast_do?id=%d" $pref.ID | path }}" method="post"></form>
<td class="text-full">{{ $pref.Title }}</td>
<td><select class="no-small" form="podcast-{{ $pref.ID }}-auto-download" name="setting">
{{ if eq $pref.AutoDownload "latest" }}
<option value="latest" selected="selected">download latest</option>
<option value="none">no auto download</option>
{{ else }}
<option value="none" selected="selected" >no auto download</option>
<option value="latest">download latest</option>
{{ end }}
</select></td>
<td><input class="no-small" form="podcast-{{ $pref.ID }}-download" type="submit" value="download all"></td>
<td><input form="podcast-{{ $pref.ID }}-auto-download" type="submit" value="save"></td>
<td><input form="podcast-{{ $pref.ID }}-delete" type="submit" value="delete"></td>
</tr>
{{ end }}
<tr>
<form id="podcast-add" action="{{ path "/admin/add_podcast_do" }}" method="post"></form>
<td><input form="podcast-add" type="text" name="feed" placeholder="rss feed url"></td>
<td><input form="podcast-add" type="submit" value="save"></td>
</tr>
</table>
</div>
</div>
{{ end }}
{{ if .User.IsAdmin }}
<div class="padded box">
<div class="box-title">
<i class="mdi mdi-radio"></i> internet_radio_stations
</div>
<div class="box-description text-light">
<p>you can add and update internet radio stations here</p>
</div>
<div>
<table id="irs-preferences">
{{ range $pref := .InternetRadioStations }}
<tr>
<form id="irs-{{ $pref.ID }}-update" action="{{ printf "/admin/update_internet_radio_station_do?id=%d" $pref.ID | path }}" method="post"></form>
<form id="irs-{{ $pref.ID }}-delete" action="{{ printf "/admin/delete_internet_radio_station_do?id=%d" $pref.ID | path }}" method="post"></form>
<td><input form="irs-{{ $pref.ID }}-update" type="text" name="streamURL" value={{ $pref.StreamURL }}></td>
<td><input form="irs-{{ $pref.ID }}-update" type="text" name="name" value={{ $pref.Name }}></td>
<td><input form="irs-{{ $pref.ID }}-update" type="text" name="homepageURL" value={{ $pref.HomepageURL }}></td>
<td><input form="irs-{{ $pref.ID }}-update" type="submit" value="update"></td>
<td><input form="irs-{{ $pref.ID }}-delete" type="submit" value="delete"></td>
</tr>
{{ end }}
<tr>
<form id="irs-add" action="{{ path "/admin/add_internet_radio_station_do" }}" method="post"></form>
<td><input form="irs-add" type="text" name="streamURL" placeholder="stream URL"></td>
<td><input form="irs-add" type="text" name="name" placeholder="name"></td>
<td><input form="irs-add" type="text" name="homepageURL" placeholder="[homepage URL]"></td>
<td><input form="irs-add" type="submit" value="add"></td>
</tr>
</table>
</div>
</div>
{{ end }}
<div class="padded box">
<div class="box-title">
<i class="mdi mdi-playlist-music"></i> playlists
</div>
<div class="block-right text-right">
{{ if eq (len .Playlists) 0 }}
<span class="text-light">no playlists yet</span>
{{ end }}
<table id="recent-playlists">
{{ range $i, $playlist := .Playlists }}
<tr>
<form id="recent-playlists-{{ $i }}" action="{{ printf "/admin/delete_playlist_do?id=%d" $playlist.ID | path }}" method="post"></form>
<td class="text-right">{{ $playlist.Name }}</td>
<td><span class="text-light">({{ $playlist.TrackCount }} tracks)</span></td>
<td class="no-small"><span class="text-light" title="{{ $playlist.CreatedAt }}">{{ $playlist.CreatedAt | dateHuman }}</span></td>
<td><input form="recent-playlists-{{ $i }}" type="submit" value="delete"></td>
</tr>
{{ end }}
</table>
<form
class="file-upload"
enctype="multipart/form-data"
action="{{ path "/admin/upload_playlist_do" }}"
method="post"
>
<div style="position: relative;">
<input style="position: absolute; opacity: 0;" name="playlist-files" type="file" multiple />
<input type="button" value="upload m3u8">
</div>
</form>
</div>
</div>
{{ end }}

View File

@@ -1,12 +0,0 @@
{{ define "content" }}
<div class="padded box">
<div class="box-title">
<i class="mdi mdi-login-variant"></i> login
</div>
<form class="block" action="{{ path "/admin/login_do" }}" method="post">
<input type="text" id="username" name="username" placeholder="username">
<input type="password" id="password" name="password" placeholder="password">
<input type="submit" value="login">
</form>
</div>
{{ end }}

View File

@@ -1,7 +0,0 @@
{{ define "content" }}
<div class="padded">
<div class="text-right">
page not found
</div>
</div>
{{ end }}

View File

@@ -1,19 +0,0 @@
{{ define "user" }}
<div class="padded box">
<div class="box-title">
<i class="mdi mdi-key-change"></i> updating last.fm keys
</div>
<div class="box-description text-light">
<p>you can get an api key <a href="https://www.last.fm/api/account/create" target="_blank">here</a> (note: only the "application name" field is required)</p>
</div>
<div class="text-right">
<p><span class="text-light">current key</span> <i>{{ default "not set" .CurrentLastFMAPIKey }}</i></p>
<p><span class="text-light">current secret</span> <i>{{ default "not set" .CurrentLastFMAPISecret }}</i></p>
</div>
<form class="block" action="{{ path "/admin/update_lastfm_api_key_do" }}" method="post">
<input type="text" id="api_key" name="api_key" placeholder="new key">
<input type="text" id="secret" name="secret" placeholder="new secret">
<input type="submit" value="update">
</form>
</div>
{{ end }}

View File

@@ -1,9 +0,0 @@
{{ define "head" }}
<link rel="stylesheet" href="{{ path "/admin/static/reset.css" }}">
<link rel="stylesheet" href="https://cdn.materialdesignicons.com/3.6.95/css/materialdesignicons.min.css">
<link rel="stylesheet" href="{{ path "/admin/static/main.css" | noCache }}">
<link rel="shortcut icon" href="{{ path "/admin/static/favicon.ico" }}" type="image/x-icon">
<link rel="icon" href="{{ path "/admin/static/favicon.ico" }}" type="image/x-icon">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<script src="{{ path "/admin/static/main.js" }}" defer></script>
{{ end }}

View File

@@ -1,186 +0,0 @@
:root {
--size: 13px;
--width-body: 750px;
--width-box-description: 510px;
--width-form: 400px;
}
@media only screen and (max-width: 780px) {
:root {
--size: 11px;
}
}
*,
body,
button,
div,
i,
input[type],
select,
span,
html {
font-family: monospace;
font-size: var(--size);
color: black;
line-height: 1;
}
body {
max-width: var(--width-body);
margin: 0 auto;
overflow-y: scroll;
}
input[type],
select,
button,
textarea {
border-radius: 0;
box-sizing: border-box;
margin: 0;
padding: 0;
border: none;
outline: 1px solid #ccc;
height: var(--size);
vertical-align: bottom;
}
input[type] {
width: calc(var(--size) * 9);
background-color: white;
cursor: pointer;
}
input[type="text"],
input[type="password"] {
width: 100%;
}
input[type="submit"] {
width: fit-content;
}
form.block {
max-width: var(--width-form);
margin-left: auto;
margin-right: 0;
display: flex;
flex-direction: column;
align-items: flex-end;
text-align: right;
}
form > * {
width: 100%;
}
table {
white-space: nowrap;
}
table td {
padding-left: calc(var(--size) * 0.5);
}
table td.text-trunc {
text-overflow: ellipsis;
overflow: hidden;
max-width: 0;
}
p {
color: inherit;
}
a,
a:visited {
color: #0064c1;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
#content > * {
margin: calc(var(--size) * 1.5);
}
#header {
border-bottom: 2px solid #0000001a;
}
#header img {
width: 60%;
display: block;
margin: 0 auto;
height: auto;
}
.flash-warning {
background-color: #fd1b1b1c;
border-right: 2px solid #fd1b1b1c;
border-bottom: 2px solid #fd1b1b1c;
}
.flash-normal {
background-color: #15ff5424;
border-right: 2px solid #15ff5424;
border-bottom: 2px solid #15ff5424;
}
.box {
background-color: #00000005;
border-right: 2px solid #0000000c;
border-bottom: 2px solid #0000000c;
}
.box-description {
max-width: var(--width-box-description);
}
@media only screen and (max-width: 780px) {
.no-small {
display: none;
}
}
.text-right {
text-align: right;
}
.text-light {
color: #00000082;
}
.text-emp {
font-style: italic;
color: #444;
}
.text-full {
width: 100%;
}
.block-right > * {
margin-left: auto;
}
.padded {
padding: var(--size);
}
.padded-side {
padding: 0 var(--size);
}
.angry {
background-color: #f4433669;
}
.avatar-preview {
width: 64px;
height: 64px;
object-fit: cover;
}

View File

@@ -1,5 +0,0 @@
for (const form of document.querySelectorAll("form.file-upload") || []) {
const input = form.querySelector("input[type=file]");
if (!input) continue;
input.onchange = (e) => form.submit();
}

View File

@@ -1,135 +0,0 @@
/* http://meyerweb.com/eric/tools/css/reset/
v2.0 | 20110126
License: none (public domain)
*/
html,
body,
div,
span,
applet,
object,
iframe,
h1,
h2,
h3,
h4,
h5,
h6,
p,
blockquote,
pre,
a,
abbr,
acronym,
address,
big,
cite,
code,
del,
dfn,
em,
img,
ins,
kbd,
q,
s,
samp,
small,
strike,
strong,
sub,
sup,
tt,
var,
b,
u,
i,
center,
dl,
dt,
dd,
ol,
ul,
li,
fieldset,
form,
label,
legend,
table,
caption,
tbody,
tfoot,
thead,
tr,
th,
td,
article,
aside,
canvas,
details,
embed,
figure,
figcaption,
footer,
header,
hgroup,
menu,
nav,
output,
ruby,
section,
summary,
time,
mark,
audio,
video {
margin: 0;
padding: 0;
border: 0;
font-size: 100%;
font: inherit;
vertical-align: baseline;
}
/* html5 display-role reset for older browsers */
article,
aside,
details,
figcaption,
figure,
footer,
header,
hgroup,
menu,
nav,
section {
display: block;
}
body {
line-height: 1;
}
ol,
ul {
list-style: none;
}
blockquote,
q {
quotes: none;
}
blockquote:before,
blockquote:after,
q:before,
q:after {
content: "";
content: none;
}
table {
border-collapse: collapse;
border-spacing: 0;
}

View File

@@ -0,0 +1,10 @@
package adminui
import "embed"
//go:embed components.tmpl pages/*.tmpl
var TemplatesFS embed.FS
//go:generate npx tailwindcss@v3.2.4 --config tailwind.config.js --input style.css --output static/style.css --minify
//go:embed static/*
var StaticFS embed.FS

View File

@@ -0,0 +1,107 @@
{{ define "block" }}
<div class="border-b-2 border-r-2 border-gray-300/80 bg-gray-50 p-4">
<div class="inline-flex items-center gap-x-3">
<span class="text-gray-500/80 w-4">{{ component "icon" .Props.Icon }}{{ end }}</span>
<span class="text-gray-900 font-bold">{{ .Props.Name }}</span>
</div>
<hr class="bg-gray-900/30 my-1" />
{{ if .Props.Desc }}
<div class="font-medium text-gray-500 max-w-[700px]">{{ .Props.Desc }}</div>
{{ end }}
<div class="mt-3 max-w-[700px] ml-auto space-y-2 text-right">
{{ slot }}
</div>
</div>
{{ end }}
{{ define "link" }}
<a class="text-blue-500" href="{{ .Props.To }}">{{ slot }}<span class="hidden md:inline">&#8230;</span></a>
{{ end }}
{{ define "ext_link" }}
<a class="text-blue-500" href="{{ .Props.To }}" target="_blank" rel="noopener noreferrer">{{ slot }}</a>
{{ end }}
{{ define "layout_user" }}
<div class="px-4 text-gray-500 text-right whitespace-nowrap">
welcome {{ .User.Name }}
&#124;
{{ component "link" (props . "To" (path "/admin/home")) }}home{{ end }}
&#124;
{{ component "link" (props . "To" (path "/admin/logout")) }}logout{{ end }}
</div>
{{ slot }}
{{ end }}
{{ define "layout" }}
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>gonic</title>
<link rel="stylesheet" href="{{ path "/admin/static/style.css" | noCache }}">
<link rel="shortcut icon" href="{{ path "/admin/static/favicon.ico" }}" type="image/x-icon">
<link rel="icon" href="{{ path "/admin/static/favicon.ico" }}" type="image/x-icon">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<script src="{{ path "/admin/static/main.js" }}" defer></script>
</head>
<body class="font-mono leading-4 text-base text-gray-800">
<div class="container mx-auto min-w-min space-y-5 p-5">
<div class="border-b-2 border-gray-300">
<a href="{{ path "/admin/home" }}">
<img class="mx-auto w-[400px]" src="{{ path "/admin/static/gonic.png" }}">
</a>
</div>
{{ range $flash := .Flashes }}
{{ $colour := "bg-green-200" }}
{{ if eq $flash.Type "warning" }}{{ $colour = "bg-red-200" }}{{ end }}
<div class="p-4 shadow-sm {{ $colour }} inline-flex items-center gap-x-3 w-full">
<span class="text-gray-500/80 w-5">{{ component "icon" "circle-info" }}{{ end }}</span>
<span>{{ $flash.Message }}</span>
</div>
{{ end }}
{{ slot }}
<div class="px-5 text-right whitespace-nowrap">
<span class="text-gray-500">{{ .Version }}</span>
senan kelly, 2020
<span class="text-gray-500">&#124;</span>
{{ component "ext_link" (props . "To" "https://github.com/sentriz/gonic") }}github{{ end }}
</div>
</div>
</body>
</html>
{{ end }}
{{/* from https://github.com/FortAwesome/Font-Awesome/tree/6.x/svgs/brand */}}
{{/* TODO: see if we can dynamically render templates based on a variable instead of this */}}
{{ define "icon" }}
{{ if (eq . "lastfm") }}
<svg class="fill-current aspect-square" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Free 6.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2023 Fonticons, Inc. --><path d="M225.8 367.1l-18.8-51s-30.5 34-76.2 34c-40.5 0-69.2-35.2-69.2-91.5 0-72.1 36.4-97.9 72.1-97.9 66.5 0 74.8 53.3 100.9 134.9 18.8 56.9 54 102.6 155.4 102.6 72.7 0 122-22.3 122-80.9 0-72.9-62.7-80.6-115-92.1-25.8-5.9-33.4-16.4-33.4-34 0-19.9 15.8-31.7 41.6-31.7 28.2 0 43.4 10.6 45.7 35.8l58.6-7c-4.7-52.8-41.1-74.5-100.9-74.5-52.8 0-104.4 19.9-104.4 83.9 0 39.9 19.4 65.1 68 76.8 44.9 10.6 79.8 13.8 79.8 45.7 0 21.7-21.1 30.5-61 30.5-59.2 0-83.9-31.1-97.9-73.9-32-96.8-43.6-163-161.3-163C45.7 113.8 0 168.3 0 261c0 89.1 45.7 137.2 127.9 137.2 66.2 0 97.9-31.1 97.9-31.1z"/></svg>
{{ else if (eq . "brain" ) }}
<svg class="fill-current aspect-square" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Free 6.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2023 Fonticons, Inc. --><path d="M184 0c30.9 0 56 25.1 56 56V456c0 30.9-25.1 56-56 56c-28.9 0-52.7-21.9-55.7-50.1c-5.2 1.4-10.7 2.1-16.3 2.1c-35.3 0-64-28.7-64-64c0-7.4 1.3-14.6 3.6-21.2C21.4 367.4 0 338.2 0 304c0-31.9 18.7-59.5 45.8-72.3C37.1 220.8 32 207 32 192c0-30.7 21.6-56.3 50.4-62.6C80.8 123.9 80 118 80 112c0-29.9 20.6-55.1 48.3-62.1C131.3 21.9 155.1 0 184 0zM328 0c28.9 0 52.6 21.9 55.7 49.9c27.8 7 48.3 32.1 48.3 62.1c0 6-.8 11.9-2.4 17.4c28.8 6.2 50.4 31.9 50.4 62.6c0 15-5.1 28.8-13.8 39.7C493.3 244.5 512 272.1 512 304c0 34.2-21.4 63.4-51.6 74.8c2.3 6.6 3.6 13.8 3.6 21.2c0 35.3-28.7 64-64 64c-5.6 0-11.1-.7-16.3-2.1c-3 28.2-26.8 50.1-55.7 50.1c-30.9 0-56-25.1-56-56V56c0-30.9 25.1-56 56-56z"/></svg>
{{ else if (eq . "chart-pie" ) }}
<svg class="fill-current aspect-square" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><!--! Font Awesome Free 6.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2023 Fonticons, Inc. --><path d="M302 240V16.6c0-9 7-16.6 16-16.6C441.7 0 542 100.3 542 224c0 9-7.6 16-16.6 16H302zM30 272C30 150.7 120.1 50.3 237 34.3c9.2-1.3 17 6.1 17 15.4V288L410.5 444.5c6.7 6.7 6.2 17.7-1.5 23.1C369.8 495.6 321.8 512 270 512C137.5 512 30 404.6 30 272zm526.4 16c9.3 0 16.6 7.8 15.4 17c-7.7 55.9-34.6 105.6-73.9 142.3c-6 5.6-15.4 5.2-21.2-.7L318 288H556.4z"/></svg>
{{ else if (eq . "brain" ) }}
<svg class="fill-current aspect-square" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Free 6.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2023 Fonticons, Inc. --><path d="M184 0c30.9 0 56 25.1 56 56V456c0 30.9-25.1 56-56 56c-28.9 0-52.7-21.9-55.7-50.1c-5.2 1.4-10.7 2.1-16.3 2.1c-35.3 0-64-28.7-64-64c0-7.4 1.3-14.6 3.6-21.2C21.4 367.4 0 338.2 0 304c0-31.9 18.7-59.5 45.8-72.3C37.1 220.8 32 207 32 192c0-30.7 21.6-56.3 50.4-62.6C80.8 123.9 80 118 80 112c0-29.9 20.6-55.1 48.3-62.1C131.3 21.9 155.1 0 184 0zM328 0c28.9 0 52.6 21.9 55.7 49.9c27.8 7 48.3 32.1 48.3 62.1c0 6-.8 11.9-2.4 17.4c28.8 6.2 50.4 31.9 50.4 62.6c0 15-5.1 28.8-13.8 39.7C493.3 244.5 512 272.1 512 304c0 34.2-21.4 63.4-51.6 74.8c2.3 6.6 3.6 13.8 3.6 21.2c0 35.3-28.7 64-64 64c-5.6 0-11.1-.7-16.3-2.1c-3 28.2-26.8 50.1-55.7 50.1c-30.9 0-56-25.1-56-56V56c0-30.9 25.1-56 56-56z"/></svg>
{{ else if (eq . "users" ) }}
<svg class="fill-current aspect-square" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512"><!--! Font Awesome Free 6.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2023 Fonticons, Inc. --><path d="M144 0a80 80 0 1 1 0 160A80 80 0 1 1 144 0zM512 0a80 80 0 1 1 0 160A80 80 0 1 1 512 0zM0 298.7C0 239.8 47.8 192 106.7 192h42.7c15.9 0 31 3.5 44.6 9.7c-1.3 7.2-1.9 14.7-1.9 22.3c0 38.2 16.8 72.5 43.3 96c-.2 0-.4 0-.7 0H21.3C9.6 320 0 310.4 0 298.7zM405.3 320c-.2 0-.4 0-.7 0c26.6-23.5 43.3-57.8 43.3-96c0-7.6-.7-15-1.9-22.3c13.6-6.3 28.7-9.7 44.6-9.7h42.7C592.2 192 640 239.8 640 298.7c0 11.8-9.6 21.3-21.3 21.3H405.3zM224 224a96 96 0 1 1 192 0 96 96 0 1 1 -192 0zM128 485.3C128 411.7 187.7 352 261.3 352H378.7C452.3 352 512 411.7 512 485.3c0 14.7-11.9 26.7-26.7 26.7H154.7c-14.7 0-26.7-11.9-26.7-26.7z"/></svg>
{{ else if (eq . "user" ) }}
<svg class="fill-current aspect-square" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--! Font Awesome Free 6.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2023 Fonticons, Inc. --><path d="M224 256A128 128 0 1 0 224 0a128 128 0 1 0 0 256zm-45.7 48C79.8 304 0 383.8 0 482.3C0 498.7 13.3 512 29.7 512H418.3c16.4 0 29.7-13.3 29.7-29.7C448 383.8 368.2 304 269.7 304H178.3z"/></svg>
{{ else if (eq . "folder-tree" ) }}
<svg class="fill-current aspect-square" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><!--! Font Awesome Free 6.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2023 Fonticons, Inc. --><path d="M64 32C64 14.3 49.7 0 32 0S0 14.3 0 32v96V384c0 35.3 28.7 64 64 64H256V384H64V160H256V96H64V32zM288 192c0 17.7 14.3 32 32 32H544c17.7 0 32-14.3 32-32V64c0-17.7-14.3-32-32-32H445.3c-8.5 0-16.6-3.4-22.6-9.4L409.4 9.4c-6-6-14.1-9.4-22.6-9.4H320c-17.7 0-32 14.3-32 32V192zm0 288c0 17.7 14.3 32 32 32H544c17.7 0 32-14.3 32-32V352c0-17.7-14.3-32-32-32H445.3c-8.5 0-16.6-3.4-22.6-9.4l-13.3-13.3c-6-6-14.1-9.4-22.6-9.4H320c-17.7 0-32 14.3-32 32V480z"/></svg>
{{ else if (eq . "music" ) }}
<svg class="fill-current aspect-square" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Free 6.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2023 Fonticons, Inc. --><path d="M499.1 6.3c8.1 6 12.9 15.6 12.9 25.7v72V368c0 44.2-43 80-96 80s-96-35.8-96-80s43-80 96-80c11.2 0 22 1.6 32 4.6V147L192 223.8V432c0 44.2-43 80-96 80s-96-35.8-96-80s43-80 96-80c11.2 0 22 1.6 32 4.6V200 128c0-14.1 9.3-26.6 22.8-30.7l320-96c9.7-2.9 20.2-1.1 28.3 5z"/></svg>
{{ else if (eq . "rss" ) }}
<svg class="fill-current aspect-square" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--! Font Awesome Free 6.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2023 Fonticons, Inc. --><path d="M0 64C0 46.3 14.3 32 32 32c229.8 0 416 186.2 416 416c0 17.7-14.3 32-32 32s-32-14.3-32-32C384 253.6 226.4 96 32 96C14.3 96 0 81.7 0 64zM0 416a64 64 0 1 1 128 0A64 64 0 1 1 0 416zM32 160c159.1 0 288 128.9 288 288c0 17.7-14.3 32-32 32s-32-14.3-32-32c0-123.7-100.3-224-224-224c-17.7 0-32-14.3-32-32s14.3-32 32-32z"/></svg>
{{ else if (eq . "radio" ) }}
<svg class="fill-current aspect-square" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Free 6.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2023 Fonticons, Inc. --><path d="M494.8 47c12.7-3.7 20-17.1 16.3-29.8S494-2.8 481.2 1L51.7 126.9c-9.4 2.7-17.9 7.3-25.1 13.2C10.5 151.7 0 170.6 0 192v4V304 448c0 35.3 28.7 64 64 64H448c35.3 0 64-28.7 64-64V192c0-35.3-28.7-64-64-64H218.5L494.8 47zM368 240a80 80 0 1 1 0 160 80 80 0 1 1 0-160zM80 256c0-8.8 7.2-16 16-16h96c8.8 0 16 7.2 16 16s-7.2 16-16 16H96c-8.8 0-16-7.2-16-16zM64 320c0-8.8 7.2-16 16-16H208c8.8 0 16 7.2 16 16s-7.2 16-16 16H80c-8.8 0-16-7.2-16-16zm16 64c0-8.8 7.2-16 16-16h96c8.8 0 16 7.2 16 16s-7.2 16-16 16H96c-8.8 0-16-7.2-16-16z"/></svg>
{{ else if (eq . "list" ) }}
<svg class="fill-current aspect-square" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Free 6.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2023 Fonticons, Inc. --><path d="M40 48C26.7 48 16 58.7 16 72v48c0 13.3 10.7 24 24 24H88c13.3 0 24-10.7 24-24V72c0-13.3-10.7-24-24-24H40zM192 64c-17.7 0-32 14.3-32 32s14.3 32 32 32H480c17.7 0 32-14.3 32-32s-14.3-32-32-32H192zm0 160c-17.7 0-32 14.3-32 32s14.3 32 32 32H480c17.7 0 32-14.3 32-32s-14.3-32-32-32H192zm0 160c-17.7 0-32 14.3-32 32s14.3 32 32 32H480c17.7 0 32-14.3 32-32s-14.3-32-32-32H192zM16 232v48c0 13.3 10.7 24 24 24H88c13.3 0 24-10.7 24-24V232c0-13.3-10.7-24-24-24H40c-13.3 0-24 10.7-24 24zM40 368c-13.3 0-24 10.7-24 24v48c0 13.3 10.7 24 24 24H88c13.3 0 24-10.7 24-24V392c0-13.3-10.7-24-24-24H40z"/></svg>
{{ else if (eq . "circle-info" ) }}
<svg class="fill-current aspect-square" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Free 6.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2023 Fonticons, Inc. --><path d="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM216 336h24V272H216c-13.3 0-24-10.7-24-24s10.7-24 24-24h48c13.3 0 24 10.7 24 24v88h8c13.3 0 24 10.7 24 24s-10.7 24-24 24H216c-13.3 0-24-10.7-24-24s10.7-24 24-24zm40-208a32 32 0 1 1 0 64 32 32 0 1 1 0-64z"/></svg>
{{ else if (eq . "key" ) }}
<svg class="fill-current aspect-square" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Free 6.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2023 Fonticons, Inc. --><path d="M336 352c97.2 0 176-78.8 176-176S433.2 0 336 0S160 78.8 160 176c0 18.7 2.9 36.8 8.3 53.7L7 391c-4.5 4.5-7 10.6-7 17v80c0 13.3 10.7 24 24 24h80c13.3 0 24-10.7 24-24V448h40c13.3 0 24-10.7 24-24V384h40c6.4 0 12.5-2.5 17-7l33.3-33.3c16.9 5.4 35 8.3 53.7 8.3zM376 96a40 40 0 1 1 0 80 40 40 0 1 1 0-80z"/></svg>
{{ end }}
{{ end }}

View File

@@ -0,0 +1,25 @@
{{ component "layout" . }}
{{ component "layout_user" . }}
{{ component "block" (props .
"Icon" "user"
"Name" (printf "changing %s's avatar" .SelectedUser.Name)
) }}
<div class="flex flex-col gap-2 items-end">
{{ if ne (len .SelectedUser.Avatar) 0 }}
<img class="h-[8rem] w-[8rem] object-cover" src="data:image/jpg;base64,{{ .SelectedUser.Avatar | base64 }}" />
<form class="contents" action="{{ printf "/admin/delete_avatar_do?user=%s" .SelectedUser.Name | path }}" method="post">
<input type="submit" value="delete avatar">
</form>
{{ end }}
<form enctype="multipart/form-data" action="{{ printf "/admin/change_avatar_do?user=%s" .SelectedUser.Name | path }}" method="post">
<div class="relative pointer-events-auto">
<input class="auto-submit absolute opacity-0" name="avatar" type="file" accept="image/jpeg image/png image/gif" />
<input type="button" value="choose file">
</div>
</form>
</div>
{{ end }}
{{ end }}
{{ end }}

View File

@@ -0,0 +1,16 @@
{{ component "layout" . }}
{{ component "layout_user" . }}
{{ component "block" (props .
"Icon" "user"
"Name" (printf "changing %s's password" .SelectedUser.Name)
) }}
<form class="flex flex-col gap-2 items-end" action="{{ printf "/admin/change_password_do?user=%s" .SelectedUser.Name | path }}" method="post">
<input type="password" id="password_one" name="password_one" placeholder="new password">
<input type="password" id="password_two" name="password_two" placeholder="verify new password">
<input type="submit" value="change">
</form>
{{ end }}
{{ end }}
{{ end }}

View File

@@ -0,0 +1,15 @@
{{ component "layout" . }}
{{ component "layout_user" . }}
{{ component "block" (props .
"Icon" "user"
"Name" (printf "changing %s's username" .SelectedUser.Name)
) }}
<form class="flex flex-col md:flex-row gap-2 items-end" action="{{ printf "/admin/change_username_do?user=%s" .SelectedUser.Name | path }}" method="post">
<input type="text" id="username" name="username" placeholder="new username">
<input type="submit" value="change">
</form>
{{ end }}
{{ end }}
{{ end }}

View File

@@ -1,13 +1,17 @@
{{ define "user" }}
<div class="padded box">
<div class="box-title">
<i class="mdi mdi-account-plus"></i> creating new user
</div>
<form class="block" action="{{ path "/admin/create_user_do" }}" method="post">
{{ component "layout" . }}
{{ component "layout_user" . }}
{{ component "block" (props .
"Icon" "user"
"Name" "creating new user"
) }}
<form class="flex flex-col gap-2 items-end" action="{{ path "/admin/create_user_do" }}" method="post">
<input type="text" id="username" name="username" placeholder="username">
<input type="password" id="password_one" name="password_one" placeholder="password">
<input type="password" id="password_two" name="password_two" placeholder="verify password">
<input type="submit" value="create">
</form>
</div>
{{ end }}
{{ end }}
{{ end }}

View File

@@ -0,0 +1,15 @@
{{ component "layout" . }}
{{ component "layout_user" . }}
{{ component "block" (props .
"Icon" "user"
"Name" (printf "deleting user %s" .SelectedUser.Name)
"Desc" "are you sure? this will also delete their plays, playlists, starred, rated, etc."
) }}
<form class="inline-block" action="{{ printf "/admin/delete_user_do?user=%s" .SelectedUser.Name | path }}" method="post">
<input type="submit" value="yes">
</form>
{{ end }}
{{ end }}
{{ end }}

View File

@@ -0,0 +1,226 @@
{{ component "layout" . }}
{{ component "layout_user" . }}
{{ component "block" (props .
"Icon" "chart-pie"
"Name" "stats"
"Desc" "total items found in all watched folders"
) }}
<div class="grid grid-cols-[auto_min-content] gap-2 gap-x-5 text-right">
<div class="text-gray-500">artists</div>
<div class="font-bold">{{ .ArtistCount }}</div>
<div class="text-gray-500">albums</div>
<div class="font-bold">{{ .AlbumCount }}</div>
<div class="text-gray-500">tracks</div>
<div class="font-bold">{{ .TrackCount }}</div>
</div>
{{ end }}
{{ component "block" (props .
"Icon" "users"
"Name" "user management"
"Desc" "manage user accounts for subsonic api and web interface access"
) }}
<div class="grid grid-cols-[repeat(3,auto)_max-content] md:grid-cols-[auto_repeat(5,min-content)] gap-2 gap-x-5 items-center text-right">
{{ range $user := .AllUsers }}
<div class="col-span-3 md:col-auto ellipsis">{{ $user.Name }}</div>
<div class="text-gray-500 whitespace-nowrap">{{ $user.CreatedAt | date }}</div>
{{ component "link" (props . "To" (printf "/admin/change_username?user=%s" $user.Name | path)) }}username{{ end }}
{{ component "link" (props . "To" (printf "/admin/change_password?user=%s" $user.Name | path)) }}password{{ end }}
{{ component "link" (props . "To" (printf "/admin/change_avatar?user=%s" $user.Name | path)) }}avatar{{ end }}
{{ if $user.IsAdmin }}
<div class="text-gray-500">delete<span class="hidden md:inline">&#8230;</span></div>
{{ else }}
{{ component "link" (props . "To" (printf "/admin/delete_user?user=%s" $user.Name | path)) }}delete{{ end }}
{{ end }}
{{ end }}
{{ if .User.IsAdmin }}
<div class="col-span-full">{{ component "link" (props . "To" (path "/admin/create_user")) }}create new{{ end }}</div>
{{ end }}
</div>
{{ end }}
{{ component "block" (props .
"Icon" "folder-tree"
"Name" "recent folders"
) }}
<div class="grid grid-cols-[1fr,auto] gap-x-3 gap-y-2 items-center justify-items-end">
{{ if eq (len .RecentFolders) 0 }}
<div class="col-span-full text-gray-500">no folders yet</div>
{{ end }}
{{ range $folder := .RecentFolders }}
<div class="text-left ellipsis">{{ $folder.RightPath }}</div>
<div class="text-gray-500" title="{{ $folder.ModifiedAt }}">{{ $folder.ModifiedAt | dateHuman }}</div>
{{ end }}
{{ if and (not .IsScanning) (.User.IsAdmin) }}
{{ if not .LastScanTime.IsZero }}
<p class="col-span-full text-gray-500" title="{{ .LastScanTime }}">scanned {{ .LastScanTime | dateHuman }}</p>
{{ end }}
<form class="col-span-full" action="{{ path "/admin/start_scan_inc_do" }}" method="post">
<input type="submit" title="start a incremental scan" value="scan now">
</form>
{{ end }}
{{ if .IsScanning }}<p class="text-green-500 col-span-full">scan in progress...</p>{{ end }}
</div>
{{ end }}
{{ component "block" (props .
"Icon" "music"
"Name" "transcoding device profiles"
"Desc" "you can find your device's client name in the gonic logs. some common client names are <span class='italic text-gray-800'>DSub</span>, <span class='italic text-gray-800'>Jamstash</span>, <span class=\"italic text-gray-800\">Soundwaves</span>, or use <span class=\"italic text-gray-800\">*</span> as fallback rule for any client. see the \"transcoding profiles\" page on the wiki for more info"
) }}
<div class="grid grid-cols-[1fr_1fr_auto] gap-2 items-center justify-items-end">
{{ range $pref := .TranscodePreferences }}
{{ $formSuffix := kebabcase $pref.Client }}
<div class="ellipsis">{{ $pref.Client }}</div>
<div>{{ $pref.Profile }}</div>
<form class="contents" action="{{ printf "/admin/delete_transcode_pref_do?client=%s" $pref.Client | path }}" method="post">
<input type="submit" value="delete">
</form>
{{ end }}
<form class="contents" action="{{ path "/admin/create_transcode_pref_do" }}" method="post">
<input type="text" name="client" placeholder="client name">
<select name="profile">
{{ range $profile := .TranscodeProfiles }}<option value="{{ $profile }}">{{ $profile }}</option>{{ end }}
</select>
<input type="submit" value="save">
</form>
</div>
{{ end }}
{{ component "block" (props .
"Icon" "lastfm"
"Name" "last.fm"
"Desc" "scrobble to last.fm on a per user basis (the admin must set a global api key first)"
) }}
<div class="flex flex-col gap-2 items-end">
{{ if .CurrentLastFMAPIKey }}
{{ if .User.LastFMSession }}
<p class="text-gray-500">current status <span class="font-bold text-green-500">linked</span></p>
<form class="contents" action="{{ path "/admin/unlink_lastfm_do" }}" method="post">
<input type="submit" value="unlink">
</form>
{{ else }}
<p class="text-gray-500">current status <span class="font-bold text-red-400">unlinked</span></p>
{{ $cbPath := path "/admin/link_lastfm_do" }}
{{ $cbURL := printf "%s%s" .RequestRoot $cbPath }}
<div>{{ component "link" (props . "To" (printf "https://www.last.fm/api/auth/?api_key=%s&cb=%s" .CurrentLastFMAPIKey $cbURL)) }}link{{ end }}</div>
{{ end }}
{{ else }}
<p class="font-bold">api key not set</p>
{{ if not .User.IsAdmin }}
<p class="text-gray-500">please ask your admin to set it</p>
{{ end }}
{{ end }}
{{ if .User.IsAdmin }}
<p>{{ component "link" (props . "To" (path "/admin/update_lastfm_api_key" )) }}update api key{{ end }}</p>
{{ end }}
</div>
{{ end }}
{{ component "block" (props .
"Icon" "brain"
"Name" "listenbrainz"
"Desc" "scrobble to listenbrainz and compatible sites on a per user basis"
) }}
<div class="grid grid-cols-[1fr_1fr_auto] gap-2 items-center justify-items-end">
{{ if .User.ListenBrainzToken }}
<p class="text-gray-500 col-span-full">current status <span class="font-bold text-green-500">linked</span></p>
<form class="contents" action="{{ path "/admin/unlink_listenbrainz_do" }}" method="post">
<input class="col-span-full" type="submit" value="unlink">
</form>
{{ else }}
<p class="text-gray-500 col-span-full">current status <span class="font-bold text-red-400">unlinked</span></p>
<form class="contents" action="{{ path "/admin/link_listenbrainz_do" }}" method="post">
<input type="text" name="url" placeholder="server addr" value="{{ default .DefaultListenBrainzURL .User.ListenBrainzURL }}">
<input type="text" name="token" placeholder="api key" value="{{ .User.ListenBrainzToken }}">
<input type="submit" value="update">
</form>
{{ end }}
</div>
{{ end }}
{{ if .User.IsAdmin }}
{{ component "block" (props .
"Icon" "rss"
"Name" "podcasts"
"Desc" "you can add podcasts rss feeds here"
) }}
<div class="grid grid-cols-[auto_auto_min-content] md:grid-cols-[5fr_3fr_auto_auto] gap-2 items-center justify-items-end">
{{ range $pref := .Podcasts }}
<div class="ellipsis">{{ $pref.Title }}</div>
<form class="contents" action="{{ printf "/admin/update_podcast_do?id=%d" $pref.ID | path }}" method="post">
<select class="auto-submit" name="setting">
<option value="latest" {{ if eq $pref.AutoDownload "latest" }}selected="selected"{{ end }}>download latest</option>
<option value="none" {{ if eq (default "none" $pref.AutoDownload) "none" }}selected="selected"{{ end }}>no auto download</option>
</select>
</form>
<form class="hidden md:contents" action="{{ printf "/admin/download_podcast_do?id=%d" $pref.ID | path }}" method="post">
<input type="submit" value="download all">
</form>
<form class="contents" action="{{ printf "/admin/delete_podcast_do?id=%d" $pref.ID | path }}" method="post">
<input type="submit" value="delete">
</form>
{{ end }}
<form class="contents" action="{{ path "/admin/add_podcast_do" }}" method="post">
<input class="md:col-start-2 col-span-2" type="text" name="feed" placeholder="rss feed url">
<input type="submit" value="add new">
</form>
</div>
{{ end }}
{{ end }}
{{ if .User.IsAdmin }}
{{ component "block" (props .
"Icon" "rss"
"Name" "internet radio stations"
"Desc" "you can add and update internet radio stations here"
) }}
<div class="grid grid-cols-[1fr_1fr_min-content_min-content] md:grid-cols-[1fr_1fr_1fr_auto_auto] gap-2 items-center justify-items-end">
{{ range $pref := .InternetRadioStations }}
<form class="contents" action="{{ printf "/admin/update_internet_radio_station_do?id=%d" $pref.ID | path }}" method="post">
<input class="col-span-full md:col-auto" type="text" name="name" value={{ $pref.Name }}>
<input type="text" name="streamURL" placeholder="stream url" value={{ $pref.StreamURL }}>
<input type="text" name="homepageURL" placeholder="homepage url" value={{ $pref.HomepageURL }}>
<input type="submit" value="update">
</form>
<form class="contents" action="{{ printf "/admin/delete_internet_radio_station_do?id=%d" $pref.ID | path }}" method="post">
<input type="submit" value="delete">
</form>
{{ end }}
<form class="contents" action="{{ path "/admin/add_internet_radio_station_do" }}" method="post">
<input type="text" name="name" placeholder="name">
<input type="text" name="streamURL" placeholder="stream url">
<input type="text" name="homepageURL" placeholder="homepage url">
<input class="col-auto md:col-span-2" type="submit" value="add">
</form>
</div>
{{ end }}
{{ end }}
{{ component "block" (props .
"Icon" "list"
"Name" "playlists"
"Desc" "choose a local <span class='italic text-gray-800'>.m3u8</span> file containing paths to music files that gonic has scanned. paths should be absolute, prefixed by the <span class='italic text-gray-800'>music-path</span> option that you started gonic with. a playlist will be created from the file and available to subsonic clients"
) }}
{{ if eq (len .Playlists) 0 }}
<span class="text-gray-500">no playlists yet</span>
{{ end }}
<div class="grid grid-cols-[auto_1fr_auto] md:grid-cols-[auto_repeat(3,min-content)] gap-x-3 gap-y-2 items-center justify-items-end">
{{ range $i, $playlist := .Playlists }}
<div class="text-right ellipsis">{{ $playlist.Name }}</div>
<div class="text-gray-500 whitespace-nowrap">({{ $playlist.TrackCount }} tracks)</div>
<div class="text-right text-gray-500 whitespace-nowrap hidden md:block" title="{{ $playlist.CreatedAt }}">{{ $playlist.CreatedAt | dateHuman }}</div>
<form class="contents" action="{{ printf "/admin/delete_playlist_do?id=%d" $playlist.ID | path }}" method="post">
<input type="submit" value="delete">
</form>
{{ end }}
<form class="col-span-full relative pointer-events-auto" enctype="multipart/form-data" action="{{ path "/admin/upload_playlist_do" }}" method="post">
<input class="auto-submit absolute opacity-0" name="playlist-files" type="file" multiple />
<input type="button" value="choose m3u8">
</form>
</div>
{{ end }}
{{ end }}
{{ end }}

View File

@@ -0,0 +1,13 @@
{{ component "layout" . }}
{{ component "block" (props .
"Icon" "user"
"Name" "login"
"Desc" "if you are logging in as an admin, the default credentials can be found in the readme"
) }}
<form class="flex flex-col md:flex-row gap-2 items-end" action="{{ path "/admin/login_do" }}" method="post">
<input class="text-center" type="text" id="username" name="username" placeholder="username">
<input class="text-center" type="password" id="password" name="password" placeholder="password">
<input type="submit" value="login">
</form>
{{ end }}
{{ end }}

View File

@@ -0,0 +1,3 @@
{{ component "layout" . }}
<p class="bg-red-100 p-4 text-center">page not found</p>
{{ end }}

View File

@@ -0,0 +1,21 @@
{{ component "layout" . }}
{{ component "layout_user" . }}
{{ component "block" (props .
"Icon" "user"
"Name" "update last.fm api keys"
"Desc" "you can get an api key from last.fm here <a class='text-blue-500' href='https://www.last.fm/api/account/create' target='_blank' rel='noopener noreferrer'>here</a>. note, only the <span class='italic text-gray-800'>application name</span> field is required"
) }}
<div class="flex flex-col gap-2 items-end">
<p class="text-gray-500">current key <span class="font-bold text-gray-800 italic">{{ default "not set" .CurrentLastFMAPIKey }}</span></p>
<p class="text-gray-500">current secret <span class="font-bold text-gray-800 italic">{{ default "not set" .CurrentLastFMAPISecret }}</span></p>
<form class="contents" action="{{ path "/admin/update_lastfm_api_key_do" }}" method="post">
<input type="text" id="api_key" name="api_key" placeholder="new key">
<input type="text" id="secret" name="secret" placeholder="new secret">
<input type="submit" value="update">
</form>
</div>
{{ end }}
{{ end }}
{{ end }}

View File

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

Before

Width:  |  Height:  |  Size: 236 KiB

After

Width:  |  Height:  |  Size: 236 KiB

View File

@@ -0,0 +1,3 @@
for (const input of document.querySelectorAll("input.auto-submit, select.auto-submit") || []) {
input.onchange = (e) => e.target.form.submit();
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,52 @@
@tailwind base;
form,
input,
select {
all: unset;
appearance: none;
display: block;
}
a {
text-decoration: none;
}
@font-face {
font-family: "Inconsolata";
font-style: normal;
font-weight: 500;
src: local(""),
url("/admin/static/inconsolata-v31-latin-500.woff2") format("woff2"),
url("/admin/static/inconsolata-v31-latin-500.woff") format("woff");
}
@font-face {
font-family: "Inconsolata";
font-style: normal;
font-weight: 600;
src: local(""),
url("/admin/static/inconsolata-v31-latin-600.woff2") format("woff2"),
url("/admin/static/inconsolata-v31-latin-600.woff") format("woff");
}
@tailwind components;
@tailwind utilities;
a {
@apply text-blue-500;
}
input[type],
select {
@apply h-6 px-2 leading-[1.5] w-full min-w-[3rem] md:min-w-[8rem] box-border bg-white text-gray-600 shadow-none border-0 outline outline-1 outline-gray-400/50 cursor-pointer overflow-hidden whitespace-nowrap text-ellipsis;
}
input[type="button"],
input[type="submit"] {
@apply text-center w-[6rem] md:w-[8rem] font-bold;
}
.ellipsis {
@apply max-w-full overflow-hidden text-ellipsis whitespace-nowrap;
}

View File

@@ -0,0 +1,13 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["*.tmpl", "**/*.tmpl"],
theme: {
screens: {
sm: "100%",
md: `870px`,
},
fontFamily: {
mono: ["Inconsolata", "monospace"],
},
},
};

View File

@@ -4,27 +4,27 @@ package ctrladmin
import (
"encoding/base64"
"encoding/gob"
"encoding/json"
"errors"
"fmt"
"html/template"
"io/fs"
"log"
"net/http"
"net/url"
"path/filepath"
"strings"
"time"
"github.com/Masterminds/sprig"
"github.com/dustin/go-humanize"
"github.com/fatih/structs"
"github.com/gorilla/sessions"
"github.com/oxtoacart/bpool"
"github.com/philippta/go-template/html/template"
"github.com/sentriz/gormstore"
"go.senan.xyz/gonic"
"go.senan.xyz/gonic/db"
"go.senan.xyz/gonic/podcasts"
"go.senan.xyz/gonic/server/assets"
"go.senan.xyz/gonic/server/ctrladmin/adminui"
"go.senan.xyz/gonic/server/ctrlbase"
)
@@ -37,6 +37,10 @@ const (
func funcMap() template.FuncMap {
return template.FuncMap{
"str": func(in any) string {
v, _ := json.Marshal(in)
return string(v)
},
"noCache": func(in string) string {
parsed, _ := url.Parse(in)
params := parsed.Query()
@@ -49,53 +53,49 @@ func funcMap() template.FuncMap {
},
"dateHuman": humanize.Time,
"base64": base64.StdEncoding.EncodeToString,
"props": func(parent any, values ...any) map[string]any {
if len(values)%2 != 0 {
panic("uneven number of key/value pairs")
}
props := map[string]any{}
for i := 0; i < len(values); i += 2 {
k, v := fmt.Sprint(values[i]), values[i+1]
props[k] = v
}
merged := map[string]any{}
if structs.IsStruct(parent) {
merged = structs.Map(parent)
}
merged["Props"] = props
return merged
},
}
}
type Controller struct {
*ctrlbase.Controller
buffPool *bpool.BufferPool
templates map[string]*template.Template
sessDB *gormstore.Store
Podcasts *podcasts.Podcasts
buffPool *bpool.BufferPool
template *template.Template
sessDB *gormstore.Store
Podcasts *podcasts.Podcasts
}
func New(b *ctrlbase.Controller, sessDB *gormstore.Store, podcasts *podcasts.Podcasts) (*Controller, error) {
tmpl := template.
tmpl, err := template.
New("layout").
Funcs(sprig.FuncMap()).
Funcs(template.FuncMap(sprig.FuncMap())).
Funcs(funcMap()). // static
Funcs(template.FuncMap{ // from base
"path": b.Path,
})
var err error
tmpl, err = tmpl.ParseFS(assets.Partials, "**/*.tmpl")
}).
ParseFS(adminui.TemplatesFS, "*.tmpl", "**/*.tmpl")
if err != nil {
return nil, fmt.Errorf("extend partials: %w", err)
return nil, fmt.Errorf("build template: %w", err)
}
tmpl, err = tmpl.ParseFS(assets.Layouts, "**/*.tmpl")
if err != nil {
return nil, fmt.Errorf("extend layouts: %w", err)
}
pagePaths, err := fs.Glob(assets.Pages, "**/*.tmpl")
if err != nil {
return nil, fmt.Errorf("parse pages: %w", err)
}
pages := map[string]*template.Template{}
for _, pagePath := range pagePaths {
pageBytes, _ := assets.Pages.ReadFile(pagePath)
page, _ := tmpl.Clone()
page, _ = page.Parse(string(pageBytes))
pageName := filepath.Base(pagePath)
pages[pageName] = page
}
return &Controller{
Controller: b,
buffPool: bpool.NewBufferPool(64),
templates: pages,
template: tmpl,
sessDB: sessDB,
Podcasts: podcasts,
}, nil
@@ -196,12 +196,7 @@ func (c *Controller) H(h handlerAdmin) http.Handler {
buff := c.buffPool.Get()
defer c.buffPool.Put(buff)
tmpl, ok := c.templates[resp.template]
if !ok {
http.Error(w, fmt.Sprintf("finding template %q", resp.template), 500)
return
}
if err := tmpl.Execute(buff, resp.data); err != nil {
if err := c.template.ExecuteTemplate(buff, resp.template, resp.data); err != nil {
http.Error(w, fmt.Sprintf("executing template: %v", err), 500)
return
}
@@ -236,8 +231,9 @@ type Flash struct {
Type FlashType
}
//nolint:gochecknoinits // for now I think it's nice that our types and their
// gob registrations are next to each other, in case there's more added later)
//
//nolint:gochecknoinits // for now I think it's nice that our types and their
func init() {
gob.Register(&Flash{})
}

View File

@@ -1,3 +1,4 @@
//nolint:goerr113
package ctrladmin
import (
@@ -41,6 +42,8 @@ func (c *Controller) ServeLogin(r *http.Request) *Response {
}
func (c *Controller) ServeHome(r *http.Request) *Response {
user := r.Context().Value(CtxUser).(*db.User)
data := &templateData{}
// stats box
c.DB.Model(&db.Artist{}).Count(&data.ArtistCount)
@@ -50,8 +53,14 @@ func (c *Controller) ServeHome(r *http.Request) *Response {
data.RequestRoot = c.BaseURL(r)
data.CurrentLastFMAPIKey, _ = c.DB.GetSetting("lastfm_api_key")
data.DefaultListenBrainzURL = listenbrainz.BaseURL
// users box
c.DB.Find(&data.AllUsers)
allUsersQ := c.DB.DB
if !user.IsAdmin {
allUsersQ = allUsersQ.Where("name=?", user.Name)
}
allUsersQ.Find(&data.AllUsers)
// recent folders box
c.DB.
Where("tag_artist_id IS NOT NULL").
@@ -64,8 +73,6 @@ func (c *Controller) ServeHome(r *http.Request) *Response {
data.LastScanTime = time.Unix(i, 0)
}
user := r.Context().Value(CtxUser).(*db.User)
// playlists box
c.DB.
Where("user_id=?", user.ID).
@@ -91,74 +98,6 @@ func (c *Controller) ServeHome(r *http.Request) *Response {
}
}
func (c *Controller) ServeChangeOwnUsername(r *http.Request) *Response {
return &Response{template: "change_own_username.tmpl"}
}
func (c *Controller) ServeChangeOwnUsernameDo(r *http.Request) *Response {
username := r.FormValue("username")
if err := validateUsername(username); err != nil {
return &Response{
redirect: r.Referer(),
flashW: []string{err.Error()},
}
}
user := r.Context().Value(CtxUser).(*db.User)
user.Name = username
c.DB.Save(user)
return &Response{redirect: "/admin/home"}
}
func (c *Controller) ServeChangeOwnPassword(r *http.Request) *Response {
return &Response{template: "change_own_password.tmpl"}
}
func (c *Controller) ServeChangeOwnPasswordDo(r *http.Request) *Response {
passwordOne := r.FormValue("password_one")
passwordTwo := r.FormValue("password_two")
if err := validatePasswords(passwordOne, passwordTwo); err != nil {
return &Response{
redirect: r.Referer(),
flashW: []string{err.Error()},
}
}
user := r.Context().Value(CtxUser).(*db.User)
user.Password = passwordOne
c.DB.Save(user)
return &Response{redirect: "/admin/home"}
}
func (c *Controller) ServeChangeOwnAvatar(r *http.Request) *Response {
data := &templateData{}
user := r.Context().Value(CtxUser).(*db.User)
data.SelectedUser = user
return &Response{
template: "change_own_avatar.tmpl",
data: data,
}
}
func (c *Controller) ServeChangeOwnAvatarDo(r *http.Request) *Response {
user := r.Context().Value(CtxUser).(*db.User)
avatar, err := getAvatarFile(r)
if err != nil {
return &Response{
redirect: r.Referer(),
flashW: []string{err.Error()},
}
}
user.Avatar = avatar
c.DB.Save(user)
return &Response{redirect: "/admin/home"}
}
func (c *Controller) ServeDeleteOwnAvatarDo(r *http.Request) *Response {
user := r.Context().Value(CtxUser).(*db.User)
user.Avatar = nil
c.DB.Save(user)
return &Response{redirect: "/admin/home"}
}
func (c *Controller) ServeLinkLastFMDo(r *http.Request) *Response {
token := r.URL.Query().Get("token")
if token == "" {
@@ -166,11 +105,11 @@ func (c *Controller) ServeLinkLastFMDo(r *http.Request) *Response {
}
apiKey, err := c.DB.GetSetting("lastfm_api_key")
if err != nil {
return &Response{code: 500, err: fmt.Sprintf("couldn't get api key: %v", err)}
return &Response{redirect: r.Referer(), flashW: []string{fmt.Sprintf("couldn't get api key: %v", err)}}
}
secret, err := c.DB.GetSetting("lastfm_secret")
if err != nil {
return &Response{code: 500, err: fmt.Sprintf("couldn't get secret: %v", err)}
return &Response{redirect: r.Referer(), flashW: []string{fmt.Sprintf("couldn't get secret: %v", err)}}
}
sessionKey, err := lastfm.GetSession(apiKey, secret, token)
if err != nil {
@@ -181,14 +120,18 @@ func (c *Controller) ServeLinkLastFMDo(r *http.Request) *Response {
}
user := r.Context().Value(CtxUser).(*db.User)
user.LastFMSession = sessionKey
c.DB.Save(&user)
if err := c.DB.Save(user).Error; err != nil {
return &Response{redirect: r.Referer(), flashW: []string{fmt.Sprintf("save user: %v", err)}}
}
return &Response{redirect: "/admin/home"}
}
func (c *Controller) ServeUnlinkLastFMDo(r *http.Request) *Response {
user := r.Context().Value(CtxUser).(*db.User)
user.LastFMSession = ""
c.DB.Save(&user)
if err := c.DB.Save(user).Error; err != nil {
return &Response{redirect: r.Referer(), flashW: []string{fmt.Sprintf("save user: %v", err)}}
}
return &Response{redirect: "/admin/home"}
}
@@ -204,7 +147,9 @@ func (c *Controller) ServeLinkListenBrainzDo(r *http.Request) *Response {
user := r.Context().Value(CtxUser).(*db.User)
user.ListenBrainzURL = url
user.ListenBrainzToken = token
c.DB.Save(&user)
if err := c.DB.Save(user).Error; err != nil {
return &Response{redirect: r.Referer(), flashW: []string{fmt.Sprintf("save user: %v", err)}}
}
return &Response{redirect: "/admin/home"}
}
@@ -212,18 +157,16 @@ func (c *Controller) ServeUnlinkListenBrainzDo(r *http.Request) *Response {
user := r.Context().Value(CtxUser).(*db.User)
user.ListenBrainzURL = ""
user.ListenBrainzToken = ""
c.DB.Save(&user)
if err := c.DB.Save(user).Error; err != nil {
return &Response{redirect: r.Referer(), flashW: []string{fmt.Sprintf("save user: %v", err)}}
}
return &Response{redirect: "/admin/home"}
}
func (c *Controller) ServeChangeUsername(r *http.Request) *Response {
username := r.URL.Query().Get("user")
if username == "" {
return &Response{code: 400, err: "please provide a username"}
}
user := c.DB.GetUserByName(username)
if user == nil {
return &Response{code: 400, err: "couldn't find a user with that name"}
user, err := selectedUserIfAdmin(c, r)
if err != nil {
return &Response{code: 400, err: err.Error()}
}
data := &templateData{}
data.SelectedUser = user
@@ -234,7 +177,10 @@ func (c *Controller) ServeChangeUsername(r *http.Request) *Response {
}
func (c *Controller) ServeChangeUsernameDo(r *http.Request) *Response {
username := r.URL.Query().Get("user")
user, err := selectedUserIfAdmin(c, r)
if err != nil {
return &Response{code: 400, err: err.Error()}
}
usernameNew := r.FormValue("username")
if err := validateUsername(usernameNew); err != nil {
return &Response{
@@ -242,20 +188,17 @@ func (c *Controller) ServeChangeUsernameDo(r *http.Request) *Response {
flashW: []string{err.Error()},
}
}
user := c.DB.GetUserByName(username)
user.Name = usernameNew
c.DB.Save(user)
if err := c.DB.Save(user).Error; err != nil {
return &Response{redirect: r.Referer(), flashW: []string{fmt.Sprintf("save username: %v", err)}}
}
return &Response{redirect: "/admin/home"}
}
func (c *Controller) ServeChangePassword(r *http.Request) *Response {
username := r.URL.Query().Get("user")
if username == "" {
return &Response{code: 400, err: "please provide a username"}
}
user := c.DB.GetUserByName(username)
if user == nil {
return &Response{code: 400, err: "couldn't find a user with that name"}
user, err := selectedUserIfAdmin(c, r)
if err != nil {
return &Response{code: 400, err: err.Error()}
}
data := &templateData{}
data.SelectedUser = user
@@ -266,7 +209,10 @@ func (c *Controller) ServeChangePassword(r *http.Request) *Response {
}
func (c *Controller) ServeChangePasswordDo(r *http.Request) *Response {
username := r.URL.Query().Get("user")
user, err := selectedUserIfAdmin(c, r)
if err != nil {
return &Response{code: 400, err: err.Error()}
}
passwordOne := r.FormValue("password_one")
passwordTwo := r.FormValue("password_two")
if err := validatePasswords(passwordOne, passwordTwo); err != nil {
@@ -275,20 +221,17 @@ func (c *Controller) ServeChangePasswordDo(r *http.Request) *Response {
flashW: []string{err.Error()},
}
}
user := c.DB.GetUserByName(username)
user.Password = passwordOne
c.DB.Save(user)
if err := c.DB.Save(user).Error; err != nil {
return &Response{redirect: r.Referer(), flashW: []string{fmt.Sprintf("save user: %v", err)}}
}
return &Response{redirect: "/admin/home"}
}
func (c *Controller) ServeChangeAvatar(r *http.Request) *Response {
username := r.URL.Query().Get("user")
if username == "" {
return &Response{code: 400, err: "please provide a username"}
}
user := c.DB.GetUserByName(username)
if user == nil {
return &Response{code: 400, err: "couldn't find a user with that name"}
user, err := selectedUserIfAdmin(c, r)
if err != nil {
return &Response{code: 400, err: err.Error()}
}
data := &templateData{}
data.SelectedUser = user
@@ -299,8 +242,10 @@ func (c *Controller) ServeChangeAvatar(r *http.Request) *Response {
}
func (c *Controller) ServeChangeAvatarDo(r *http.Request) *Response {
username := r.URL.Query().Get("user")
user := c.DB.GetUserByName(username)
user, err := selectedUserIfAdmin(c, r)
if err != nil {
return &Response{code: 400, err: err.Error()}
}
avatar, err := getAvatarFile(r)
if err != nil {
return &Response{
@@ -309,26 +254,34 @@ func (c *Controller) ServeChangeAvatarDo(r *http.Request) *Response {
}
}
user.Avatar = avatar
c.DB.Save(user)
return &Response{redirect: "/admin/home"}
if err := c.DB.Save(user).Error; err != nil {
return &Response{redirect: r.Referer(), flashW: []string{fmt.Sprintf("save user: %v", err)}}
}
return &Response{
redirect: r.Referer(),
flashN: []string{"avatar saved successfully"},
}
}
func (c *Controller) ServeDeleteAvatarDo(r *http.Request) *Response {
username := r.URL.Query().Get("user")
user := c.DB.GetUserByName(username)
user, err := selectedUserIfAdmin(c, r)
if err != nil {
return &Response{code: 400, err: err.Error()}
}
user.Avatar = nil
c.DB.Save(user)
return &Response{redirect: "/admin/home"}
if err := c.DB.Save(user).Error; err != nil {
return &Response{redirect: r.Referer(), flashW: []string{fmt.Sprintf("save user: %v", err)}}
}
return &Response{
redirect: r.Referer(),
flashN: []string{"avatar deleted successfully"},
}
}
func (c *Controller) ServeDeleteUser(r *http.Request) *Response {
username := r.URL.Query().Get("user")
if username == "" {
return &Response{code: 400, err: "please provide a username"}
}
user := c.DB.GetUserByName(username)
if user == nil {
return &Response{code: 400, err: "couldn't find a user with that name"}
user, err := selectedUserIfAdmin(c, r)
if err != nil {
return &Response{code: 400, err: err.Error()}
}
data := &templateData{}
data.SelectedUser = user
@@ -339,15 +292,19 @@ func (c *Controller) ServeDeleteUser(r *http.Request) *Response {
}
func (c *Controller) ServeDeleteUserDo(r *http.Request) *Response {
username := r.URL.Query().Get("user")
user := c.DB.GetUserByName(username)
user, err := selectedUserIfAdmin(c, r)
if err != nil {
return &Response{code: 400, err: err.Error()}
}
if user.IsAdmin {
return &Response{
redirect: "/admin/home",
flashW: []string{"can't delete the admin user"},
}
}
c.DB.Delete(user)
if err := c.DB.Delete(user).Error; err != nil {
return &Response{redirect: r.Referer(), flashW: []string{fmt.Sprintf("delete user: %v", err)}}
}
return &Response{redirect: "/admin/home"}
}
@@ -388,10 +345,10 @@ func (c *Controller) ServeUpdateLastFMAPIKey(r *http.Request) *Response {
data := &templateData{}
var err error
if data.CurrentLastFMAPIKey, err = c.DB.GetSetting("lastfm_api_key"); err != nil {
return &Response{code: 500, err: fmt.Sprintf("couldn't get api key: %v", err)}
return &Response{redirect: r.Referer(), flashW: []string{fmt.Sprintf("couldn't get api key: %v", err)}}
}
if data.CurrentLastFMAPISecret, err = c.DB.GetSetting("lastfm_secret"); err != nil {
return &Response{code: 500, err: fmt.Sprintf("couldn't get secret: %v", err)}
return &Response{redirect: r.Referer(), flashW: []string{fmt.Sprintf("couldn't get secret: %v", err)}}
}
return &Response{
template: "update_lastfm_api_key.tmpl",
@@ -409,10 +366,10 @@ func (c *Controller) ServeUpdateLastFMAPIKeyDo(r *http.Request) *Response {
}
}
if err := c.DB.SetSetting("lastfm_api_key", apiKey); err != nil {
return &Response{code: 500, err: fmt.Sprintf("couldn't set api key: %v", err)}
return &Response{redirect: r.Referer(), flashW: []string{fmt.Sprintf("couldn't set api key: %v", err)}}
}
if err := c.DB.SetSetting("lastfm_secret", secret); err != nil {
return &Response{code: 500, err: fmt.Sprintf("couldn't set secret: %v", err)}
return &Response{redirect: r.Referer(), flashW: []string{fmt.Sprintf("couldn't set secret: %v", err)}}
}
return &Response{redirect: "/admin/home"}
}
@@ -570,7 +527,7 @@ func (c *Controller) ServeInternetRadioStationAddDo(r *http.Request) *Response {
station.Name = name
station.HomepageURL = homepageURL
if err := c.DB.Save(&station).Error; err != nil {
return &Response{code: 500, err: fmt.Sprintf("error saving station: %v", err)}
return &Response{redirect: r.Referer(), flashW: []string{fmt.Sprintf("error saving station: %v", err)}}
}
return &Response{
@@ -613,7 +570,7 @@ func (c *Controller) ServeInternetRadioStationUpdateDo(r *http.Request) *Respons
var station db.InternetRadioStation
if err := c.DB.Where("id=?", stationID).First(&station).Error; err != nil {
return &Response{code: 404, err: fmt.Sprintf("find station by id: %v", err)}
return &Response{redirect: r.Referer(), flashW: []string{fmt.Sprintf("find station by id: %v", err)}}
}
station.StreamURL = streamURL
@@ -636,11 +593,11 @@ func (c *Controller) ServeInternetRadioStationDeleteDo(r *http.Request) *Respons
var station db.InternetRadioStation
if err := c.DB.Where("id=?", stationID).First(&station).Error; err != nil {
return &Response{code: 404, err: fmt.Sprintf("find station by id: %v", err)}
return &Response{redirect: r.Referer(), flashW: []string{fmt.Sprintf("find station by id: %v", err)}}
}
if err := c.DB.Where("id=?", stationID).Delete(&db.InternetRadioStation{}).Error; err != nil {
return &Response{code: 500, err: fmt.Sprintf("deleting radio station: %v", err)}
return &Response{redirect: r.Referer(), flashW: []string{fmt.Sprintf("deleting radio station: %v", err)}}
}
return &Response{
@@ -668,3 +625,16 @@ func getAvatarFile(r *http.Request) ([]byte, error) {
}
return buff.Bytes(), nil
}
func selectedUserIfAdmin(c *Controller, r *http.Request) (*db.User, error) {
selectedUsername := r.URL.Query().Get("user")
if selectedUsername == "" {
return nil, fmt.Errorf("please provide a username")
}
user := r.Context().Value(CtxUser).(*db.User)
if !user.IsAdmin && user.Name != selectedUsername {
return nil, fmt.Errorf("must be admin to perform actions for other users")
}
selectedUser := c.DB.GetUserByName(selectedUsername)
return selectedUser, nil
}

View File

@@ -22,8 +22,8 @@ import (
"go.senan.xyz/gonic/scrobble"
"go.senan.xyz/gonic/scrobble/lastfm"
"go.senan.xyz/gonic/scrobble/listenbrainz"
"go.senan.xyz/gonic/server/assets"
"go.senan.xyz/gonic/server/ctrladmin"
"go.senan.xyz/gonic/server/ctrladmin/adminui"
"go.senan.xyz/gonic/server/ctrlbase"
"go.senan.xyz/gonic/server/ctrlsubsonic"
"go.senan.xyz/gonic/transcode"
@@ -149,7 +149,7 @@ func setupAdmin(r *mux.Router, ctrl *ctrladmin.Controller) {
r.Handle("/login", ctrl.H(ctrl.ServeLogin))
r.Handle("/login_do", ctrl.HR(ctrl.ServeLoginDo)) // "raw" handler, updates session
staticHandler := http.StripPrefix("/admin", http.FileServer(http.FS(assets.Static)))
staticHandler := http.StripPrefix("/admin", http.FileServer(http.FS(adminui.StaticFS)))
r.PathPrefix("/static").Handler(staticHandler)
// user routes (if session is valid)
@@ -157,13 +157,15 @@ func setupAdmin(r *mux.Router, ctrl *ctrladmin.Controller) {
routUser.Use(ctrl.WithUserSession)
routUser.Handle("/logout", ctrl.HR(ctrl.ServeLogout)) // "raw" handler, updates session
routUser.Handle("/home", ctrl.H(ctrl.ServeHome))
routUser.Handle("/change_own_username", ctrl.H(ctrl.ServeChangeOwnUsername))
routUser.Handle("/change_own_username_do", ctrl.H(ctrl.ServeChangeOwnUsernameDo))
routUser.Handle("/change_own_password", ctrl.H(ctrl.ServeChangeOwnPassword))
routUser.Handle("/change_own_password_do", ctrl.H(ctrl.ServeChangeOwnPasswordDo))
routUser.Handle("/change_own_avatar", ctrl.H(ctrl.ServeChangeOwnAvatar))
routUser.Handle("/change_own_avatar_do", ctrl.H(ctrl.ServeChangeOwnAvatarDo))
routUser.Handle("/delete_own_avatar_do", ctrl.H(ctrl.ServeDeleteOwnAvatarDo))
routUser.Handle("/change_username", ctrl.H(ctrl.ServeChangeUsername))
routUser.Handle("/change_username_do", ctrl.H(ctrl.ServeChangeUsernameDo))
routUser.Handle("/change_password", ctrl.H(ctrl.ServeChangePassword))
routUser.Handle("/change_password_do", ctrl.H(ctrl.ServeChangePasswordDo))
routUser.Handle("/change_avatar", ctrl.H(ctrl.ServeChangeAvatar))
routUser.Handle("/change_avatar_do", ctrl.H(ctrl.ServeChangeAvatarDo))
routUser.Handle("/delete_avatar_do", ctrl.H(ctrl.ServeDeleteAvatarDo))
routUser.Handle("/delete_user", ctrl.H(ctrl.ServeDeleteUser))
routUser.Handle("/delete_user_do", ctrl.H(ctrl.ServeDeleteUserDo))
routUser.Handle("/link_lastfm_do", ctrl.H(ctrl.ServeLinkLastFMDo))
routUser.Handle("/unlink_lastfm_do", ctrl.H(ctrl.ServeUnlinkLastFMDo))
routUser.Handle("/link_listenbrainz_do", ctrl.H(ctrl.ServeLinkListenBrainzDo))
@@ -176,15 +178,6 @@ func setupAdmin(r *mux.Router, ctrl *ctrladmin.Controller) {
// admin routes (if session is valid, and is admin)
routAdmin := routUser.NewRoute().Subrouter()
routAdmin.Use(ctrl.WithAdminSession)
routAdmin.Handle("/change_username", ctrl.H(ctrl.ServeChangeUsername))
routAdmin.Handle("/change_username_do", ctrl.H(ctrl.ServeChangeUsernameDo))
routAdmin.Handle("/change_password", ctrl.H(ctrl.ServeChangePassword))
routAdmin.Handle("/change_password_do", ctrl.H(ctrl.ServeChangePasswordDo))
routAdmin.Handle("/change_avatar", ctrl.H(ctrl.ServeChangeAvatar))
routAdmin.Handle("/change_avatar_do", ctrl.H(ctrl.ServeChangeAvatarDo))
routAdmin.Handle("/delete_avatar_do", ctrl.H(ctrl.ServeDeleteAvatarDo))
routAdmin.Handle("/delete_user", ctrl.H(ctrl.ServeDeleteUser))
routAdmin.Handle("/delete_user_do", ctrl.H(ctrl.ServeDeleteUserDo))
routAdmin.Handle("/create_user", ctrl.H(ctrl.ServeCreateUser))
routAdmin.Handle("/create_user_do", ctrl.H(ctrl.ServeCreateUserDo))
routAdmin.Handle("/update_lastfm_api_key", ctrl.H(ctrl.ServeUpdateLastFMAPIKey))