embed html template and static files

This commit is contained in:
ari melody 2025-09-30 19:03:35 +01:00
parent b150fa491c
commit e5dcc4b884
Signed by: ari
GPG key ID: CF99829C92678188
44 changed files with 316 additions and 255 deletions

View file

@ -0,0 +1,47 @@
<dialog id="addcredit">
<header>
<h2>Add Artist Credit</h2>
</header>
<ul>
{{range $Artist := .Artists}}
<li class="new-artist"
data-id="{{$Artist.ID}}"
hx-get="/admin/release/{{$.ReleaseID}}/newcredit/{{$Artist.ID}}"
hx-target="#editcredits ul"
hx-swap="beforeend"
>
<img src="{{$Artist.GetAvatar}}" alt="" width="16" loading="lazy" class="artist-avatar">
<span class="artist-name">{{$Artist.Name}} <span class="artist-id">({{$Artist.ID}})</span></span>
</li>
{{end}}
</ul>
{{if not .Artists}}
<p class="empty">There are no more artists to add.</p>
{{end}}
<div class="dialog-actions">
<button id="cancel" type="button">Cancel</button>
</div>
<script type="text/javascript">
(() => {
const newCreditModal = document.getElementById("addcredit")
const editCreditsModal = document.getElementById("editcredits")
const cancelBtn = newCreditModal.querySelector("#cancel");
editCreditsModal.addEventListener("htmx:afterSwap", () => {
newCreditModal.close();
newCreditModal.remove();
});
cancelBtn.addEventListener("click", () => {
newCreditModal.close();
newCreditModal.remove();
});
newCreditModal.showModal();
})();
</script>
</dialog>

View file

@ -0,0 +1,114 @@
<dialog id="editcredits">
<header>
<h2>Editing: Credits</h2>
<a id="add-credit"
class="button new"
href="/admin/release/{{.ID}}/addcredit"
hx-get="/admin/release/{{.ID}}/addcredit"
hx-target="body"
hx-swap="beforeend"
>Add</a>
</header>
<form action="/api/v1/music/{{.ID}}/credits">
<ul>
{{range .Credits}}
<li class="credit" data-artist="{{.Artist.ID}}">
<div>
<img src="{{.Artist.GetAvatar}}" alt="" width="64" loading="lazy" class="artist-avatar">
<div class="credit-info">
<p class="artist-name">{{.Artist.Name}}</p>
<div class="credit-attribute">
<label for="role">Role:</label>
<input type="text" name="role" value="{{.Role}}">
</div>
<div class="credit-attribute">
<label for="primary">Primary:</label>
<input type="checkbox" name="primary" {{if .Primary}}checked{{end}}>
</div>
</div>
<a class="delete">Delete</a>
</div>
</li>
{{end}}
</ul>
<div class="dialog-actions">
<button id="discard" type="button">Discard</button>
<button id="save" type="submit" class="save">Save</button>
</div>
</form>
<script type="module">
import { makeMagicList } from "/admin/static/admin.js";
(() => {
const container = document.getElementById("editcredits");
const form = document.querySelector("#editcredits form");
const creditList = form.querySelector("ul");
const addCreditBtn = document.getElementById("add-credit");
const discardBtn = form.querySelector("button#discard");
makeMagicList(creditList, ".credit");
function rigCredit(el) {
const artistID = el.dataset.artist;
const deleteBtn = el.querySelector("a.delete");
deleteBtn.addEventListener("click", e => {
if (!confirm("Are you sure you want to delete " + artistID + "'s credit?")) return;
el.remove();
});
el.draggable = true;
el.addEventListener("dragstart", () => { el.classList.add("moving") });
el.addEventListener("dragend", () => { el.classList.remove("moving") });
}
[...creditList.querySelectorAll(".credit")].map(rigCredit);
creditList.addEventListener("htmx:afterSwap", () => {
rigCredit(creditList.children[creditList.children.length - 1]);
});
container.showModal();
container.addEventListener("close", () => {
container.remove();
});
form.addEventListener("submit", e => {
const credits = [...creditList.querySelectorAll(".credit")].map(el => {
return {
"artist": el.dataset.artist,
"role": el.querySelector(`input[name="role"]`).value,
"primary": el.querySelector(`input[name="primary"]`).checked,
};
});
e.preventDefault();
fetch(form.action, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(credits)
}).then(res => {
if (res.ok) location = location;
else {
res.text().then(err => {
alert(err);
console.error(err);
});
}
}).catch(err => {
alert("Failed to update credits. Check the console for details.");
console.error(err);
});
});
discardBtn.addEventListener("click", e => {
e.preventDefault();
container.close();
});
})();
</script>
</dialog>

View file

@ -0,0 +1,17 @@
<li class="credit" data-artist="{{.ID}}">
<div>
<img src="{{.GetAvatar}}" alt="" width="64" loading="lazy" class="artist-avatar">
<div class="credit-info">
<p class="artist-name">{{.Name}}</p>
<div class="credit-attribute">
<label for="role">Role:</label>
<input type="text" name="role" value="">
</div>
<div class="credit-attribute">
<label for="primary">Primary:</label>
<input type="checkbox" name="primary">
</div>
</div>
<a class="delete">Delete</a>
</div>
</li>

View file

@ -0,0 +1,159 @@
<dialog id="editlinks">
<header>
<h2>Editing: Links</h2>
<button id="add-link" class="button new">Add</button>
</header>
<form action="/api/v1/music/{{.ID}}/links">
<table>
<tr>
<th class="grabber"></th>
<th class="link-name">Name</th>
<th class="link-url">URL</th>
<th></th>
</tr>
{{range .Links}}
<tr class="link">
<td class="grabber"><img src="/img/list-grabber.svg"/></td>
<td class="link-name">
<input type="text" name="name" value="{{.Name}}">
</td>
<td class="link-url">
<input type="text" name="url" value="{{.URL}}">
</td>
<td>
<a class="delete">Delete</a>
</td>
</tr>
{{end}}
</table>
<div class="dialog-actions">
<button id="discard" type="button">Discard</button>
<button id="save" type="submit" class="save">Save</button>
</div>
</form>
<script type="module">
import { makeMagicList } from "/admin/static/admin.js";
(() => {
const container = document.getElementById("editlinks");
const form = document.querySelector("#editlinks form");
const linkTable = form.querySelector("table tbody");
const addLinkBtn = document.getElementById("add-link");
const discardBtn = form.querySelector("button#discard");
makeMagicList(linkTable, "tr.link");
function rigLinkItem(el) {
const nameInput = el.querySelector(`input[name="name"]`)
const deleteBtn = el.querySelector("a.delete");
deleteBtn.addEventListener("click", e => {
e.preventDefault();
if (nameInput.value != "" &&
!confirm("Are you sure you want to delete \"" + nameInput.value + "\"?"))
return;
el.remove();
});
}
[...linkTable.querySelectorAll("tr.link")].map(rigLinkItem);
addLinkBtn.addEventListener("click", e => {
e.preventDefault();
const row = document.createElement("tr");
row.className = "link";
const grabberCell = document.createElement("td");
grabberCell.className = "grabber";
const grabberImg = document.createElement("img");
grabberImg.src = "/img/list-grabber.svg";
grabberCell.appendChild(grabberImg);
row.appendChild(grabberCell);
const nameCell = document.createElement("td");
nameCell.className = "link-name";
const nameInput = document.createElement("input");
nameInput.type = "text";
nameInput.name = "name";
nameCell.appendChild(nameInput);
row.appendChild(nameCell);
const urlCell = document.createElement("td");
urlCell.className = "link-url";
const urlInput = document.createElement("input");
urlInput.type = "text";
urlInput.name = "url";
urlCell.appendChild(urlInput);
row.appendChild(urlCell);
const deleteCell = document.createElement("td");
const deleteBtn = document.createElement("a");
deleteBtn.className = "delete";
deleteBtn.innerText = "Delete";
deleteCell.appendChild(deleteBtn);
row.appendChild(deleteCell);
linkTable.appendChild(row);
row.draggable = true;
row.addEventListener("dragstart", () => { row.classList.add("moving") });
row.addEventListener("dragend", () => { row.classList.remove("moving") });
row.querySelectorAll("input").forEach(el => {
el.addEventListener("mousedown", () => { row.draggable = false });
el.addEventListener("mouseup", () => { row.draggable = true });
el.addEventListener("dragstart", e => { e.stopPropagation() });
});
deleteBtn.addEventListener("click", e => {
e.preventDefault();
if (nameInput.value != "" && !confirm("Are you sure you want to delete \"" + nameInput.value + "\"?")) return;
row.remove();
});
});
container.showModal();
container.addEventListener("close", () => {
container.remove();
});
form.addEventListener("submit", e => {
var links = [];
[...linkTable.querySelectorAll("tr.link")].map(el => {
const name = el.querySelector(`input[name="name"]`).value;
const url = el.querySelector(`input[name="url"]`).value;
if (name == "" || url == "") return;
links.push({
"name": name,
"url": url,
});
})
e.preventDefault();
fetch(form.action, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(links)
}).then(res => {
if (res.ok) location = location;
else {
res.text().then(err => {
alert(err);
console.error(err);
});
}
}).catch(err => {
alert("Failed to update links. Check the console for details.");
console.error(err);
});
});
discardBtn.addEventListener("click", e => {
e.preventDefault();
container.close();
});
})();
</script>
</dialog>

View file

@ -0,0 +1,23 @@
{{define "release"}}
<div class="release">
<div class="release-artwork">
<img src="{{.GetArtwork}}" alt="" width="128" loading="lazy">
</div>
<div class="release-info">
<h3 class="release-title">
<a href="/admin/release/{{.ID}}">{{.Title}}</a>
<small>
<span title="{{.PrintReleaseDate}}">{{.ReleaseDate.Year}}</span>
{{if not .Visible}}(hidden){{end}}
</small>
</h3>
<p class="release-artists">{{.PrintArtists true true}}</p>
<p class="release-type-single">{{.ReleaseType}}
(<a href="/admin/release/{{.ID}}#tracks">{{len .Tracks}} track{{if not (eq (len .Tracks) 1)}}s{{end}}</a>)</p>
<div class="release-actions">
<a href="/admin/release/{{.ID}}">Edit</a>
<a href="/music/{{.ID}}" target="_blank">Gateway <img class="icon" src="/img/external-link.svg"/></a>
</div>
</div>
</div>
{{end}}

View file

@ -0,0 +1,47 @@
<dialog id="addtrack">
<header>
<h2>Add Track</h2>
</header>
<ul>
{{range $Track := .Tracks}}
</li>
<li class="new-track"
data-id="{{$Track.ID}}"
hx-get="/admin/release/{{$.ReleaseID}}/newtrack/{{$Track.ID}}"
hx-target="#edittracks ul"
hx-swap="beforeend"
>
{{.Title}}
</li>
{{end}}
</ul>
{{if not .Tracks}}
<p class="empty">There are no more tracks to add.</p>
{{end}}
<div class="dialog-actions">
<button id="cancel" type="button">Cancel</button>
</div>
<script type="text/javascript">
(() => {
const newTrackModal = document.getElementById("addtrack")
const editTracksModal = document.getElementById("edittracks")
const cancelBtn = newTrackModal.querySelector("#cancel");
editTracksModal.addEventListener("htmx:afterSwap", () => {
newTrackModal.close();
newTrackModal.remove();
});
cancelBtn.addEventListener("click", () => {
newTrackModal.close();
newTrackModal.remove();
});
newTrackModal.showModal();
})();
</script>
</dialog>

View file

@ -0,0 +1,112 @@
<dialog id="edittracks">
<header>
<h2>Editing: Tracks</h2>
<a id="add-track"
class="button new"
href="/admin/release/{{.Release.ID}}/addtrack"
hx-get="/admin/release/{{.Release.ID}}/addtrack"
hx-target="body"
hx-swap="beforeend"
>Add</a>
</header>
<form action="/api/v1/music/{{.Release.ID}}/tracks">
<ul>
{{range $i, $track := .Release.Tracks}}
<li class="track" data-track="{{$track.ID}}" data-title="{{$track.Title}}" data-number="{{$track.Add $i 1}}" draggable="true">
<div>
<p class="track-name">
<span class="track-number">{{.Add $i 1}}</span>
{{$track.Title}}
</p>
<a class="delete">Delete</a>
</div>
</li>
{{end}}
</ul>
<div class="dialog-actions">
<button id="discard" type="button">Discard</button>
<button id="save" type="submit" class="save">Save</button>
</div>
</form>
<script type="module">
import { makeMagicList } from "/admin/static/admin.js";
(() => {
const container = document.getElementById("edittracks");
const form = document.querySelector("#edittracks form");
const trackList = form.querySelector("ul");
const addTrackBtn = document.getElementById("add-track");
const discardBtn = form.querySelector("button#discard");
makeMagicList(trackList, ".track", refreshTrackNumbers);
function rigTrackItem(trackItem) {
const trackID = trackItem.dataset.track;
const trackTitle = trackItem.dataset.title;
const deleteBtn = trackItem.querySelector("a.delete");
deleteBtn.addEventListener("click", e => {
e.preventDefault();
if (!confirm("Are you sure you want to remove " + trackTitle + "?")) return;
trackItem.remove();
refreshTrackNumbers();
});
}
function refreshTrackNumbers() {
trackList.querySelectorAll("li").forEach((trackItem, i) => {
trackItem.querySelector(".track-number").innerText = i + 1;
});
}
trackList.addEventListener("htmx:afterSwap", e => {
const trackItem = trackList.children[trackList.children.length - 1];
trackList.appendChild(trackItem);
trackItem.addEventListener("dragstart", () => { trackItem.classList.add("moving") });
trackItem.addEventListener("dragend", () => { trackItem.classList.remove("moving") });
rigTrackItem(trackItem);
refreshTrackNumbers();
});
trackList.querySelectorAll("li").forEach(trackItem => {
rigTrackItem(trackItem);
});
container.showModal();
container.addEventListener("close", () => {
container.remove();
});
form.addEventListener("submit", e => {
e.preventDefault();
let tracks = [...trackList.querySelectorAll(".track")].map(trackItem => trackItem.dataset.track);
fetch(form.action, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(tracks)
}).then(res => {
if (res.ok) location = location;
else {
res.text().then(err => {
alert(err);
console.error(err);
});
}
}).catch(err => {
alert("Failed to update tracks. Check the console for details.");
console.error(err);
});
});
discardBtn.addEventListener("click", e => {
e.preventDefault();
container.close();
});
})();
</script>
</dialog>

View file

@ -0,0 +1,9 @@
<li class="track" data-track="{{.ID}}" data-title="{{.Title}}" data-number="0" draggable="true">
<div>
<p class="track-name">
<span class="track-number">0</span>
{{.Title}}
</p>
<a class="delete">Delete</a>
</div>
</li>

View file

@ -0,0 +1,84 @@
{{define "head"}}
<title>Account Settings - ari melody 💫</title>
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
<link rel="stylesheet" href="/admin/static/edit-account.css">
{{end}}
{{define "content"}}
<main>
{{if .Session.Message.Valid}}
<p id="message">{{html .Session.Message.String}}</p>
{{end}}
{{if .Session.Error.Valid}}
<p id="error">{{html .Session.Error.String}}</p>
{{end}}
<h1>Account Settings ({{.Session.Account.Username}})</h1>
<div class="card-title">
<h2>Change Password</h2>
</div>
<div class="card">
<form action="/admin/account/password" method="POST" id="change-password">
<label for="current-password">Current Password</label>
<input type="password" id="current-password" name="current-password" value="" autocomplete="current-password" required>
<label for="new-password">New Password</label>
<input type="password" id="new-password" name="new-password" value="" autocomplete="new-password" required>
<label for="confirm-password">Confirm Password</label>
<input type="password" id="confirm-password" value="" autocomplete="new-password" required>
<button type="submit" class="save">Change Password</button>
</form>
</div>
<div class="card-title">
<h2>MFA Devices</h2>
</div>
<div class="card mfa-devices">
{{if .TOTPs}}
{{range .TOTPs}}
<div class="mfa-device">
<div>
<p class="mfa-device-name">{{.TOTP.Name}}</p>
<p class="mfa-device-date">Added: {{.CreatedAtString}}</p>
</div>
<div>
<a class="button delete" href="/admin/account/totp-delete/{{.TOTP.Name}}">Delete</a>
</div>
</div>
{{end}}
{{else}}
<p>You have no MFA devices.</p>
{{end}}
<div>
<button type="submit" class="save" id="enable-email" disabled>Enable Email TOTP</button>
<a class="button new" id="add-totp-device" href="/admin/account/totp-setup">Add TOTP Device</a>
</div>
</div>
<div class="card-title">
<h2>Danger Zone</h2>
</div>
<div class="card danger">
<p>
Clicking the button below will delete your account.
This action is <strong>irreversible</strong>.
You will need to enter your password and TOTP below.
</p>
<form action="/admin/account/delete" method="POST">
<label for="password">Password</label>
<input type="password" name="password" value="" autocomplete="current-password" required>
<label for="totp">TOTP</label>
<input type="text" name="totp" value="" autocomplete="one-time-code" required>
<button type="submit" class="delete">Delete Account</button>
</form>
</div>
</main>
<script type="module" src="/admin/static/edit-account.js" defer></script>
{{end}}

View file

@ -0,0 +1,72 @@
{{define "head"}}
<title>Editing {{.Artist.Name}} - ari melody 💫</title>
<link rel="shortcut icon" href="{{.Artist.GetAvatar}}" type="image/x-icon">
<link rel="stylesheet" href="/admin/static/edit-artist.css">
{{end}}
{{define "content"}}
<main>
<h1>Editing Artist</h1>
<div id="artist" data-id="{{.Artist.ID}}">
<div class="artist-avatar">
<img src="{{.Artist.Avatar}}" alt="" width="256" loading="lazy" id="avatar">
<input type="file" id="avatar-file" name="Artwork" accept=".png,.jpg,.jpeg" hidden>
<button id="remove-avatar">Remove</button>
</div>
<div class="artist-info">
<p class="attribute-header">Name</p>
<h2 class="artist-name">
<input type="text" id="name" name="artist-name" value="{{.Artist.Name}}">
</h2>
<p class="attribute-header">Website</p>
<input type="text" id="website" name="website" value="{{.Artist.Website}}">
<div class="artist-actions">
<button type="submit" class="save" id="save" disabled>Save</button>
</div>
</div>
</div>
<div class="card-title">
<h2>Featured in</h2>
</div>
<div class="card releases">
{{if .Credits}}
{{range .Credits}}
<div class="credit">
<img src="{{.Release.Artwork}}" alt="" width="64" loading="lazy" class="release-artwork">
<div class="credit-info">
<h3 class="credit-name"><a href="/admin/release/{{.Release.ID}}">{{.Release.Title}}</a></h3>
<p class="credit-artists">{{.Release.PrintArtists true true}}</p>
<p class="artist-role">
Role: {{.Role}}
{{if .Primary}}
<small>(Primary)</small>
{{end}}
</p>
</div>
</div>
{{end}}
{{else}}
<p>This artist has no credits.</p>
{{end}}
</div>
<div class="card-title">
<h2>Danger Zone</h2>
</div>
<div class="card danger">
<p>
Clicking the button below will delete this artist.
This action is <strong>irreversible</strong>.
You will be prompted to confirm this decision.
</p>
<button class="delete" id="delete">Delete Artist</button>
</div>
</main>
<script type="module" src="/admin/static/edit-artist.js" defer></script>
{{end}}

View file

@ -0,0 +1,193 @@
{{define "head"}}
<title>Editing {{.Release.Title}} - ari melody 💫</title>
<link rel="shortcut icon" href="{{.Release.GetArtwork}}" type="image/x-icon">
<link rel="stylesheet" href="/admin/static/edit-release.css">
{{end}}
{{define "content"}}
<main>
<div id="release" data-id="{{.Release.ID}}">
<div class="release-artwork">
<img src="{{.Release.Artwork}}" alt="" width="256" loading="lazy" id="artwork">
<input type="file" id="artwork-file" name="Artwork" accept=".png,.jpg,.jpeg" hidden>
<button id="remove-artwork">Remove</button>
</div>
<div class="release-info">
<h1 class="release-title">
<input type="text" id="title" name="Title" value="{{.Release.Title}}" autocomplete="on">
</h1>
<table>
<tr>
<td>Type</td>
<td>
{{$t := .Release.ReleaseType}}
<select name="Type" id="type">
<option value="single" {{if eq $t "single"}}selected{{end}}>
Single
</option>
<option value="album" {{if eq $t "album"}}selected{{end}}>
Album
</option>
<option value="ep" {{if eq $t "ep"}}selected{{end}}>
EP
</option>
<option value="compilation" {{if eq $t "compilation"}}selected{{end}}>
Compilation
</option>
</select>
</td>
</tr>
<tr>
<td>Description</td>
<td>
<textarea
name="Description"
value="{{.Release.Description}}"
placeholder="No description provided."
rows="3"
id="description"
>{{.Release.Description}}</textarea>
</td>
</tr>
<tr>
<td>Release Date</td>
<td>
<input type="datetime-local" name="release-date" id="release-date" value="{{.Release.TextReleaseDate}}">
</td>
</tr>
<tr>
<td>Buy Name</td>
<td>
<input type="text" name="buyname" id="buyname" value="{{.Release.Buyname}}" autocomplete="on">
</td>
</tr>
<tr>
<td>Buy Link</td>
<td>
<input type="text" name="buylink" id="buylink" value="{{.Release.Buylink}}" autocomplete="on">
</td>
</tr>
<tr>
<td>Copyright</td>
<td>
<input type="text" name="copyright" id="copyright" value="{{.Release.Copyright}}" autocomplete="on">
</td>
</tr>
<tr>
<td>Copyright URL</td>
<td>
<input type="text" name="copyright-url" id="copyright-url" value="{{.Release.CopyrightURL}}" autocomplete="on">
</td>
</tr>
<tr>
<td>Visible</td>
<td>
<select name="Visibility" id="visibility">
<option value="true" {{if .Release.Visible}}selected{{end}}>True</option>
<option value="false" {{if not .Release.Visible}}selected{{end}}>False</option>
</select>
</td>
</tr>
</table>
<div class="release-actions">
<a href="/music/{{.Release.ID}}" class="button" target="_blank">Gateway <img class="icon" src="/img/external-link.svg"/></a>
<button type="submit" class="save" id="save" disabled>Save</button>
</div>
</div>
</div>
<div class="card-title">
<h2>Credits ({{len .Release.Credits}})</h2>
<a class="button edit"
href="/admin/release/{{.Release.ID}}/editcredits"
hx-get="/admin/release/{{.Release.ID}}/editcredits"
hx-target="body"
hx-swap="beforeend"
>Edit</a>
</div>
<div class="card credits">
{{range .Release.Credits}}
<div class="credit">
<img src="{{.Artist.GetAvatar}}" alt="" width="64" loading="lazy" class="artist-avatar">
<div class="credit-info">
<p class="artist-name"><a href="/admin/artist/{{.Artist.ID}}">{{.Artist.Name}}</a></p>
<p class="artist-role">
{{.Role}}
{{if .Primary}}
<small>(Primary)</small>
{{end}}
</p>
</div>
</div>
{{end}}
{{if not .Release.Credits}}
<p>There are no credits.</p>
{{end}}
</div>
<div class="card-title">
<h2>Links ({{len .Release.Links}})</h2>
<a class="button edit"
href="/admin/release/{{.Release.ID}}/editlinks"
hx-get="/admin/release/{{.Release.ID}}/editlinks"
hx-target="body"
hx-swap="beforeend"
>Edit</a>
</div>
<div class="card links">
{{range .Release.Links}}
<a href="{{.URL}}" target="_blank" class="button" data-name="{{.Name}}">{{.Name}} <img class="icon" src="/img/external-link.svg"/></a>
{{end}}
</div>
<div class="card-title" id="tracks">
<h2>Tracklist ({{len .Release.Tracks}})</h2>
<a class="button edit"
href="/admin/release/{{.Release.ID}}/edittracks"
hx-get="/admin/release/{{.Release.ID}}/edittracks"
hx-target="body"
hx-swap="beforeend"
>Edit</a>
</div>
<div class="card tracks">
{{range $i, $track := .Release.Tracks}}
<div class="track" data-id="{{$track.ID}}">
<h2 class="track-title">
<span class="track-number">{{.Add $i 1}}</span>
<a href="/admin/track/{{$track.ID}}">{{$track.Title}}</a>
</h2>
<h3>Description</h3>
{{if $track.Description}}
<p class="track-description">{{$track.GetDescriptionHTML}}</p>
{{else}}
<p class="track-description empty">No description provided.</p>
{{end}}
<h3>Lyrics</h3>
{{if $track.Lyrics}}
<p class="track-lyrics">{{$track.GetLyricsHTML}}</p>
{{else}}
<p class="track-lyrics empty">There are no lyrics.</p>
{{end}}
</div>
{{end}}
</div>
<div class="card-title">
<h2>Danger Zone</h2>
</div>
<div class="card danger">
<p>
Clicking the button below will delete this release.
This action is <strong>irreversible</strong>.
You will be prompted to confirm this decision.
</p>
<button class="delete" id="delete">Delete Release</button>
</div>
</main>
<script type="module" src="/admin/static/edit-release.js" defer></script>
{{end}}

View file

@ -0,0 +1,70 @@
{{define "head"}}
<title>Editing Track - ari melody 💫</title>
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
<link rel="stylesheet" href="/admin/static/edit-track.css">
{{end}}
{{define "content"}}
<main>
<h1>Editing Track</h1>
<div id="track" data-id="{{.Track.ID}}">
<div class="track-info">
<p class="attribute-header">Title</p>
<h2 class="track-title">
<input type="text" id="title" name="Title" value="{{.Track.Title}}">
</h2>
<p class="attribute-header">Description</p>
<textarea
name="Description"
value="{{.Track.Description}}"
placeholder="No description provided."
rows="5"
id="description"
>{{.Track.Description}}</textarea>
<p class="attribute-header">Lyrics</p>
<textarea
name="Lyrics"
value="{{.Track.Lyrics}}"
placeholder="There are no lyrics."
rows="5"
id="lyrics"
>{{.Track.Lyrics}}</textarea>
<div class="track-actions">
<button type="submit" class="save" id="save" disabled>Save</button>
</div>
</div>
</div>
<div class="card-title">
<h2>Featured in</h2>
</div>
<div class="card releases">
{{if .Releases}}
{{range .Releases}}
{{block "release" .}}{{end}}
{{end}}
{{else}}
<p>This track isn't bound to a release.</p>
{{end}}
</div>
<div class="card-title">
<h2>Danger Zone</h2>
</div>
<div class="card danger">
<p>
Clicking the button below will delete this track.
This action is <strong>irreversible</strong>.
You will be prompted to confirm this decision.
</p>
<button class="delete" id="delete">Delete Track</button>
</div>
</main>
<script type="module" src="/admin/static/edit-track.js" defer></script>
{{end}}

View file

@ -0,0 +1,72 @@
{{define "head"}}
<title>Admin - ari melody 💫</title>
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
<link rel="stylesheet" href="/admin/static/index.css">
{{end}}
{{define "content"}}
<main>
<div class="card-title">
<h1>Releases</h1>
<a class="button new" id="create-release">Create New</a>
</div>
<div class="card releases">
{{range .Releases}}
{{block "release" .}}{{end}}
{{end}}
{{if not .Releases}}
<p>There are no releases.</p>
{{end}}
</div>
<div class="card-title">
<h1>Artists</h1>
<a class="button new" id="create-artist">Create New</a>
</div>
<div class="card artists">
{{range $Artist := .Artists}}
<div class="artist">
<img src="{{$Artist.GetAvatar}}" alt="" width="64" loading="lazy" class="artist-avatar">
<a href="/admin/artist/{{$Artist.ID}}" class="artist-name">{{$Artist.Name}}</a>
</div>
{{end}}
{{if not .Artists}}
<p>There are no artists.</p>
{{end}}
</div>
<div class="card-title">
<h1>Tracks</h1>
<a class="button new" id="create-track">Create New</a>
</div>
<div class="card tracks">
<p><em>"Orphaned" tracks that have not yet been bound to a release.</em></p>
<br>
{{range $Track := .Tracks}}
<div class="track">
<h2 class="track-title">
<a href="/admin/track/{{$Track.ID}}">{{$Track.Title}}</a>
</h2>
{{if $Track.Description}}
<p class="track-description">{{$Track.GetDescriptionHTML}}</p>
{{else}}
<p class="track-description empty">No description provided.</p>
{{end}}
{{if $Track.Lyrics}}
<p class="track-lyrics">{{$Track.GetLyricsHTML}}</p>
{{else}}
<p class="track-lyrics empty">There are no lyrics.</p>
{{end}}
</div>
{{end}}
{{if not .Artists}}
<p>There are no artists.</p>
{{end}}
</div>
</main>
<script type="module" src="/admin/static/admin.js"></script>
<script type="module" src="/admin/static/index.js"></script>
{{end}}

View file

@ -0,0 +1,54 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta http-equiv="content-type" content="text/html; charset=UTF-8">
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{{block "head" .}}{{end}}
<link rel="stylesheet" href="/admin/static/admin.css">
<script type="module" src="/script/vendor/htmx.min.js"></script>
</head>
<body>
<header>
<nav>
<img src="/img/favicon.png" alt="" class="icon">
<div class="nav-item">
<a href="/">ari melody</a>
</div>
<div class="nav-item">
<a href="/admin">home</a>
</div>
{{if .Session.Account}}
<div class="nav-item">
<a href="/admin/logs">logs</a>
</div>
{{end}}
<div class="flex-fill"></div>
{{if .Session.Account}}
<div class="nav-item">
<a href="/admin/account">account ({{.Session.Account.Username}})</a>
</div>
<div class="nav-item">
<a href="/admin/logout" id="logout">log out</a>
</div>
{{else}}
<div class="nav-item">
<a href="/admin/register" id="register">create account</a>
</div>
{{end}}
</nav>
</header>
{{block "content" .}}{{end}}
{{template "prideflag"}}
</body>
</html>

View file

@ -0,0 +1,47 @@
{{define "head"}}
<title>Login - ari melody 💫</title>
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
<link rel="stylesheet" href="/admin/static/admin.css">
<style>
form#login-totp {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
form div {
width: 20rem;
}
form button {
margin-top: 1rem;
}
input {
width: calc(100% - 1rem - 2px);
}
</style>
{{end}}
{{define "content"}}
<main>
{{if .Session.Message.Valid}}
<p id="message">{{html .Session.Message.String}}</p>
{{end}}
{{if .Session.Error.Valid}}
<p id="error">{{html .Session.Error.String}}</p>
{{end}}
<form action="/admin/totp" method="POST" id="login-totp">
<h1>Two-Factor Authentication</h1>
<div>
<label for="totp">TOTP</label>
<input type="text" name="totp" value="" autocomplete="one-time-code" required autofocus>
</div>
<button type="submit" class="save">Login</button>
</form>
</main>
{{end}}

View file

@ -0,0 +1,50 @@
{{define "head"}}
<title>Login - ari melody 💫</title>
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
<link rel="stylesheet" href="/admin/static/admin.css">
<style>
form#login {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
form div {
width: 20rem;
}
form button {
margin-top: 1rem;
}
input {
width: calc(100% - 1rem - 2px);
}
</style>
{{end}}
{{define "content"}}
<main>
{{if .Session.Message.Valid}}
<p id="message">{{html .Session.Message.String}}</p>
{{end}}
{{if .Session.Error.Valid}}
<p id="error">{{html .Session.Error.String}}</p>
{{end}}
<form action="/admin/login" method="POST" id="login">
<h1>Log In</h1>
<div>
<label for="username">Username</label>
<input type="text" name="username" value="" autocomplete="username" required autofocus>
<label for="password">Password</label>
<input type="password" name="password" value="" autocomplete="current-password" required>
</div>
<button type="submit" class="save">Login</button>
</form>
</main>
{{end}}

View file

@ -0,0 +1,22 @@
{{define "head"}}
<title>Admin - ari melody 💫</title>
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
<style>
p a {
color: #2a67c8;
}
</style>
{{end}}
{{define "content"}}
<main>
<meta http-equiv="refresh" content="0;url=/admin/login" />
<p>
Logged out successfully.
You should be redirected to <a href="/admin/login">/admin/login</a> shortly.
</p>
</main>
{{end}}

View file

@ -0,0 +1,68 @@
{{define "head"}}
<title>Audit Logs - ari melody 💫</title>
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
<link rel="stylesheet" href="/admin/static/admin.css">
<link rel="stylesheet" href="/admin/static/logs.css">
{{end}}
{{define "content"}}
<main>
<h1>Audit Logs</h1>
<form action="/admin/logs" method="GET">
<div id="search">
<input type="text" name="q" value="" placeholder="Filter by message...">
<button type="submit" class="save">Search</button>
</div>
<div id="filters">
<div>
<p>Level:</p>
<label for="level-info">Info</label>
<input type="checkbox" name="level-info" id="level-info">
<label for="level-warn">Warning</label>
<input type="checkbox" name="level-warn" id="level-warn">
</div>
<div>
<p>Type:</p>
<label for="type-account">Account</label>
<input type="checkbox" name="type-account" id="type-account">
<label for="type-music">Music</label>
<input type="checkbox" name="type-music" id="type-music">
<label for="type-artist">Artist</label>
<input type="checkbox" name="type-artist" id="type-artist">
<label for="type-blog">Blog</label>
<input type="checkbox" name="type-blog" id="type-blog">
<label for="type-artwork">Artwork</label>
<input type="checkbox" name="type-artwork" id="type-artwork">
<label for="type-files">Files</label>
<input type="checkbox" name="type-files" id="type-files">
<label for="type-misc">Misc</label>
<input type="checkbox" name="type-misc" id="type-misc">
</div>
</div>
</form>
<hr>
<table id="logs">
<thead>
<tr>
<th class="log-time">Time</th>
<th class="log-level">Level</th>
<th class="log-type">Type</th>
<th class="log-content">Message</th>
</tr>
</thead>
<tbody>
{{range .Logs}}
<tr class="log {{lower (parseLevel .Level)}}">
<td class="log-time">{{prettyTime .CreatedAt}}</td>
<td class="log-level">{{parseLevel .Level}}</td>
<td class="log-type">{{titleCase .Type}}</td>
<td class="log-content">{{.Content}}</td>
</tr>
{{end}}
</tbody>
</table>
</main>
{{end}}

View file

@ -0,0 +1,21 @@
{{define "prideflag"}}
<a href="https://github.com/arimelody/prideflag" target="_blank" id="prideflag">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" hx-preserve="true">
<path id="red" d="M120,80 L100,100 L120,120 Z" style="fill:#d20605"/>
<path id="orange" d="M120,80 V40 L80,80 L100,100 Z" style="fill:#ef9c00"/>
<path id="yellow" d="M120,40 V0 L60,60 L80,80 Z" style="fill:#e5fe02"/>
<path id="green" d="M120,0 H80 L40,40 L60,60 Z" style="fill:#09be01"/>
<path id="blue" d="M80,0 H40 L20,20 L40,40 Z" style="fill:#081a9a"/>
<path id="purple" d="M40,0 H0 L20,20 Z" style="fill:#76008a"/>
<rect id="black" x="60" width="60" height="60" style="fill:#010101"/>
<rect id="brown" x="70" width="50" height="50" style="fill:#603814"/>
<rect id="lightblue" x="80" width="40" height="40" style="fill:#73d6ed"/>
<rect id="pink" x="90" width="30" height="30" style="fill:#ffafc8"/>
<rect id="white" x="100" width="20" height="20" style="fill:#fff"/>
<rect id="intyellow" x="110" width="10" height="10" style="fill:#fed800"/>
<circle id="intpurple" cx="120" cy="0" r="5" stroke="#7601ad" stroke-width="2" fill="none"/>
</svg>
</a>
{{end}}

View file

@ -0,0 +1,61 @@
{{define "head"}}
<title>Register - ari melody 💫</title>
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
<link rel="stylesheet" href="/admin/static/admin.css">
<style>
p a {
color: #2a67c8;
}
a.discord {
color: #5865F2;
}
form#register {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
form div {
width: 20rem;
}
form button {
margin-top: 1rem;
}
input {
width: calc(100% - 1rem - 2px);
}
</style>
{{end}}
{{define "content"}}
<main>
{{if .Session.Error.Valid}}
<p id="error">{{html .Session.Error.String}}</p>
{{end}}
<form action="/admin/register" method="POST" id="register">
<h1>Create Account</h1>
<div>
<label for="username">Username</label>
<input type="text" name="username" value="" autocomplete="username" required autofocus>
<label for="email">Email</label>
<input type="text" name="email" value="" autocomplete="email" required>
<label for="password">Password</label>
<input type="password" name="password" value="" autocomplete="new-password" required>
<label for="invite">Invite Code</label>
<input type="text" name="invite" value="" autocomplete="off" required>
</div>
<button type="submit" class="new">Create Account</button>
</form>
</main>
{{end}}

View file

@ -0,0 +1,48 @@
{{define "head"}}
<title>TOTP Confirmation - ari melody 💫</title>
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
<link rel="stylesheet" href="/admin/static/admin.css">
<style>
.qr-code {
border: 1px solid #8888;
}
code {
user-select: all;
}
</style>
{{end}}
{{define "content"}}
<main>
{{if .Session.Error.Valid}}
<p id="error">{{html .Session.Error.String}}</p>
{{end}}
<form action="/admin/account/totp-confirm?totp-name={{.NameEscaped}}" method="POST" id="totp-setup">
{{if .QRBase64Image}}
<img src="data:image/png;base64,{{.QRBase64Image}}" alt="" class="qr-code">
<p>
Scan the QR code above into your authentication app or password manager,
then enter your 2FA code below.
</p>
<p>
If the QR code does not work, you may also enter this secret code:
</p>
{{else}}
<p>
Paste the below secret code into your authentication app or password manager,
then enter your 2FA code below:
</p>
{{end}}
<p><code>{{.TOTP.Secret}}</code></p>
<label for="totp">TOTP:</label>
<input type="text" name="totp" value="" autocomplete="one-time-code" required autofocus>
<button type="submit" class="new">Create</button>
</form>
</main>
{{end}}

View file

@ -0,0 +1,20 @@
{{define "head"}}
<title>TOTP Setup - ari melody 💫</title>
<link rel="shortcut icon" href="/img/favicon.png" type="image/x-icon">
<link rel="stylesheet" href="/admin/static/admin.css">
{{end}}
{{define "content"}}
<main>
{{if .Session.Error.Valid}}
<p id="error">{{html .Session.Error.String}}</p>
{{end}}
<form action="/admin/account/totp-setup" method="POST" id="totp-setup">
<label for="totp-name">TOTP Device Name:</label>
<input type="text" name="totp-name" value="" autocomplete="off" required autofocus>
<button type="submit" class="new">Create</button>
</form>
</main>
{{end}}

View file

@ -0,0 +1,128 @@
package templates
import (
"arimelody-web/log"
"fmt"
"html/template"
"strings"
"time"
_ "embed"
)
//go:embed "html/layout.html"
var layoutHTML string
//go:embed "html/prideflag.html"
var prideflagHTML string
//go:embed "html/index.html"
var indexHTML string
//go:embed "html/register.html"
var registerHTML string
//go:embed "html/login.html"
var loginHTML string
//go:embed "html/login-totp.html"
var loginTotpHTML string
//go:embed "html/totp-confirm.html"
var totpConfirmHTML string
//go:embed "html/totp-setup.html"
var totpSetupHTML string
//go:embed "html/logout.html"
var logoutHTML string
//go:embed "html/logs.html"
var logsHTML string
//go:embed "html/edit-account.html"
var editAccountHTML string
//go:embed "html/edit-artist.html"
var editArtistHTML string
//go:embed "html/edit-release.html"
var editReleaseHTML string
//go:embed "html/edit-track.html"
var editTrackHTML string
//go:embed "html/components/credits/newcredit.html"
var componentNewCreditHTML string
//go:embed "html/components/credits/addcredit.html"
var componentAddCreditHTML string
//go:embed "html/components/credits/editcredits.html"
var componentEditCreditsHTML string
//go:embed "html/components/links/editlinks.html"
var componentEditLinksHTML string
//go:embed "html/components/release/release-list-item.html"
var componentReleaseListItemHTML string
//go:embed "html/components/tracks/newtrack.html"
var componentNewTrackHTML string
//go:embed "html/components/tracks/addtrack.html"
var componentAddTrackHTML string
//go:embed "html/components/tracks/edittracks.html"
var componentEditTracksHTML string
var BaseTemplate = template.Must(template.New("base").Parse(
strings.Join([]string{ layoutHTML, prideflagHTML }, "\n"),
))
var IndexTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(
strings.Join([]string{
indexHTML,
componentReleaseListItemHTML,
}, "\n"),
))
var LoginTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(loginHTML))
var LoginTOTPTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(loginTotpHTML))
var RegisterTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(registerHTML))
var LogoutTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(logoutHTML))
var AccountTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(editAccountHTML))
var TOTPSetupTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(totpSetupHTML))
var TOTPConfirmTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(totpConfirmHTML))
var LogsTemplate = template.Must(template.Must(BaseTemplate.Clone()).Funcs(template.FuncMap{
"parseLevel": func(level log.LogLevel) string {
switch level {
case log.LEVEL_INFO:
return "INFO"
case log.LEVEL_WARN:
return "WARN"
}
return fmt.Sprintf("%d?", level)
},
"titleCase": func(logType string) string {
runes := []rune(logType)
for i, r := range runes {
if (i == 0 || runes[i - 1] == ' ') && r >= 'a' && r <= 'z' {
runes[i] = r + ('A' - 'a')
}
}
return string(runes)
},
"lower": func(str string) string { return strings.ToLower(str) },
"prettyTime": func(t time.Time) string {
// return t.Format("2006-01-02 15:04:05")
// return t.Format("15:04:05, 2 Jan 2006")
return t.Format("02 Jan 2006, 15:04:05")
},
}).Parse(logsHTML))
var EditReleaseTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(editReleaseHTML))
var EditArtistTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(editArtistHTML))
var EditTrackTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(
strings.Join([]string{
editTrackHTML,
componentReleaseListItemHTML,
}, "\n"),
))
var EditCreditsTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(componentEditCreditsHTML))
var AddCreditTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(componentAddCreditHTML))
var NewCreditTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(componentNewCreditHTML))
var EditLinksTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(componentEditLinksHTML))
var EditTracksTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(componentEditTracksHTML))
var AddTrackTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(componentAddTrackHTML))
var NewTrackTemplate = template.Must(template.Must(BaseTemplate.Clone()).Parse(componentNewTrackHTML))