full release edit capabilities oh my goodness gracious

Signed-off-by: ari melody <ari@arimelody.me>
This commit is contained in:
ari melody 2024-08-31 01:30:30 +01:00
parent 34cddcfdb2
commit 604e2a4a7c
25 changed files with 1043 additions and 202 deletions

View file

@ -12,8 +12,7 @@
hx-swap="beforeend" hx-swap="beforeend"
> >
<img src="{{$Artist.GetAvatar}}" alt="" width="16" loading="lazy" class="artist-avatar"> <img src="{{$Artist.GetAvatar}}" alt="" width="16" loading="lazy" class="artist-avatar">
<span class="artist-name">{{$Artist.Name}}</span> <span class="artist-name">{{$Artist.Name}} <span class="artist-id">({{$Artist.ID}})</span></span>
<span class="artist-id">({{$Artist.ID}})</span>
</li> </li>
{{end}} {{end}}
</ul> </ul>

View file

@ -27,7 +27,7 @@
<input type="checkbox" name="primary" {{if .Primary}}checked{{end}}> <input type="checkbox" name="primary" {{if .Primary}}checked{{end}}>
</div> </div>
</div> </div>
<button type="button" class="delete">Delete</button> <a class="delete">Delete</a>
</div> </div>
</li> </li>
{{end}} {{end}}
@ -40,6 +40,8 @@
</form> </form>
<script type="module"> <script type="module">
import { makeMagicList } from "/admin/static/admin.js";
(() => { (() => {
const container = document.getElementById("editcredits"); const container = document.getElementById("editcredits");
const form = document.querySelector("#editcredits form"); const form = document.querySelector("#editcredits form");
@ -47,39 +49,21 @@
const addCreditBtn = document.getElementById("add-credit"); const addCreditBtn = document.getElementById("add-credit");
const discardBtn = form.querySelector("button#discard"); const discardBtn = form.querySelector("button#discard");
function creditFromElement(el) { makeMagicList(creditList, ".credit");
const artistID = el.dataset.artist;
const roleInput = el.querySelector(`input[name="role"]`)
const primaryInput = el.querySelector(`input[name="primary"]`)
const deleteBtn = el.querySelector("button.delete");
let credit = {
"artist": artistID,
"role": roleInput.value,
"primary": primaryInput.checked,
};
roleInput.addEventListener("change", () => {
credit.role = roleInput.value;
});
primaryInput.addEventListener("change", () => {
credit.primary = primaryInput.checked;
});
deleteBtn.addEventListener("click", e => {
if (!confirm("Are you sure you want to delete " + artistID + "'s credit?")) return;
el.remove();
credits = credits.filter(credit => credit.artist != artistID);
});
return credit;
}
let credits = [...form.querySelectorAll(".credit")].map(el => creditFromElement(el));
creditList.addEventListener("htmx:afterSwap", e => { creditList.addEventListener("htmx:afterSwap", e => {
const el = creditList.children[creditList.children.length - 1]; const el = creditList.children[creditList.children.length - 1];
const credit = creditFromElement(el);
credits.push(credit); 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.addEventListener("dragstart", () => { el.classList.add("moving") });
el.addEventListener("dragend", () => { el.classList.remove("moving") });
}); });
container.showModal(); container.showModal();
@ -89,12 +73,18 @@
}); });
form.addEventListener("submit", e => { 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(); e.preventDefault();
fetch(form.action, { fetch(form.action, {
method: "PUT", method: "PUT",
headers: { headers: { "Content-Type": "application/json" },
"Content-Type": "application/json",
},
body: JSON.stringify(credits) body: JSON.stringify(credits)
}).then(res => { }).then(res => {
if (res.ok) location = location; if (res.ok) location = location;
@ -105,7 +95,7 @@
}); });
} }
}).catch(err => { }).catch(err => {
alert("Failed to update credits. Check the console for details"); alert("Failed to update credits. Check the console for details.");
console.error(err); console.error(err);
}); });
}); });

View file

@ -12,6 +12,6 @@
<input type="checkbox" name="primary"> <input type="checkbox" name="primary">
</div> </div>
</div> </div>
<button type="button" class="delete">Delete</button> <a class="delete">Delete</a>
</div> </div>
</li> </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,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/{{.ID}}/addtrack"
hx-get="/admin/release/{{.ID}}/addtrack"
hx-target="body"
hx-swap="beforeend"
>Add</a>
</header>
<form action="/api/v1/music/{{.ID}}/tracks">
<ul>
{{range .Tracks}}
<li class="track" data-track="{{.ID}}" data-title="{{.Title}}" data-number="{{.Number}}" draggable="true">
<div>
<p class="track-name">
<span class="track-number">{{.Number}}</span>
{{.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="{{.Number}}" draggable="true">
<div>
<p class="track-name">
<span class="track-number">{{.Number}}</span>
{{.Title}}
</p>
<a class="delete">Delete</a>
</div>
</li>

View file

@ -45,6 +45,18 @@ func serveRelease() http.Handler {
case "newcredit": case "newcredit":
serveNewCredit().ServeHTTP(w, r) serveNewCredit().ServeHTTP(w, r)
return return
case "editlinks":
serveEditLinks(release).ServeHTTP(w, r)
return
case "edittracks":
serveEditTracks(release).ServeHTTP(w, r)
return
case "addtrack":
serveAddTrack(release).ServeHTTP(w, r)
return
case "newtrack":
serveNewTrack(release).ServeHTTP(w, r)
return
} }
http.NotFound(w, r) http.NotFound(w, r)
return return
@ -52,11 +64,11 @@ func serveRelease() http.Handler {
tracks := []gatewayTrack{} tracks := []gatewayTrack{}
for i, track := range release.Tracks { for i, track := range release.Tracks {
tracks = append([]gatewayTrack{{ tracks = append(tracks, gatewayTrack{
Track: track, Track: track,
Lyrics: template.HTML(strings.Replace(track.Lyrics, "\n", "<br>", -1)), Lyrics: template.HTML(strings.Replace(track.Lyrics, "\n", "<br>", -1)),
Number: len(release.Tracks) - i, Number: i + 1,
}}, tracks...) })
} }
lrw := global.LoggingResponseWriter{ResponseWriter: w, Code: http.StatusOK} lrw := global.LoggingResponseWriter{ResponseWriter: w, Code: http.StatusOK}
@ -121,3 +133,84 @@ func serveNewCredit() http.Handler {
return return
}) })
} }
func serveEditLinks(release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
serveComponent(path.Join("links", "editlinks.html"), release).ServeHTTP(w, r)
return
})
}
func serveEditTracks(release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
type Track struct {
*model.Track
Number int
}
type Release struct {
*model.Release
Tracks []Track
}
var data = Release{ release, []Track{} }
for i, track := range release.Tracks {
data.Tracks = append(data.Tracks, Track{track, i + 1})
}
serveComponent(path.Join("tracks", "edittracks.html"), data).ServeHTTP(w, r)
return
})
}
func serveAddTrack(release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var tracks = []*model.Track{}
for _, track := range global.Tracks {
var exists = false
for _, t := range release.Tracks {
if t == track {
exists = true
break
}
}
if !exists {
tracks = append(tracks, track)
}
}
type response struct {
ReleaseID string;
Tracks []*model.Track
}
w.Header().Set("Content-Type", "text/html")
serveComponent(path.Join("tracks", "addtrack.html"), response{
ReleaseID: release.ID,
Tracks: tracks,
}).ServeHTTP(w, r)
return
})
}
func serveNewTrack(release *model.Release) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
track := global.GetTrack(strings.Split(r.URL.Path, "/")[3])
if track == nil {
http.NotFound(w, r)
return
}
type Track struct {
*model.Track
Number int
}
w.Header().Set("Content-Type", "text/html")
serveComponent(path.Join("tracks", "newtrack.html"), Track{
track,
len(release.Tracks) + 1,
}).ServeHTTP(w, r)
return
})
}

View file

@ -86,7 +86,7 @@ a img {
} }
.card { .card {
margin-bottom: 2em; margin-bottom: 1em;
} }
.card h2 { .card h2 {

View file

@ -0,0 +1,68 @@
/**
* Creates a "magic" reorderable list from `container`.
* This function is absolute magic and I love it
*
* Example:
* ```html
* <ul id="list">
* <li>Item 1</li>
* <li>Item 2</li>
* <li>Item 3</li>
* </ul>
* ```
* ```js
* // javascript
* makeMagicList(document.getElementById("list"), "li");
* ```
*
* @param {HTMLElement} container The parent container to use as a list.
* @param {string} itemSelector The selector name of list item elements.
* @param {Function} callback A function to call after each reordering.
*/
export function makeMagicList(container, itemSelector, callback) {
if (!container)
throw new Error("container not provided");
if (!itemSelector)
throw new Error("itemSelector not provided");
container.querySelectorAll(itemSelector).forEach(item => {
item.draggable = true;
item.addEventListener("dragstart", () => { item.classList.add("moving") });
item.addEventListener("dragend", () => { item.classList.remove("moving") });
item.querySelectorAll("input").forEach(el => {
el.addEventListener("mousedown", () => { item.draggable = false });
el.addEventListener("mouseup", () => { item.draggable = true });
el.addEventListener("dragstart", e => { e.stopPropagation() });
});
});
var lastCursorY;
container.addEventListener("dragover", event => {
const dragging = container.querySelector(itemSelector + ".moving");
if (!dragging) return;
let cursorY = event.touches ? event.touches[0].clientY : event.clientY;
// don't bother processing if we haven't moved
if (lastCursorY === cursorY) return
lastCursorY = cursorY;
// get the element positioned ahead of the cursor
const notMoving = [...container.querySelectorAll(itemSelector + ":not(.moving)")];
const afterElement = notMoving.reduce((previous, current) => {
const box = current.getBoundingClientRect();
const offset = cursorY - box.top - box.height / 2;
if (offset < 0 && offset > previous.offset)
return { offset: offset, element: current };
return previous;
}, { offset: Number.NEGATIVE_INFINITY }).element;
if (afterElement) {
container.insertBefore(dragging, afterElement);
} else {
container.appendChild(dragging);
}
if (callback) callback();
});
}

View file

@ -1,3 +1,9 @@
input[type="text"] {
font-size: inherit;
font-family: inherit;
color: inherit;
}
#release { #release {
margin-bottom: 1em; margin-bottom: 1em;
padding: 1.5em; padding: 1.5em;
@ -12,10 +18,6 @@
.release-artwork { .release-artwork {
width: 200px; width: 200px;
display: flex;
justify-content: center;
align-items: start;
} }
.release-artwork img { .release-artwork img {
@ -28,6 +30,7 @@
} }
.release-info { .release-info {
width: 0;
margin: 0; margin: 0;
flex-grow: 1; flex-grow: 1;
display: flex; display: flex;
@ -38,6 +41,28 @@
margin: 0; margin: 0;
} }
#title {
width: 100%;
margin: 0 -.2em;
padding: 0 .2em;
font-weight: bold;
border-radius: 4px;
border: 1px solid transparent;
background: transparent;
outline: none;
}
#title:hover {
background: #ffffff;
border-color: #80808080;
}
#title:active,
#title:focus {
background: #ffffff;
border-color: #808080;
}
.release-title small { .release-title small {
opacity: .75; opacity: .75;
} }
@ -71,6 +96,7 @@
border: none; border: none;
background: none; background: none;
outline: none; outline: none;
resize: vertical;
} }
.release-info table td:has(select), .release-info table td:has(select),
.release-info table td:has(input), .release-info table td:has(input),
@ -126,6 +152,10 @@ button[disabled] {
cursor: not-allowed !important; cursor: not-allowed !important;
} }
a.delete {
color: #d22828;
}
.release-actions { .release-actions {
margin-top: auto; margin-top: auto;
display: flex; display: flex;
@ -134,90 +164,6 @@ button[disabled] {
justify-content: right; justify-content: right;
} }
.card.credits .credit {
margin-bottom: .5em;
padding: .5em;
display: flex;
flex-direction: row;
align-items: center;
gap: 1em;
border-radius: .5em;
background: #f8f8f8f8;
border: 1px solid #808080;
}
.card.credits .credit .artist-avatar {
border-radius: .5em;
}
.card.credits .credit .artist-name {
font-weight: bold;
}
.card.credits .credit .artist-role small {
font-size: inherit;
opacity: .66;
}
.track {
margin-bottom: 1em;
padding: 1em;
display: flex;
flex-direction: column;
gap: .5em;
border-radius: .5em;
background: #f8f8f8f8;
border: 1px solid #808080;
}
.card h2.track-title {
margin: 0;
display: flex;
flex-direction: row;
justify-content: space-between;
}
.card-title a.button {
text-decoration: none;
}
.track-id {
width: fit-content;
font-family: "Monaspace Argon", monospace;
font-size: .8em;
font-style: italic;
line-height: 1em;
user-select: all;
-webkit-user-select: all;
}
.track-album {
margin-left: auto;
font-style: italic;
font-size: .75em;
opacity: .5;
}
.track-album.empty {
color: #ff2020;
opacity: 1;
}
.track-description {
font-style: italic;
}
.track-lyrics {
max-height: 10em;
overflow-y: scroll;
}
.track .empty {
opacity: 0.75;
}
dialog { dialog {
width: min(720px, calc(100% - 2em)); width: min(720px, calc(100% - 2em));
padding: 2em; padding: 2em;
@ -245,13 +191,15 @@ dialog div.dialog-actions {
gap: .5em; gap: .5em;
} }
dialog#editcredits ul { .card-title a.button {
margin: 0; text-decoration: none;
padding: 0;
list-style: none;
} }
dialog#editcredits .credit>div { /*
* RELEASE CREDITS
*/
.card.credits .credit {
margin-bottom: .5em; margin-bottom: .5em;
padding: .5em; padding: .5em;
display: flex; display: flex;
@ -264,24 +212,70 @@ dialog#editcredits .credit>div {
border: 1px solid #808080; border: 1px solid #808080;
} }
dialog#editcredits .credit p { .card.credits .credit .artist-avatar {
margin: 0;
}
dialog#editcredits .credit .artist-avatar {
border-radius: .5em; border-radius: .5em;
} }
dialog#editcredits .credit .credit-info { .card.credits .credit .artist-name {
font-weight: bold;
}
.card.credits .credit .artist-role small {
font-size: inherit;
opacity: .66;
}
#editcredits ul {
margin: 0;
padding: 0;
list-style: none;
}
#editcredits .credit>div {
margin-bottom: .5em;
padding: .5em;
display: flex;
flex-direction: row;
align-items: center;
gap: 1em;
border-radius: .5em;
background: #f8f8f8f8;
border: 1px solid #808080;
}
#editcredits .credit {
transition: transform .2s ease-out, opacity .2s;
}
#editcredits .credit.moving {
transform: scale(1.05);
opacity: .5;
}
#editcredits .credit p {
margin: 0;
}
#editcredits .credit .artist-avatar {
border-radius: .5em;
}
#editcredits .credit .credit-info {
width: 100%; width: 100%;
} }
dialog#editcredits .credit .credit-info .credit-attribute { #editcredits .credit .credit-info .credit-attribute {
width: 100%; width: 100%;
display: flex; display: flex;
} }
dialog#editcredits .credit .credit-info .credit-attribute input[type="text"] { #editcredits .credit .credit-info .credit-attribute label {
display: flex;
align-items: center;
}
#editcredits .credit .credit-info .credit-attribute input[type="text"] {
margin-left: .25em; margin-left: .25em;
padding: .2em .4em; padding: .2em .4em;
flex-grow: 1; flex-grow: 1;
@ -291,15 +285,255 @@ dialog#editcredits .credit .credit-info .credit-attribute input[type="text"] {
color: inherit; color: inherit;
} }
dialog#editcredits .credit .artist-name { #editcredits .credit .artist-name {
font-weight: bold; font-weight: bold;
} }
dialog#editcredits .credit .artist-role small { #editcredits .credit .artist-role small {
font-size: inherit; font-size: inherit;
opacity: .66; opacity: .66;
} }
dialog#editcredits .credit button.delete { #editcredits .credit button.delete {
margin-left: auto; margin-left: auto;
} }
#addcredit ul {
padding: 0;
list-style: none;
background: #f8f8f8;
}
#addcredit ul li.new-artist {
padding: .5em;
display: flex;
gap: .5em;
cursor: pointer;
}
#addcredit ul li.new-artist:nth-child(even) {
background: #f0f0f0;
}
#addcredit ul li.new-artist:hover {
background: #e0e0e0;
}
#addcredit .new-artist .artist-id {
opacity: .5;
}
/*
* RELEASE LINKS
*/
.card.links {
display: flex;
gap: .2em;
}
.card.links a.button[data-name="spotify"] {
background-color: #8cff83
}
.card.links a.button[data-name="applemusic"] {
background-color: #8cd9ff
}
.card.links a.button[data-name="soundcloud"] {
background-color: #fdaa6d
}
.card.links a.button[data-name="youtube"] {
background-color: #ff6e6e
}
#editlinks table {
width: 100%;
}
#editlinks tr {
display: flex;
}
#editlinks th {
padding: 0 .1em;
display: flex;
align-items: center;
text-align: left;
}
#editlinks tr:nth-child(odd) {
background: #f8f8f8;
}
#editlinks tr th,
#editlinks tr td {
height: 2em;
}
#editlinks tr td {
padding: 0;
}
#editlinks tr.link {
transition: transform .2s ease-out, opacity .2s;
}
#editlinks tr.link.moving {
transform: scale(1.05);
opacity: .5;
}
#editlinks tr .grabber {
width: 2em;
display: flex;
justify-content: center;
cursor: pointer;
}
#editlinks tr .grabber img {
width: 1em;
pointer-events: none;
}
#editlinks tr .link-name {
width: 8em;
}
#editlinks tr .link-url {
flex-grow: 1;
}
#editlinks td a.delete {
display: flex;
height: 100%;
align-items: center;
padding: 0 .5em;
}
#editlinks td input[type="text"] {
width: calc(100% - .6em);
height: 100%;
padding: 0 .3em;
border: none;
outline: none;
cursor: pointer;
background: none;
}
#editlinks td input[type="text"]:hover {
background: #0001;
}
#editlinks td input[type="text"]:focus {
outline: 1px solid #808080;
}
/*
* RELEASE TRACKS
*/
.card.tracks .track {
margin-bottom: 1em;
padding: 1em;
display: flex;
flex-direction: column;
gap: .5em;
border-radius: .5em;
background: #f8f8f8f8;
border: 1px solid #808080;
}
.card.tracks h2.track-title {
margin: 0;
display: flex;
gap: .5em;
}
.card.tracks h2.track-title .track-number {
opacity: .5;
}
.card.tracks .track-album {
margin-left: auto;
font-style: italic;
font-size: .75em;
opacity: .5;
}
.card.tracks .track-album.empty {
color: #ff2020;
opacity: 1;
}
.card.tracks .track-description {
font-style: italic;
}
.card.tracks .track-lyrics {
max-height: 10em;
overflow-y: scroll;
}
.card.tracks .track .empty {
opacity: 0.75;
}
#edittracks ul {
padding: 0;
list-style: none;
}
#edittracks .track {
transition: transform .2s ease-out, opacity .2s;
}
#edittracks .track.moving {
transform: scale(1.05);
opacity: .5;
}
#edittracks .track div {
padding: .5em;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
cursor: pointer;
}
#edittracks .track div:active {
cursor: move;
}
#edittracks .track:nth-child(even) {
background: #f0f0f0;
}
#edittracks .track-number {
min-width: 1em;
display: inline-block;
opacity: .5;
}
#edittracks .track-name {
margin: 0;
}
#addtrack ul {
padding: 0;
list-style: none;
background: #f8f8f8;
}
#addtrack ul li.new-track {
padding: .5em;
display: flex;
gap: .5em;
cursor: pointer;
}
#addtrack ul li.new-track:nth-child(even) {
background: #f0f0f0;
}
#addtrack ul li.new-track:hover {
background: #e0e0e0;
}

View file

@ -1,7 +1,9 @@
import Stateful from "/script/silver.min.js" import Stateful from "/script/silver.min.js"
const releaseID = document.getElementById("release").dataset.id; const releaseID = document.getElementById("release").dataset.id;
const artwork_input = document.getElementById("artwork"); const title_input = document.getElementById("title");
const artwork_img = document.getElementById("artwork");
const artwork_input = document.getElementById("artwork-file");
const type_input = document.getElementById("type"); const type_input = document.getElementById("type");
const desc_input = document.getElementById("description"); const desc_input = document.getElementById("description");
const date_input = document.getElementById("release-date"); const date_input = document.getElementById("release-date");
@ -10,20 +12,22 @@ const buylink_input = document.getElementById("buylink");
const vis_input = document.getElementById("visibility"); const vis_input = document.getElementById("visibility");
const save_btn = document.getElementById("save"); const save_btn = document.getElementById("save");
let token = atob(localStorage.getItem("arime-token")); var artwork_data = artwork_img.attributes.src.value;
let edited = new Stateful(false); var token = atob(localStorage.getItem("arime-token"));
let release_data = update_data(undefined); var edited = new Stateful(false);
var release_data = update_data(undefined);
function update_data(old) { function update_data(old) {
let release_data = { var release_data = {
visible: vis_input.value === "true", visible: vis_input.value === "true",
title: undefined, title: title_input.value,
description: desc_input.value, description: desc_input.value,
type: type_input.value, type: type_input.value,
releaseDate: date_input.value, releaseDate: date_input.value,
artwork: artwork_input.attributes.src.value, artwork: artwork_data,
buyname: buyname_input.value, buyname: buyname_input.value,
buylink: buylink_input.value, buylink: buylink_input.value,
}; };
@ -38,8 +42,6 @@ function update_data(old) {
function save_release() { function save_release() {
console.table(release_data); console.table(release_data);
edited.set(false);
(async () => { (async () => {
const res = await fetch( const res = await fetch(
"/api/v1/music/" + releaseID, { "/api/v1/music/" + releaseID, {
@ -61,15 +63,29 @@ function save_release() {
location = location; location = location;
})(); })();
} }
window.save_release = save_release;
edited.onUpdate(edited => { edited.onUpdate(edited => {
save_btn.disabled = !edited; save_btn.disabled = !edited;
}) })
artwork_input.addEventListener("click", () => { title_input.addEventListener("change", () => {
release_data = update_data(release_data); release_data = update_data(release_data);
}); });
artwork_img.addEventListener("click", () => {
artwork_input.addEventListener("change", () => {
if (artwork_input.files.length > 0) {
const reader = new FileReader();
reader.onload = e => {
const data = e.target.result;
artwork_img.src = data;
artwork_data = data;
release_data = update_data(release_data);
};
reader.readAsDataURL(artwork_input.files[0]);
}
});
artwork_input.click();
});
type_input.addEventListener("change", () => { type_input.addEventListener("change", () => {
release_data = update_data(release_data); release_data = update_data(release_data);
}); });

24
admin/static/index.js Normal file
View file

@ -0,0 +1,24 @@
const newReleaseBtn = document.getElementById("create-release");
newReleaseBtn.addEventListener("click", event => {
event.preventDefault();
const id = prompt("Enter an ID for this release:");
if (id == null || id == "") return;
fetch("/api/v1/music", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({id})
}).then(res => {
if (res.ok) location = "/admin/release/" + id;
else {
res.text().then(err => {
alert("Request failed: " + err);
console.error(err);
});
}
}).catch(err => {
alert("Failed to create release. Check the console for details.");
console.error(err);
});
});

View file

@ -2,7 +2,7 @@
<title>editing {{.Title}} - ari melody 💫</title> <title>editing {{.Title}} - ari melody 💫</title>
<link rel="shortcut icon" href="{{.GetArtwork}}" type="image/x-icon"> <link rel="shortcut icon" href="{{.GetArtwork}}" type="image/x-icon">
<link rel="stylesheet" href="/admin/static/release.css"> <link rel="stylesheet" href="/admin/static/edit-release.css">
{{end}} {{end}}
{{define "content"}} {{define "content"}}
@ -11,18 +11,13 @@
<div id="release" data-id="{{.ID}}"> <div id="release" data-id="{{.ID}}">
<div class="release-artwork"> <div class="release-artwork">
<img src="{{.Artwork}}" alt="" width="256" loading="lazy" id="artwork"> <img src="{{.Artwork}}" alt="" width="256" loading="lazy" id="artwork">
<input type="file" id="artwork-file" name="Artwork" accept=".png,.jpg,.jpeg" hidden>
</div> </div>
<div class="release-info"> <div class="release-info">
<h1 class="release-title"> <h1 class="release-title">
<!-- <input type="text" name="Title" value="{{.Title}}"> --> <input type="text" id="title" name="Title" value="{{.Title}}">
<span id="title" editable="true">{{.Title}}</span>
<small>{{.GetReleaseYear}}</small>
</h1> </h1>
<table> <table>
<tr>
<td>Artists</td>
<td>{{.PrintArtists true true}}</td>
</tr>
<tr> <tr>
<td>Type</td> <td>Type</td>
<td> <td>
@ -50,7 +45,7 @@
name="Description" name="Description"
value="{{.Description}}" value="{{.Description}}"
placeholder="No description provided." placeholder="No description provided."
rows="3" rows="1"
id="description" id="description"
>{{.Description}}</textarea> >{{.Description}}</textarea>
</td> </td>
@ -130,9 +125,7 @@
</div> </div>
<div class="card links"> <div class="card links">
{{range .Links}} {{range .Links}}
<div class="release-link" data-id="{{.Name}}"> <a href="{{.URL}}" target="_blank" class="button" data-name="{{.Name}}">{{.Name}} <img src="/img/external-link.svg"/></a>
<a href="{{.URL}}" class="button">{{.Name}} <img src="/img/external-link.svg"/></a></p>
</div>
{{end}} {{end}}
</div> </div>
@ -148,13 +141,19 @@
<div class="card tracks"> <div class="card tracks">
{{range .Tracks}} {{range .Tracks}}
<div class="track" data-id="{{.ID}}"> <div class="track" data-id="{{.ID}}">
<h2 class="track-title">{{.Number}}. {{.Title}}</h2> <h2 class="track-title">
<p class="track-id">{{.ID}}</p> <span class="track-number">{{.Number}}</span>
<a href="/admin/track/{{.ID}}">{{.Title}}</a>
</h2>
<h3>Description</h3>
{{if .Description}} {{if .Description}}
<p class="track-description">{{.Description}}</p> <p class="track-description">{{.Description}}</p>
{{else}} {{else}}
<p class="track-description empty">No description provided.</p> <p class="track-description empty">No description provided.</p>
{{end}} {{end}}
<h3>Lyrics</h3>
{{if .Lyrics}} {{if .Lyrics}}
<p class="track-lyrics">{{.Lyrics}}</p> <p class="track-lyrics">{{.Lyrics}}</p>
{{else}} {{else}}

View file

@ -9,7 +9,7 @@
<div class="card-title"> <div class="card-title">
<h1>Releases</h1> <h1>Releases</h1>
<a href="/admin/createrelease" class="create-btn">Create New</a> <a href="/admin/createrelease" class="create-btn" id="create-release">Create New</a>
</div> </div>
<div class="card releases"> <div class="card releases">
{{range $Release := .Releases}} {{range $Release := .Releases}}
@ -93,5 +93,6 @@
</main> </main>
<script type="module" src="/admin/static/admin.js" defer></script> <script type="module" src="/admin/static/admin.js"></script>
<script type="module" src="/admin/static/index.js"></script>
{{end}} {{end}}

View file

@ -1,9 +1,14 @@
package api package api
import ( import (
"bufio"
"encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io/fs"
"net/http" "net/http"
"os"
"path/filepath"
"strings" "strings"
"time" "time"
@ -85,40 +90,53 @@ func CreateRelease() http.Handler {
http.Error(w, "Release ID cannot be empty\n", http.StatusBadRequest) http.Error(w, "Release ID cannot be empty\n", http.StatusBadRequest)
return return
} }
if *data.Title == "" {
http.Error(w, "Release title cannot be empty\n", http.StatusBadRequest) title := data.ID
if data.Title != nil && *data.Title != "" {
title = *data.Title
}
description := ""
if data.Description != nil && *data.Description != "" { description = *data.Description }
releaseType := model.Single
if data.ReleaseType != nil && *data.ReleaseType != "" { releaseType = *data.ReleaseType }
releaseDate := time.Time{}
if data.ReleaseDate != nil && *data.ReleaseDate != "" {
releaseDate, err = time.Parse("2006-01-02T15:04", *data.ReleaseDate)
if err != nil {
http.Error(w, "Invalid release date", http.StatusBadRequest)
return return
} }
if data.Buyname == nil || *data.Buyname == "" { *data.Buyname = "buy" } } else {
if data.Buylink == nil || *data.Buylink == "" { *data.Buylink = "https://arimelody.me" } releaseDate = time.Date(time.Now().Year(), time.Now().Month(), time.Now().Day(), 0, 0, 0, 0, time.UTC)
}
artwork := "/img/default-cover-art.png"
if data.Artwork != nil && *data.Artwork != "" { artwork = *data.Artwork }
buyname := ""
if data.Buyname != nil && *data.Buyname != "" { buyname = *data.Buyname }
buylink := ""
if data.Buylink != nil && *data.Buylink != "" { buylink = *data.Buylink }
if global.GetRelease(data.ID) != nil { if global.GetRelease(data.ID) != nil {
http.Error(w, fmt.Sprintf("Release %s already exists\n", data.ID), http.StatusBadRequest) http.Error(w, fmt.Sprintf("Release %s already exists\n", data.ID), http.StatusBadRequest)
return return
} }
releaseDate := time.Time{}
if *data.ReleaseDate == "" {
http.Error(w, "Release date cannot be empty\n", http.StatusBadRequest)
return
} else if data.ReleaseDate != nil {
releaseDate, err = time.Parse("2006-01-02T15:04", *data.ReleaseDate)
if err != nil {
http.Error(w, "Invalid release date", http.StatusBadRequest)
return
}
}
var release = model.Release{ var release = model.Release{
ID: data.ID, ID: data.ID,
Visible: *data.Visible, Visible: false,
Title: *data.Title, Title: title,
Description: *data.Description, Description: description,
ReleaseType: *data.ReleaseType, ReleaseType: releaseType,
ReleaseDate: releaseDate, ReleaseDate: releaseDate,
Artwork: *data.Artwork, Artwork: artwork,
Buyname: *data.Buyname, Buyname: buyname,
Buylink: *data.Buylink, Buylink: buylink,
Links: []*model.Link{}, Links: []*model.Link{},
Credits: []*model.Credit{}, Credits: []*model.Credit{},
Tracks: []*model.Track{}, Tracks: []*model.Track{},
@ -181,7 +199,69 @@ func UpdateRelease() http.Handler {
} }
update.ReleaseDate = newDate update.ReleaseDate = newDate
} }
if data.Artwork != nil { update.Artwork = *data.Artwork } if data.Artwork != nil {
if strings.Contains(*data.Artwork, ";base64,") {
split := strings.Split(*data.Artwork, ";base64,")
header := split[0]
imageData, err := base64.StdEncoding.DecodeString(split[1])
ext, _ := strings.CutPrefix(header, "data:image/")
switch ext {
case "png":
case "jpg":
case "jpeg":
default:
http.Error(w, "Invalid image type. Allowed: .png, .jpg, .jpeg", http.StatusBadRequest)
return
}
artworkDirectory := filepath.Join("uploads", "musicart")
// ensure directory exists
os.MkdirAll(artworkDirectory, os.ModePerm)
imagePath := filepath.Join(artworkDirectory, fmt.Sprintf("%s.%s", update.ID, ext))
file, err := os.Create(imagePath)
if err != nil {
fmt.Printf("FATAL: Failed to create file %s: %s\n", imagePath, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
defer file.Close()
buffer := bufio.NewWriter(file)
_, err = buffer.Write(imageData)
if err != nil {
fmt.Printf("FATAL: Failed to write to file %s: %s\n", imagePath, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
if err := buffer.Flush(); err != nil {
fmt.Printf("FATAL: Failed to flush data to file %s: %s\n", imagePath, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
// clean up files with this ID and different extensions
err = filepath.Walk(artworkDirectory, func(path string, info fs.FileInfo, err error) error {
if path == imagePath { return nil }
withoutExt := strings.TrimSuffix(path, filepath.Ext(path))
if withoutExt != filepath.Join(artworkDirectory, update.ID) { return nil }
return os.Remove(path)
})
if err != nil {
fmt.Printf("WARN: Error while cleaning up artwork files: %s\n", err)
}
fmt.Printf("Artwork for %s updated.\n", update.ID)
update.Artwork = fmt.Sprintf("/uploads/musicart/%s.%s", update.ID, ext)
} else {
update.Artwork = *data.Artwork
}
}
if data.Buyname != nil { if data.Buyname != nil {
if *data.Buyname == "" { if *data.Buyname == "" {
http.Error(w, "Release buy name cannot be empty", http.StatusBadRequest) http.Error(w, "Release buy name cannot be empty", http.StatusBadRequest)

View file

@ -47,7 +47,7 @@ func PullReleaseTracksDB(db *sqlx.DB, release *model.Release) ([]*model.Track, e
err := db.Select(&track_rows, err := db.Select(&track_rows,
"SELECT track FROM musicreleasetrack "+ "SELECT track FROM musicreleasetrack "+
"WHERE release=$1 "+ "WHERE release=$1 "+
"ORDER BY number DESC", "ORDER BY number ASC",
release.ID, release.ID,
) )
if err != nil { if err != nil {

View file

@ -86,11 +86,11 @@ func ServeGateway() http.Handler {
tracks := []gatewayTrack{} tracks := []gatewayTrack{}
for i, track := range release.Tracks { for i, track := range release.Tracks {
tracks = append([]gatewayTrack{{ tracks = append(tracks, gatewayTrack{
Track: track, Track: track,
Lyrics: template.HTML(strings.Replace(track.Lyrics, "\n", "<br>", -1)), Lyrics: template.HTML(strings.Replace(track.Lyrics, "\n", "<br>", -1)),
Number: len(release.Tracks) - i, Number: i + 1,
}}, tracks...) })
} }
lrw := global.LoggingResponseWriter{ResponseWriter: w, Code: http.StatusOK} lrw := global.LoggingResponseWriter{ResponseWriter: w, Code: http.StatusOK}

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 144 144" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<path d="M136,66L136,78C136,81.311 133.311,84 130,84L14,84C10.689,84 8,81.311 8,78L8,66C8,62.689 10.689,60 14,60L130,60C133.311,60 136,62.689 136,66ZM136,100L136,112C136,115.311 133.311,118 130,118L14,118C10.689,118 8,115.311 8,112L8,100C8,96.689 10.689,94 14,94L130,94C133.311,94 136,96.689 136,100ZM136,32L136,44C136,47.311 133.311,50 130,50L14,50C10.689,50 8,47.311 8,44L8,32C8,28.689 10.689,26 14,26L130,26C133.311,26 136,28.689 136,32Z"/>
<path d="M136,32L136,44C136,47.311 133.311,50 130,50L14,50C10.689,50 8,47.311 8,44L8,32C8,28.689 10.689,26 14,26L130,26C133.311,26 136,28.689 136,32ZM136,66L136,78C136,81.311 133.311,84 130,84L14,84C10.689,84 8,81.311 8,78L8,66C8,62.689 10.689,60 14,60L130,60C133.311,60 136,62.689 136,66ZM136,100L136,112C136,115.311 133.311,118 130,118L14,118C10.689,118 8,115.311 8,112L8,100C8,96.689 10.689,94 14,94L130,94C133.311,94 136,96.689 136,100Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
res/list-grabber.afdesign Normal file

Binary file not shown.

Binary file not shown.

View file

@ -64,6 +64,7 @@
<p>Releases: {{.PrintReleaseDate}}</p> <p>Releases: {{.PrintReleaseDate}}</p>
{{end}} {{end}}
{{if .IsReleased}}
<ul id="links"> <ul id="links">
{{if .Buylink}} {{if .Buylink}}
<li> <li>
@ -77,6 +78,7 @@
</li> </li>
{{end}} {{end}}
</ul> </ul>
{{end}}
{{if .Description}} {{if .Description}}
<p id="description"> <p id="description">

View file

@ -38,6 +38,7 @@
</h1> </h1>
<h2 class="music-artist">{{$Release.PrintArtists true true}}</h2> <h2 class="music-artist">{{$Release.PrintArtists true true}}</h2>
<h3 class="music-type-{{$Release.ReleaseType}}">{{$Release.ReleaseType}}</h3> <h3 class="music-type-{{$Release.ReleaseType}}">{{$Release.ReleaseType}}</h3>
{{if $Release.IsReleased}}
<ul class="music-links"> <ul class="music-links">
{{range $Link := $Release.Links}} {{range $Link := $Release.Links}}
<li> <li>
@ -45,6 +46,7 @@
</li> </li>
{{end}} {{end}}
</ul> </ul>
{{end}}
</div> </div>
</div> </div>
{{end}} {{end}}