home to your local SPACEGIRL 💫 arimelody.space
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

full release edit capabilities oh my goodness gracious

Signed-off-by: ari melody <ari@arimelody.me>

+1236 -395
+1 -2
admin/components/credits/addcredit.html
··· 12 12 hx-swap="beforeend" 13 13 > 14 14 <img src="{{$Artist.GetAvatar}}" alt="" width="16" loading="lazy" class="artist-avatar"> 15 - <span class="artist-name">{{$Artist.Name}}</span> 16 - <span class="artist-id">({{$Artist.ID}})</span> 15 + <span class="artist-name">{{$Artist.Name}} <span class="artist-id">({{$Artist.ID}})</span></span> 17 16 </li> 18 17 {{end}} 19 18 </ul>
+21 -31
admin/components/credits/editcredits.html
··· 27 27 <input type="checkbox" name="primary" {{if .Primary}}checked{{end}}> 28 28 </div> 29 29 </div> 30 - <button type="button" class="delete">Delete</button> 30 + <a class="delete">Delete</a> 31 31 </div> 32 32 </li> 33 33 {{end}} ··· 40 40 </form> 41 41 42 42 <script type="module"> 43 + import { makeMagicList } from "/admin/static/admin.js"; 44 + 43 45 (() => { 44 46 const container = document.getElementById("editcredits"); 45 47 const form = document.querySelector("#editcredits form"); ··· 47 49 const addCreditBtn = document.getElementById("add-credit"); 48 50 const discardBtn = form.querySelector("button#discard"); 49 51 50 - function creditFromElement(el) { 51 - const artistID = el.dataset.artist; 52 - const roleInput = el.querySelector(`input[name="role"]`) 53 - const primaryInput = el.querySelector(`input[name="primary"]`) 54 - const deleteBtn = el.querySelector("button.delete"); 52 + makeMagicList(creditList, ".credit"); 55 53 56 - let credit = { 57 - "artist": artistID, 58 - "role": roleInput.value, 59 - "primary": primaryInput.checked, 60 - }; 54 + creditList.addEventListener("htmx:afterSwap", e => { 55 + const el = creditList.children[creditList.children.length - 1]; 56 + 57 + const artistID = el.dataset.artist; 58 + const deleteBtn = el.querySelector("a.delete"); 61 59 62 - roleInput.addEventListener("change", () => { 63 - credit.role = roleInput.value; 64 - }); 65 - primaryInput.addEventListener("change", () => { 66 - credit.primary = primaryInput.checked; 67 - }); 68 60 deleteBtn.addEventListener("click", e => { 69 61 if (!confirm("Are you sure you want to delete " + artistID + "'s credit?")) return; 70 62 el.remove(); 71 - credits = credits.filter(credit => credit.artist != artistID); 72 63 }); 73 64 74 - return credit; 75 - } 76 - 77 - let credits = [...form.querySelectorAll(".credit")].map(el => creditFromElement(el)); 78 - 79 - creditList.addEventListener("htmx:afterSwap", e => { 80 - const el = creditList.children[creditList.children.length - 1]; 81 - const credit = creditFromElement(el); 82 - credits.push(credit); 65 + el.addEventListener("dragstart", () => { el.classList.add("moving") }); 66 + el.addEventListener("dragend", () => { el.classList.remove("moving") }); 83 67 }); 84 68 85 69 container.showModal(); ··· 89 73 }); 90 74 91 75 form.addEventListener("submit", e => { 76 + const credits = [...creditList.querySelectorAll(".credit")].map(el => { 77 + return { 78 + "artist": el.dataset.artist, 79 + "role": el.querySelector(`input[name="role"]`).value, 80 + "primary": el.querySelector(`input[name="primary"]`).checked, 81 + }; 82 + }); 83 + 92 84 e.preventDefault(); 93 85 fetch(form.action, { 94 86 method: "PUT", 95 - headers: { 96 - "Content-Type": "application/json", 97 - }, 87 + headers: { "Content-Type": "application/json" }, 98 88 body: JSON.stringify(credits) 99 89 }).then(res => { 100 90 if (res.ok) location = location; ··· 105 95 }); 106 96 } 107 97 }).catch(err => { 108 - alert("Failed to update credits. Check the console for details"); 98 + alert("Failed to update credits. Check the console for details."); 109 99 console.error(err); 110 100 }); 111 101 });
+1 -1
admin/components/credits/newcredit.html
··· 12 12 <input type="checkbox" name="primary"> 13 13 </div> 14 14 </div> 15 - <button type="button" class="delete">Delete</button> 15 + <a class="delete">Delete</a> 16 16 </div> 17 17 </li>
admin/components/links/addlink.html

This is a binary file and will not be displayed.

+159
admin/components/links/editlinks.html
··· 1 + <dialog id="editlinks"> 2 + <header> 3 + <h2>Editing: Links</h2> 4 + <button id="add-link" class="button new">Add</button> 5 + </header> 6 + 7 + <form action="/api/v1/music/{{.ID}}/links"> 8 + <table> 9 + <tr> 10 + <th class="grabber"></th> 11 + <th class="link-name">Name</th> 12 + <th class="link-url">URL</th> 13 + <th></th> 14 + </tr> 15 + {{range .Links}} 16 + <tr class="link"> 17 + <td class="grabber"><img src="/img/list-grabber.svg"/></td> 18 + <td class="link-name"> 19 + <input type="text" name="name" value="{{.Name}}"> 20 + </td> 21 + <td class="link-url"> 22 + <input type="text" name="url" value="{{.URL}}"> 23 + </td> 24 + <td> 25 + <a class="delete">Delete</a> 26 + </td> 27 + </tr> 28 + {{end}} 29 + </table> 30 + 31 + <div class="dialog-actions"> 32 + <button id="discard" type="button">Discard</button> 33 + <button id="save" type="submit" class="save">Save</button> 34 + </div> 35 + </form> 36 + 37 + <script type="module"> 38 + import { makeMagicList } from "/admin/static/admin.js"; 39 + (() => { 40 + const container = document.getElementById("editlinks"); 41 + const form = document.querySelector("#editlinks form"); 42 + const linkTable = form.querySelector("table tbody"); 43 + const addLinkBtn = document.getElementById("add-link"); 44 + const discardBtn = form.querySelector("button#discard"); 45 + 46 + makeMagicList(linkTable, "tr.link"); 47 + 48 + function rigLinkItem(el) { 49 + const nameInput = el.querySelector(`input[name="name"]`) 50 + const deleteBtn = el.querySelector("a.delete"); 51 + 52 + deleteBtn.addEventListener("click", e => { 53 + e.preventDefault(); 54 + if (nameInput.value != "" && 55 + !confirm("Are you sure you want to delete \"" + nameInput.value + "\"?")) 56 + return; 57 + el.remove(); 58 + }); 59 + } 60 + 61 + [...linkTable.querySelectorAll("tr.link")].map(rigLinkItem); 62 + 63 + addLinkBtn.addEventListener("click", e => { 64 + e.preventDefault(); 65 + const row = document.createElement("tr"); 66 + row.className = "link"; 67 + 68 + const grabberCell = document.createElement("td"); 69 + grabberCell.className = "grabber"; 70 + const grabberImg = document.createElement("img"); 71 + grabberImg.src = "/img/list-grabber.svg"; 72 + grabberCell.appendChild(grabberImg); 73 + row.appendChild(grabberCell); 74 + 75 + const nameCell = document.createElement("td"); 76 + nameCell.className = "link-name"; 77 + const nameInput = document.createElement("input"); 78 + nameInput.type = "text"; 79 + nameInput.name = "name"; 80 + nameCell.appendChild(nameInput); 81 + row.appendChild(nameCell); 82 + 83 + const urlCell = document.createElement("td"); 84 + urlCell.className = "link-url"; 85 + const urlInput = document.createElement("input"); 86 + urlInput.type = "text"; 87 + urlInput.name = "url"; 88 + urlCell.appendChild(urlInput); 89 + row.appendChild(urlCell); 90 + 91 + const deleteCell = document.createElement("td"); 92 + const deleteBtn = document.createElement("a"); 93 + deleteBtn.className = "delete"; 94 + deleteBtn.innerText = "Delete"; 95 + deleteCell.appendChild(deleteBtn); 96 + row.appendChild(deleteCell); 97 + 98 + linkTable.appendChild(row); 99 + 100 + row.draggable = true; 101 + row.addEventListener("dragstart", () => { row.classList.add("moving") }); 102 + row.addEventListener("dragend", () => { row.classList.remove("moving") }); 103 + row.querySelectorAll("input").forEach(el => { 104 + el.addEventListener("mousedown", () => { row.draggable = false }); 105 + el.addEventListener("mouseup", () => { row.draggable = true }); 106 + el.addEventListener("dragstart", e => { e.stopPropagation() }); 107 + }); 108 + 109 + deleteBtn.addEventListener("click", e => { 110 + e.preventDefault(); 111 + if (nameInput.value != "" && !confirm("Are you sure you want to delete \"" + nameInput.value + "\"?")) return; 112 + row.remove(); 113 + }); 114 + }); 115 + 116 + container.showModal(); 117 + 118 + container.addEventListener("close", () => { 119 + container.remove(); 120 + }); 121 + 122 + form.addEventListener("submit", e => { 123 + var links = []; 124 + [...linkTable.querySelectorAll("tr.link")].map(el => { 125 + const name = el.querySelector(`input[name="name"]`).value; 126 + const url = el.querySelector(`input[name="url"]`).value; 127 + if (name == "" || url == "") return; 128 + links.push({ 129 + "name": name, 130 + "url": url, 131 + }); 132 + }) 133 + 134 + e.preventDefault(); 135 + fetch(form.action, { 136 + method: "PUT", 137 + headers: { "Content-Type": "application/json" }, 138 + body: JSON.stringify(links) 139 + }).then(res => { 140 + if (res.ok) location = location; 141 + else { 142 + res.text().then(err => { 143 + alert(err); 144 + console.error(err); 145 + }); 146 + } 147 + }).catch(err => { 148 + alert("Failed to update links. Check the console for details."); 149 + console.error(err); 150 + }); 151 + }); 152 + 153 + discardBtn.addEventListener("click", e => { 154 + e.preventDefault(); 155 + container.close(); 156 + }); 157 + })(); 158 + </script> 159 + </dialog>
admin/components/links/newlink.html

This is a binary file and will not be displayed.

+47
admin/components/tracks/addtrack.html
··· 1 + <dialog id="addtrack"> 2 + <header> 3 + <h2>Add track</h2> 4 + </header> 5 + 6 + <ul> 7 + {{range $Track := .Tracks}} 8 + </li> 9 + <li class="new-track" 10 + data-id="{{$Track.ID}}" 11 + hx-get="/admin/release/{{$.ReleaseID}}/newtrack/{{$Track.ID}}" 12 + hx-target="#edittracks ul" 13 + hx-swap="beforeend" 14 + > 15 + {{.Title}} 16 + </li> 17 + {{end}} 18 + </ul> 19 + 20 + {{if not .Tracks}} 21 + <p class="empty">There are no more tracks to add.</p> 22 + {{end}} 23 + 24 + <div class="dialog-actions"> 25 + <button id="cancel" type="button">Cancel</button> 26 + </div> 27 + 28 + <script type="text/javascript"> 29 + (() => { 30 + const newTrackModal = document.getElementById("addtrack") 31 + const editTracksModal = document.getElementById("edittracks") 32 + const cancelBtn = newTrackModal.querySelector("#cancel"); 33 + 34 + editTracksModal.addEventListener("htmx:afterSwap", () => { 35 + newTrackModal.close(); 36 + newTrackModal.remove(); 37 + }); 38 + 39 + cancelBtn.addEventListener("click", () => { 40 + newTrackModal.close(); 41 + newTrackModal.remove(); 42 + }); 43 + 44 + newTrackModal.showModal(); 45 + })(); 46 + </script> 47 + </dialog>
+112
admin/components/tracks/edittracks.html
··· 1 + <dialog id="edittracks"> 2 + <header> 3 + <h2>Editing: Tracks</h2> 4 + <a id="add-track" 5 + class="button new" 6 + href="/admin/release/{{.ID}}/addtrack" 7 + hx-get="/admin/release/{{.ID}}/addtrack" 8 + hx-target="body" 9 + hx-swap="beforeend" 10 + >Add</a> 11 + </header> 12 + 13 + <form action="/api/v1/music/{{.ID}}/tracks"> 14 + <ul> 15 + {{range .Tracks}} 16 + <li class="track" data-track="{{.ID}}" data-title="{{.Title}}" data-number="{{.Number}}" draggable="true"> 17 + <div> 18 + <p class="track-name"> 19 + <span class="track-number">{{.Number}}</span> 20 + {{.Title}} 21 + </p> 22 + <a class="delete">Delete</a> 23 + </div> 24 + </li> 25 + {{end}} 26 + </ul> 27 + 28 + <div class="dialog-actions"> 29 + <button id="discard" type="button">Discard</button> 30 + <button id="save" type="submit" class="save">Save</button> 31 + </div> 32 + </form> 33 + 34 + <script type="module"> 35 + import { makeMagicList } from "/admin/static/admin.js"; 36 + (() => { 37 + const container = document.getElementById("edittracks"); 38 + const form = document.querySelector("#edittracks form"); 39 + const trackList = form.querySelector("ul"); 40 + const addTrackBtn = document.getElementById("add-track"); 41 + const discardBtn = form.querySelector("button#discard"); 42 + 43 + makeMagicList(trackList, ".track", refreshTrackNumbers); 44 + 45 + function rigTrackItem(trackItem) { 46 + const trackID = trackItem.dataset.track; 47 + const trackTitle = trackItem.dataset.title; 48 + const deleteBtn = trackItem.querySelector("a.delete"); 49 + 50 + deleteBtn.addEventListener("click", e => { 51 + e.preventDefault(); 52 + if (!confirm("Are you sure you want to remove " + trackTitle + "?")) return; 53 + trackItem.remove(); 54 + refreshTrackNumbers(); 55 + }); 56 + } 57 + 58 + function refreshTrackNumbers() { 59 + trackList.querySelectorAll("li").forEach((trackItem, i) => { 60 + trackItem.querySelector(".track-number").innerText = i + 1; 61 + }); 62 + } 63 + 64 + trackList.addEventListener("htmx:afterSwap", e => { 65 + const trackItem = trackList.children[trackList.children.length - 1]; 66 + trackList.appendChild(trackItem); 67 + trackItem.addEventListener("dragstart", () => { trackItem.classList.add("moving") }); 68 + trackItem.addEventListener("dragend", () => { trackItem.classList.remove("moving") }); 69 + rigTrackItem(trackItem); 70 + refreshTrackNumbers(); 71 + }); 72 + 73 + trackList.querySelectorAll("li").forEach(trackItem => { 74 + rigTrackItem(trackItem); 75 + }); 76 + 77 + container.showModal(); 78 + 79 + container.addEventListener("close", () => { 80 + container.remove(); 81 + }); 82 + 83 + form.addEventListener("submit", e => { 84 + e.preventDefault(); 85 + 86 + let tracks = [...trackList.querySelectorAll(".track")].map(trackItem => trackItem.dataset.track); 87 + 88 + fetch(form.action, { 89 + method: "PUT", 90 + headers: { "Content-Type": "application/json" }, 91 + body: JSON.stringify(tracks) 92 + }).then(res => { 93 + if (res.ok) location = location; 94 + else { 95 + res.text().then(err => { 96 + alert(err); 97 + console.error(err); 98 + }); 99 + } 100 + }).catch(err => { 101 + alert("Failed to update tracks. Check the console for details."); 102 + console.error(err); 103 + }); 104 + }); 105 + 106 + discardBtn.addEventListener("click", e => { 107 + e.preventDefault(); 108 + container.close(); 109 + }); 110 + })(); 111 + </script> 112 + </dialog>
+9
admin/components/tracks/newtrack.html
··· 1 + <li class="track" data-track="{{.ID}}" data-title="{{.Title}}" data-number="{{.Number}}" draggable="true"> 2 + <div> 3 + <p class="track-name"> 4 + <span class="track-number">{{.Number}}</span> 5 + {{.Title}} 6 + </p> 7 + <a class="delete">Delete</a> 8 + </div> 9 + </li>
+96 -3
admin/releasehttp.go
··· 45 45 case "newcredit": 46 46 serveNewCredit().ServeHTTP(w, r) 47 47 return 48 + case "editlinks": 49 + serveEditLinks(release).ServeHTTP(w, r) 50 + return 51 + case "edittracks": 52 + serveEditTracks(release).ServeHTTP(w, r) 53 + return 54 + case "addtrack": 55 + serveAddTrack(release).ServeHTTP(w, r) 56 + return 57 + case "newtrack": 58 + serveNewTrack(release).ServeHTTP(w, r) 59 + return 48 60 } 49 61 http.NotFound(w, r) 50 62 return ··· 52 64 53 65 tracks := []gatewayTrack{} 54 66 for i, track := range release.Tracks { 55 - tracks = append([]gatewayTrack{{ 67 + tracks = append(tracks, gatewayTrack{ 56 68 Track: track, 57 69 Lyrics: template.HTML(strings.Replace(track.Lyrics, "\n", "<br>", -1)), 58 - Number: len(release.Tracks) - i, 59 - }}, tracks...) 70 + Number: i + 1, 71 + }) 60 72 } 61 73 62 74 lrw := global.LoggingResponseWriter{ResponseWriter: w, Code: http.StatusOK} ··· 121 133 return 122 134 }) 123 135 } 136 + 137 + func serveEditLinks(release *model.Release) http.Handler { 138 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 139 + w.Header().Set("Content-Type", "text/html") 140 + serveComponent(path.Join("links", "editlinks.html"), release).ServeHTTP(w, r) 141 + return 142 + }) 143 + } 144 + 145 + func serveEditTracks(release *model.Release) http.Handler { 146 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 147 + w.Header().Set("Content-Type", "text/html") 148 + type Track struct { 149 + *model.Track 150 + Number int 151 + } 152 + type Release struct { 153 + *model.Release 154 + Tracks []Track 155 + } 156 + var data = Release{ release, []Track{} } 157 + for i, track := range release.Tracks { 158 + data.Tracks = append(data.Tracks, Track{track, i + 1}) 159 + } 160 + 161 + serveComponent(path.Join("tracks", "edittracks.html"), data).ServeHTTP(w, r) 162 + return 163 + }) 164 + } 165 + 166 + func serveAddTrack(release *model.Release) http.Handler { 167 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 168 + var tracks = []*model.Track{} 169 + for _, track := range global.Tracks { 170 + var exists = false 171 + for _, t := range release.Tracks { 172 + if t == track { 173 + exists = true 174 + break 175 + } 176 + } 177 + if !exists { 178 + tracks = append(tracks, track) 179 + } 180 + } 181 + 182 + type response struct { 183 + ReleaseID string; 184 + Tracks []*model.Track 185 + } 186 + 187 + w.Header().Set("Content-Type", "text/html") 188 + serveComponent(path.Join("tracks", "addtrack.html"), response{ 189 + ReleaseID: release.ID, 190 + Tracks: tracks, 191 + }).ServeHTTP(w, r) 192 + return 193 + }) 194 + } 195 + 196 + func serveNewTrack(release *model.Release) http.Handler { 197 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 198 + track := global.GetTrack(strings.Split(r.URL.Path, "/")[3]) 199 + if track == nil { 200 + http.NotFound(w, r) 201 + return 202 + } 203 + 204 + type Track struct { 205 + *model.Track 206 + Number int 207 + } 208 + 209 + w.Header().Set("Content-Type", "text/html") 210 + serveComponent(path.Join("tracks", "newtrack.html"), Track{ 211 + track, 212 + len(release.Tracks) + 1, 213 + }).ServeHTTP(w, r) 214 + return 215 + }) 216 + }
+1 -1
admin/static/admin.css
··· 86 86 } 87 87 88 88 .card { 89 - margin-bottom: 2em; 89 + margin-bottom: 1em; 90 90 } 91 91 92 92 .card h2 {
+68
admin/static/admin.js
··· 1 + /** 2 + * Creates a "magic" reorderable list from `container`. 3 + * This function is absolute magic and I love it 4 + * 5 + * Example: 6 + * ```html 7 + * <ul id="list"> 8 + * <li>Item 1</li> 9 + * <li>Item 2</li> 10 + * <li>Item 3</li> 11 + * </ul> 12 + * ``` 13 + * ```js 14 + * // javascript 15 + * makeMagicList(document.getElementById("list"), "li"); 16 + * ``` 17 + * 18 + * @param {HTMLElement} container The parent container to use as a list. 19 + * @param {string} itemSelector The selector name of list item elements. 20 + * @param {Function} callback A function to call after each reordering. 21 + */ 22 + export function makeMagicList(container, itemSelector, callback) { 23 + if (!container) 24 + throw new Error("container not provided"); 25 + if (!itemSelector) 26 + throw new Error("itemSelector not provided"); 27 + 28 + container.querySelectorAll(itemSelector).forEach(item => { 29 + item.draggable = true; 30 + item.addEventListener("dragstart", () => { item.classList.add("moving") }); 31 + item.addEventListener("dragend", () => { item.classList.remove("moving") }); 32 + item.querySelectorAll("input").forEach(el => { 33 + el.addEventListener("mousedown", () => { item.draggable = false }); 34 + el.addEventListener("mouseup", () => { item.draggable = true }); 35 + el.addEventListener("dragstart", e => { e.stopPropagation() }); 36 + }); 37 + }); 38 + 39 + var lastCursorY; 40 + container.addEventListener("dragover", event => { 41 + const dragging = container.querySelector(itemSelector + ".moving"); 42 + if (!dragging) return; 43 + 44 + let cursorY = event.touches ? event.touches[0].clientY : event.clientY; 45 + 46 + // don't bother processing if we haven't moved 47 + if (lastCursorY === cursorY) return 48 + lastCursorY = cursorY; 49 + 50 + // get the element positioned ahead of the cursor 51 + const notMoving = [...container.querySelectorAll(itemSelector + ":not(.moving)")]; 52 + const afterElement = notMoving.reduce((previous, current) => { 53 + const box = current.getBoundingClientRect(); 54 + const offset = cursorY - box.top - box.height / 2; 55 + if (offset < 0 && offset > previous.offset) 56 + return { offset: offset, element: current }; 57 + return previous; 58 + }, { offset: Number.NEGATIVE_INFINITY }).element; 59 + 60 + if (afterElement) { 61 + container.insertBefore(dragging, afterElement); 62 + } else { 63 + container.appendChild(dragging); 64 + } 65 + 66 + if (callback) callback(); 67 + }); 68 + }
+539
admin/static/edit-release.css
··· 1 + input[type="text"] { 2 + font-size: inherit; 3 + font-family: inherit; 4 + color: inherit; 5 + } 6 + 7 + #release { 8 + margin-bottom: 1em; 9 + padding: 1.5em; 10 + display: flex; 11 + flex-direction: row; 12 + gap: 1.2em; 13 + 14 + border-radius: .5em; 15 + background: #f8f8f8f8; 16 + border: 1px solid #808080; 17 + } 18 + 19 + .release-artwork { 20 + width: 200px; 21 + } 22 + 23 + .release-artwork img { 24 + width: 100%; 25 + aspect-ratio: 1; 26 + } 27 + .release-artwork img:hover { 28 + outline: 1px solid #808080; 29 + cursor: pointer; 30 + } 31 + 32 + .release-info { 33 + width: 0; 34 + margin: 0; 35 + flex-grow: 1; 36 + display: flex; 37 + flex-direction: column; 38 + } 39 + 40 + .release-title { 41 + margin: 0; 42 + } 43 + 44 + #title { 45 + width: 100%; 46 + margin: 0 -.2em; 47 + padding: 0 .2em; 48 + font-weight: bold; 49 + border-radius: 4px; 50 + border: 1px solid transparent; 51 + background: transparent; 52 + outline: none; 53 + } 54 + 55 + #title:hover { 56 + background: #ffffff; 57 + border-color: #80808080; 58 + } 59 + 60 + #title:active, 61 + #title:focus { 62 + background: #ffffff; 63 + border-color: #808080; 64 + } 65 + 66 + .release-title small { 67 + opacity: .75; 68 + } 69 + 70 + .release-info table { 71 + width: 100%; 72 + margin: .5em 0; 73 + border-collapse: collapse; 74 + } 75 + .release-info table td { 76 + padding: .2em; 77 + border-bottom: 1px solid #d0d0d0; 78 + } 79 + .release-info table tr td:first-child { 80 + vertical-align: top; 81 + opacity: .66; 82 + } 83 + .release-info table tr td:not(:first-child):hover { 84 + background: #e8e8e8; 85 + cursor: pointer; 86 + } 87 + .release-info table td select, 88 + .release-info table td input, 89 + .release-info table td textarea { 90 + padding: .2em; 91 + resize: none; 92 + width: 100%; 93 + font-family: inherit; 94 + font-size: inherit; 95 + color: inherit; 96 + border: none; 97 + background: none; 98 + outline: none; 99 + resize: vertical; 100 + } 101 + .release-info table td:has(select), 102 + .release-info table td:has(input), 103 + .release-info table td:has(textarea) { 104 + padding: 0; 105 + } 106 + 107 + button, .button { 108 + padding: .5em .8em; 109 + font-family: inherit; 110 + font-size: inherit; 111 + border-radius: .5em; 112 + border: 1px solid #a0a0a0; 113 + background: #f0f0f0; 114 + color: inherit; 115 + } 116 + button:hover, .button:hover { 117 + background: #fff; 118 + border-color: #d0d0d0; 119 + } 120 + button:active, .button:active { 121 + background: #d0d0d0; 122 + border-color: #808080; 123 + } 124 + 125 + button { 126 + color: inherit; 127 + } 128 + button.new { 129 + background: #c4ff6a; 130 + border-color: #84b141; 131 + } 132 + button.save { 133 + background: #6fd7ff; 134 + border-color: #6f9eb0; 135 + } 136 + button.delete { 137 + background: #ff7171; 138 + border-color: #7d3535; 139 + } 140 + button:hover { 141 + background: #fff; 142 + border-color: #d0d0d0; 143 + } 144 + button:active { 145 + background: #d0d0d0; 146 + border-color: #808080; 147 + } 148 + button[disabled] { 149 + background: #d0d0d0 !important; 150 + border-color: #808080 !important; 151 + opacity: .5; 152 + cursor: not-allowed !important; 153 + } 154 + 155 + a.delete { 156 + color: #d22828; 157 + } 158 + 159 + .release-actions { 160 + margin-top: auto; 161 + display: flex; 162 + gap: .5em; 163 + flex-direction: row; 164 + justify-content: right; 165 + } 166 + 167 + dialog { 168 + width: min(720px, calc(100% - 2em)); 169 + padding: 2em; 170 + border: 1px solid #101010; 171 + border-radius: 8px; 172 + } 173 + 174 + dialog header { 175 + margin-bottom: 1em; 176 + background: none; 177 + display: flex; 178 + flex-direction: row; 179 + justify-content: space-between; 180 + } 181 + 182 + dialog header h2 { 183 + margin: 0; 184 + } 185 + 186 + dialog div.dialog-actions { 187 + margin-top: 1em; 188 + display: flex; 189 + flex-direction: row; 190 + justify-content: end; 191 + gap: .5em; 192 + } 193 + 194 + .card-title a.button { 195 + text-decoration: none; 196 + } 197 + 198 + /* 199 + * RELEASE CREDITS 200 + */ 201 + 202 + .card.credits .credit { 203 + margin-bottom: .5em; 204 + padding: .5em; 205 + display: flex; 206 + flex-direction: row; 207 + align-items: center; 208 + gap: 1em; 209 + 210 + border-radius: .5em; 211 + background: #f8f8f8f8; 212 + border: 1px solid #808080; 213 + } 214 + 215 + .card.credits .credit .artist-avatar { 216 + border-radius: .5em; 217 + } 218 + 219 + .card.credits .credit .artist-name { 220 + font-weight: bold; 221 + } 222 + 223 + .card.credits .credit .artist-role small { 224 + font-size: inherit; 225 + opacity: .66; 226 + } 227 + 228 + #editcredits ul { 229 + margin: 0; 230 + padding: 0; 231 + list-style: none; 232 + } 233 + 234 + #editcredits .credit>div { 235 + margin-bottom: .5em; 236 + padding: .5em; 237 + display: flex; 238 + flex-direction: row; 239 + align-items: center; 240 + gap: 1em; 241 + 242 + border-radius: .5em; 243 + background: #f8f8f8f8; 244 + border: 1px solid #808080; 245 + } 246 + 247 + #editcredits .credit { 248 + transition: transform .2s ease-out, opacity .2s; 249 + } 250 + 251 + #editcredits .credit.moving { 252 + transform: scale(1.05); 253 + opacity: .5; 254 + } 255 + 256 + #editcredits .credit p { 257 + margin: 0; 258 + } 259 + 260 + #editcredits .credit .artist-avatar { 261 + border-radius: .5em; 262 + } 263 + 264 + #editcredits .credit .credit-info { 265 + width: 100%; 266 + } 267 + 268 + #editcredits .credit .credit-info .credit-attribute { 269 + width: 100%; 270 + display: flex; 271 + } 272 + 273 + #editcredits .credit .credit-info .credit-attribute label { 274 + display: flex; 275 + align-items: center; 276 + } 277 + 278 + #editcredits .credit .credit-info .credit-attribute input[type="text"] { 279 + margin-left: .25em; 280 + padding: .2em .4em; 281 + flex-grow: 1; 282 + font-family: inherit; 283 + border: 1px solid #8888; 284 + border-radius: 4px; 285 + color: inherit; 286 + } 287 + 288 + #editcredits .credit .artist-name { 289 + font-weight: bold; 290 + } 291 + 292 + #editcredits .credit .artist-role small { 293 + font-size: inherit; 294 + opacity: .66; 295 + } 296 + 297 + #editcredits .credit button.delete { 298 + margin-left: auto; 299 + } 300 + 301 + #addcredit ul { 302 + padding: 0; 303 + list-style: none; 304 + background: #f8f8f8; 305 + } 306 + 307 + #addcredit ul li.new-artist { 308 + padding: .5em; 309 + display: flex; 310 + gap: .5em; 311 + cursor: pointer; 312 + } 313 + 314 + #addcredit ul li.new-artist:nth-child(even) { 315 + background: #f0f0f0; 316 + } 317 + 318 + #addcredit ul li.new-artist:hover { 319 + background: #e0e0e0; 320 + } 321 + 322 + #addcredit .new-artist .artist-id { 323 + opacity: .5; 324 + } 325 + 326 + /* 327 + * RELEASE LINKS 328 + */ 329 + 330 + .card.links { 331 + display: flex; 332 + gap: .2em; 333 + } 334 + 335 + .card.links a.button[data-name="spotify"] { 336 + background-color: #8cff83 337 + } 338 + 339 + .card.links a.button[data-name="applemusic"] { 340 + background-color: #8cd9ff 341 + } 342 + 343 + .card.links a.button[data-name="soundcloud"] { 344 + background-color: #fdaa6d 345 + } 346 + 347 + .card.links a.button[data-name="youtube"] { 348 + background-color: #ff6e6e 349 + } 350 + 351 + #editlinks table { 352 + width: 100%; 353 + } 354 + 355 + #editlinks tr { 356 + display: flex; 357 + } 358 + 359 + #editlinks th { 360 + padding: 0 .1em; 361 + display: flex; 362 + align-items: center; 363 + text-align: left; 364 + } 365 + 366 + #editlinks tr:nth-child(odd) { 367 + background: #f8f8f8; 368 + } 369 + 370 + #editlinks tr th, 371 + #editlinks tr td { 372 + height: 2em; 373 + } 374 + 375 + #editlinks tr td { 376 + padding: 0; 377 + } 378 + 379 + #editlinks tr.link { 380 + transition: transform .2s ease-out, opacity .2s; 381 + } 382 + 383 + #editlinks tr.link.moving { 384 + transform: scale(1.05); 385 + opacity: .5; 386 + } 387 + 388 + #editlinks tr .grabber { 389 + width: 2em; 390 + display: flex; 391 + justify-content: center; 392 + cursor: pointer; 393 + } 394 + #editlinks tr .grabber img { 395 + width: 1em; 396 + pointer-events: none; 397 + } 398 + #editlinks tr .link-name { 399 + width: 8em; 400 + } 401 + #editlinks tr .link-url { 402 + flex-grow: 1; 403 + } 404 + 405 + #editlinks td a.delete { 406 + display: flex; 407 + height: 100%; 408 + align-items: center; 409 + padding: 0 .5em; 410 + } 411 + 412 + #editlinks td input[type="text"] { 413 + width: calc(100% - .6em); 414 + height: 100%; 415 + padding: 0 .3em; 416 + border: none; 417 + outline: none; 418 + cursor: pointer; 419 + background: none; 420 + } 421 + #editlinks td input[type="text"]:hover { 422 + background: #0001; 423 + } 424 + #editlinks td input[type="text"]:focus { 425 + outline: 1px solid #808080; 426 + } 427 + 428 + /* 429 + * RELEASE TRACKS 430 + */ 431 + 432 + .card.tracks .track { 433 + margin-bottom: 1em; 434 + padding: 1em; 435 + display: flex; 436 + flex-direction: column; 437 + gap: .5em; 438 + 439 + border-radius: .5em; 440 + background: #f8f8f8f8; 441 + border: 1px solid #808080; 442 + } 443 + 444 + .card.tracks h2.track-title { 445 + margin: 0; 446 + display: flex; 447 + gap: .5em; 448 + } 449 + 450 + .card.tracks h2.track-title .track-number { 451 + opacity: .5; 452 + } 453 + 454 + .card.tracks .track-album { 455 + margin-left: auto; 456 + font-style: italic; 457 + font-size: .75em; 458 + opacity: .5; 459 + } 460 + 461 + .card.tracks .track-album.empty { 462 + color: #ff2020; 463 + opacity: 1; 464 + } 465 + 466 + .card.tracks .track-description { 467 + font-style: italic; 468 + } 469 + 470 + .card.tracks .track-lyrics { 471 + max-height: 10em; 472 + overflow-y: scroll; 473 + } 474 + 475 + .card.tracks .track .empty { 476 + opacity: 0.75; 477 + } 478 + 479 + #edittracks ul { 480 + padding: 0; 481 + list-style: none; 482 + } 483 + 484 + #edittracks .track { 485 + transition: transform .2s ease-out, opacity .2s; 486 + } 487 + 488 + #edittracks .track.moving { 489 + transform: scale(1.05); 490 + opacity: .5; 491 + } 492 + 493 + #edittracks .track div { 494 + padding: .5em; 495 + display: flex; 496 + flex-direction: row; 497 + justify-content: space-between; 498 + align-items: center; 499 + cursor: pointer; 500 + } 501 + 502 + #edittracks .track div:active { 503 + cursor: move; 504 + } 505 + 506 + #edittracks .track:nth-child(even) { 507 + background: #f0f0f0; 508 + } 509 + 510 + #edittracks .track-number { 511 + min-width: 1em; 512 + display: inline-block; 513 + opacity: .5; 514 + } 515 + 516 + #edittracks .track-name { 517 + margin: 0; 518 + } 519 + 520 + #addtrack ul { 521 + padding: 0; 522 + list-style: none; 523 + background: #f8f8f8; 524 + } 525 + 526 + #addtrack ul li.new-track { 527 + padding: .5em; 528 + display: flex; 529 + gap: .5em; 530 + cursor: pointer; 531 + } 532 + 533 + #addtrack ul li.new-track:nth-child(even) { 534 + background: #f0f0f0; 535 + } 536 + 537 + #addtrack ul li.new-track:hover { 538 + background: #e0e0e0; 539 + }
+27 -11
admin/static/edit-release.js
··· 1 1 import Stateful from "/script/silver.min.js" 2 2 3 3 const releaseID = document.getElementById("release").dataset.id; 4 - const artwork_input = document.getElementById("artwork"); 4 + const title_input = document.getElementById("title"); 5 + const artwork_img = document.getElementById("artwork"); 6 + const artwork_input = document.getElementById("artwork-file"); 5 7 const type_input = document.getElementById("type"); 6 8 const desc_input = document.getElementById("description"); 7 9 const date_input = document.getElementById("release-date"); ··· 10 12 const vis_input = document.getElementById("visibility"); 11 13 const save_btn = document.getElementById("save"); 12 14 13 - let token = atob(localStorage.getItem("arime-token")); 15 + var artwork_data = artwork_img.attributes.src.value; 14 16 15 - let edited = new Stateful(false); 17 + var token = atob(localStorage.getItem("arime-token")); 18 + 19 + var edited = new Stateful(false); 16 20 17 - let release_data = update_data(undefined); 21 + var release_data = update_data(undefined); 18 22 19 23 function update_data(old) { 20 - let release_data = { 24 + var release_data = { 21 25 visible: vis_input.value === "true", 22 - title: undefined, 26 + title: title_input.value, 23 27 description: desc_input.value, 24 28 type: type_input.value, 25 29 releaseDate: date_input.value, 26 - artwork: artwork_input.attributes.src.value, 30 + artwork: artwork_data, 27 31 buyname: buyname_input.value, 28 32 buylink: buylink_input.value, 29 33 }; ··· 38 42 function save_release() { 39 43 console.table(release_data); 40 44 41 - edited.set(false); 42 - 43 45 (async () => { 44 46 const res = await fetch( 45 47 "/api/v1/music/" + releaseID, { ··· 61 63 location = location; 62 64 })(); 63 65 } 64 - window.save_release = save_release; 65 66 66 67 edited.onUpdate(edited => { 67 68 save_btn.disabled = !edited; 68 69 }) 69 70 70 - artwork_input.addEventListener("click", () => { 71 + title_input.addEventListener("change", () => { 71 72 release_data = update_data(release_data); 73 + }); 74 + artwork_img.addEventListener("click", () => { 75 + artwork_input.addEventListener("change", () => { 76 + if (artwork_input.files.length > 0) { 77 + const reader = new FileReader(); 78 + reader.onload = e => { 79 + const data = e.target.result; 80 + artwork_img.src = data; 81 + artwork_data = data; 82 + release_data = update_data(release_data); 83 + }; 84 + reader.readAsDataURL(artwork_input.files[0]); 85 + } 86 + }); 87 + artwork_input.click(); 72 88 }); 73 89 type_input.addEventListener("change", () => { 74 90 release_data = update_data(release_data);
+24
admin/static/index.js
··· 1 + const newReleaseBtn = document.getElementById("create-release"); 2 + 3 + newReleaseBtn.addEventListener("click", event => { 4 + event.preventDefault(); 5 + const id = prompt("Enter an ID for this release:"); 6 + if (id == null || id == "") return; 7 + 8 + fetch("/api/v1/music", { 9 + method: "POST", 10 + headers: { "Content-Type": "application/json" }, 11 + body: JSON.stringify({id}) 12 + }).then(res => { 13 + if (res.ok) location = "/admin/release/" + id; 14 + else { 15 + res.text().then(err => { 16 + alert("Request failed: " + err); 17 + console.error(err); 18 + }); 19 + } 20 + }).catch(err => { 21 + alert("Failed to create release. Check the console for details."); 22 + console.error(err); 23 + }); 24 + });
-305
admin/static/release.css
··· 1 - #release { 2 - margin-bottom: 1em; 3 - padding: 1.5em; 4 - display: flex; 5 - flex-direction: row; 6 - gap: 1.2em; 7 - 8 - border-radius: .5em; 9 - background: #f8f8f8f8; 10 - border: 1px solid #808080; 11 - } 12 - 13 - .release-artwork { 14 - width: 200px; 15 - 16 - display: flex; 17 - justify-content: center; 18 - align-items: start; 19 - } 20 - 21 - .release-artwork img { 22 - width: 100%; 23 - aspect-ratio: 1; 24 - } 25 - .release-artwork img:hover { 26 - outline: 1px solid #808080; 27 - cursor: pointer; 28 - } 29 - 30 - .release-info { 31 - margin: 0; 32 - flex-grow: 1; 33 - display: flex; 34 - flex-direction: column; 35 - } 36 - 37 - .release-title { 38 - margin: 0; 39 - } 40 - 41 - .release-title small { 42 - opacity: .75; 43 - } 44 - 45 - .release-info table { 46 - width: 100%; 47 - margin: .5em 0; 48 - border-collapse: collapse; 49 - } 50 - .release-info table td { 51 - padding: .2em; 52 - border-bottom: 1px solid #d0d0d0; 53 - } 54 - .release-info table tr td:first-child { 55 - vertical-align: top; 56 - opacity: .66; 57 - } 58 - .release-info table tr td:not(:first-child):hover { 59 - background: #e8e8e8; 60 - cursor: pointer; 61 - } 62 - .release-info table td select, 63 - .release-info table td input, 64 - .release-info table td textarea { 65 - padding: .2em; 66 - resize: none; 67 - width: 100%; 68 - font-family: inherit; 69 - font-size: inherit; 70 - color: inherit; 71 - border: none; 72 - background: none; 73 - outline: none; 74 - } 75 - .release-info table td:has(select), 76 - .release-info table td:has(input), 77 - .release-info table td:has(textarea) { 78 - padding: 0; 79 - } 80 - 81 - button, .button { 82 - padding: .5em .8em; 83 - font-family: inherit; 84 - font-size: inherit; 85 - border-radius: .5em; 86 - border: 1px solid #a0a0a0; 87 - background: #f0f0f0; 88 - color: inherit; 89 - } 90 - button:hover, .button:hover { 91 - background: #fff; 92 - border-color: #d0d0d0; 93 - } 94 - button:active, .button:active { 95 - background: #d0d0d0; 96 - border-color: #808080; 97 - } 98 - 99 - button { 100 - color: inherit; 101 - } 102 - button.new { 103 - background: #c4ff6a; 104 - border-color: #84b141; 105 - } 106 - button.save { 107 - background: #6fd7ff; 108 - border-color: #6f9eb0; 109 - } 110 - button.delete { 111 - background: #ff7171; 112 - border-color: #7d3535; 113 - } 114 - button:hover { 115 - background: #fff; 116 - border-color: #d0d0d0; 117 - } 118 - button:active { 119 - background: #d0d0d0; 120 - border-color: #808080; 121 - } 122 - button[disabled] { 123 - background: #d0d0d0 !important; 124 - border-color: #808080 !important; 125 - opacity: .5; 126 - cursor: not-allowed !important; 127 - } 128 - 129 - .release-actions { 130 - margin-top: auto; 131 - display: flex; 132 - gap: .5em; 133 - flex-direction: row; 134 - justify-content: right; 135 - } 136 - 137 - .card.credits .credit { 138 - margin-bottom: .5em; 139 - padding: .5em; 140 - display: flex; 141 - flex-direction: row; 142 - align-items: center; 143 - gap: 1em; 144 - 145 - border-radius: .5em; 146 - background: #f8f8f8f8; 147 - border: 1px solid #808080; 148 - } 149 - 150 - .card.credits .credit .artist-avatar { 151 - border-radius: .5em; 152 - } 153 - 154 - .card.credits .credit .artist-name { 155 - font-weight: bold; 156 - } 157 - 158 - .card.credits .credit .artist-role small { 159 - font-size: inherit; 160 - opacity: .66; 161 - } 162 - 163 - .track { 164 - margin-bottom: 1em; 165 - padding: 1em; 166 - display: flex; 167 - flex-direction: column; 168 - gap: .5em; 169 - 170 - border-radius: .5em; 171 - background: #f8f8f8f8; 172 - border: 1px solid #808080; 173 - } 174 - 175 - .card h2.track-title { 176 - margin: 0; 177 - display: flex; 178 - flex-direction: row; 179 - justify-content: space-between; 180 - } 181 - 182 - .card-title a.button { 183 - text-decoration: none; 184 - } 185 - 186 - .track-id { 187 - width: fit-content; 188 - font-family: "Monaspace Argon", monospace; 189 - font-size: .8em; 190 - font-style: italic; 191 - line-height: 1em; 192 - user-select: all; 193 - -webkit-user-select: all; 194 - } 195 - 196 - .track-album { 197 - margin-left: auto; 198 - font-style: italic; 199 - font-size: .75em; 200 - opacity: .5; 201 - } 202 - 203 - .track-album.empty { 204 - color: #ff2020; 205 - opacity: 1; 206 - } 207 - 208 - .track-description { 209 - font-style: italic; 210 - } 211 - 212 - .track-lyrics { 213 - max-height: 10em; 214 - overflow-y: scroll; 215 - } 216 - 217 - .track .empty { 218 - opacity: 0.75; 219 - } 220 - 221 - dialog { 222 - width: min(720px, calc(100% - 2em)); 223 - padding: 2em; 224 - border: 1px solid #101010; 225 - border-radius: 8px; 226 - } 227 - 228 - dialog header { 229 - margin-bottom: 1em; 230 - background: none; 231 - display: flex; 232 - flex-direction: row; 233 - justify-content: space-between; 234 - } 235 - 236 - dialog header h2 { 237 - margin: 0; 238 - } 239 - 240 - dialog div.dialog-actions { 241 - margin-top: 1em; 242 - display: flex; 243 - flex-direction: row; 244 - justify-content: end; 245 - gap: .5em; 246 - } 247 - 248 - dialog#editcredits ul { 249 - margin: 0; 250 - padding: 0; 251 - list-style: none; 252 - } 253 - 254 - dialog#editcredits .credit>div { 255 - margin-bottom: .5em; 256 - padding: .5em; 257 - display: flex; 258 - flex-direction: row; 259 - align-items: center; 260 - gap: 1em; 261 - 262 - border-radius: .5em; 263 - background: #f8f8f8f8; 264 - border: 1px solid #808080; 265 - } 266 - 267 - dialog#editcredits .credit p { 268 - margin: 0; 269 - } 270 - 271 - dialog#editcredits .credit .artist-avatar { 272 - border-radius: .5em; 273 - } 274 - 275 - dialog#editcredits .credit .credit-info { 276 - width: 100%; 277 - } 278 - 279 - dialog#editcredits .credit .credit-info .credit-attribute { 280 - width: 100%; 281 - display: flex; 282 - } 283 - 284 - dialog#editcredits .credit .credit-info .credit-attribute input[type="text"] { 285 - margin-left: .25em; 286 - padding: .2em .4em; 287 - flex-grow: 1; 288 - font-family: inherit; 289 - border: 1px solid #8888; 290 - border-radius: 4px; 291 - color: inherit; 292 - } 293 - 294 - dialog#editcredits .credit .artist-name { 295 - font-weight: bold; 296 - } 297 - 298 - dialog#editcredits .credit .artist-role small { 299 - font-size: inherit; 300 - opacity: .66; 301 - } 302 - 303 - dialog#editcredits .credit button.delete { 304 - margin-left: auto; 305 - }
+13 -14
admin/views/edit-release.html
··· 2 2 <title>editing {{.Title}} - ari melody 💫</title> 3 3 <link rel="shortcut icon" href="{{.GetArtwork}}" type="image/x-icon"> 4 4 5 - <link rel="stylesheet" href="/admin/static/release.css"> 5 + <link rel="stylesheet" href="/admin/static/edit-release.css"> 6 6 {{end}} 7 7 8 8 {{define "content"}} ··· 11 11 <div id="release" data-id="{{.ID}}"> 12 12 <div class="release-artwork"> 13 13 <img src="{{.Artwork}}" alt="" width="256" loading="lazy" id="artwork"> 14 + <input type="file" id="artwork-file" name="Artwork" accept=".png,.jpg,.jpeg" hidden> 14 15 </div> 15 16 <div class="release-info"> 16 17 <h1 class="release-title"> 17 - <!-- <input type="text" name="Title" value="{{.Title}}"> --> 18 - <span id="title" editable="true">{{.Title}}</span> 19 - <small>{{.GetReleaseYear}}</small> 18 + <input type="text" id="title" name="Title" value="{{.Title}}"> 20 19 </h1> 21 20 <table> 22 - <tr> 23 - <td>Artists</td> 24 - <td>{{.PrintArtists true true}}</td> 25 - </tr> 26 21 <tr> 27 22 <td>Type</td> 28 23 <td> ··· 50 45 name="Description" 51 46 value="{{.Description}}" 52 47 placeholder="No description provided." 53 - rows="3" 48 + rows="1" 54 49 id="description" 55 50 >{{.Description}}</textarea> 56 51 </td> ··· 130 125 </div> 131 126 <div class="card links"> 132 127 {{range .Links}} 133 - <div class="release-link" data-id="{{.Name}}"> 134 - <a href="{{.URL}}" class="button">{{.Name}} <img src="/img/external-link.svg"/></a></p> 135 - </div> 128 + <a href="{{.URL}}" target="_blank" class="button" data-name="{{.Name}}">{{.Name}} <img src="/img/external-link.svg"/></a> 136 129 {{end}} 137 130 </div> 138 131 ··· 148 141 <div class="card tracks"> 149 142 {{range .Tracks}} 150 143 <div class="track" data-id="{{.ID}}"> 151 - <h2 class="track-title">{{.Number}}. {{.Title}}</h2> 152 - <p class="track-id">{{.ID}}</p> 144 + <h2 class="track-title"> 145 + <span class="track-number">{{.Number}}</span> 146 + <a href="/admin/track/{{.ID}}">{{.Title}}</a> 147 + </h2> 148 + 149 + <h3>Description</h3> 153 150 {{if .Description}} 154 151 <p class="track-description">{{.Description}}</p> 155 152 {{else}} 156 153 <p class="track-description empty">No description provided.</p> 157 154 {{end}} 155 + 156 + <h3>Lyrics</h3> 158 157 {{if .Lyrics}} 159 158 <p class="track-lyrics">{{.Lyrics}}</p> 160 159 {{else}}
+3 -2
admin/views/index.html
··· 9 9 10 10 <div class="card-title"> 11 11 <h1>Releases</h1> 12 - <a href="/admin/createrelease" class="create-btn">Create New</a> 12 + <a href="/admin/createrelease" class="create-btn" id="create-release">Create New</a> 13 13 </div> 14 14 <div class="card releases"> 15 15 {{range $Release := .Releases}} ··· 93 93 94 94 </main> 95 95 96 - <script type="module" src="/admin/static/admin.js" defer></script> 96 + <script type="module" src="/admin/static/admin.js"></script> 97 + <script type="module" src="/admin/static/index.js"></script> 97 98 {{end}}
+101 -21
api/release.go
··· 1 1 package api 2 2 3 3 import ( 4 + "bufio" 5 + "encoding/base64" 4 6 "encoding/json" 5 7 "fmt" 8 + "io/fs" 6 9 "net/http" 10 + "os" 11 + "path/filepath" 7 12 "strings" 8 13 "time" 9 14 ··· 85 90 http.Error(w, "Release ID cannot be empty\n", http.StatusBadRequest) 86 91 return 87 92 } 88 - if *data.Title == "" { 89 - http.Error(w, "Release title cannot be empty\n", http.StatusBadRequest) 90 - return 93 + 94 + title := data.ID 95 + if data.Title != nil && *data.Title != "" { 96 + title = *data.Title 91 97 } 92 - if data.Buyname == nil || *data.Buyname == "" { *data.Buyname = "buy" } 93 - if data.Buylink == nil || *data.Buylink == "" { *data.Buylink = "https://arimelody.me" } 94 98 95 - if global.GetRelease(data.ID) != nil { 96 - http.Error(w, fmt.Sprintf("Release %s already exists\n", data.ID), http.StatusBadRequest) 97 - return 98 - } 99 + description := "" 100 + if data.Description != nil && *data.Description != "" { description = *data.Description } 101 + 102 + releaseType := model.Single 103 + if data.ReleaseType != nil && *data.ReleaseType != "" { releaseType = *data.ReleaseType } 99 104 100 105 releaseDate := time.Time{} 101 - if *data.ReleaseDate == "" { 102 - http.Error(w, "Release date cannot be empty\n", http.StatusBadRequest) 103 - return 104 - } else if data.ReleaseDate != nil { 106 + if data.ReleaseDate != nil && *data.ReleaseDate != "" { 105 107 releaseDate, err = time.Parse("2006-01-02T15:04", *data.ReleaseDate) 106 108 if err != nil { 107 109 http.Error(w, "Invalid release date", http.StatusBadRequest) 108 110 return 109 111 } 112 + } else { 113 + releaseDate = time.Date(time.Now().Year(), time.Now().Month(), time.Now().Day(), 0, 0, 0, 0, time.UTC) 114 + } 115 + 116 + artwork := "/img/default-cover-art.png" 117 + if data.Artwork != nil && *data.Artwork != "" { artwork = *data.Artwork } 118 + 119 + buyname := "" 120 + if data.Buyname != nil && *data.Buyname != "" { buyname = *data.Buyname } 121 + 122 + buylink := "" 123 + if data.Buylink != nil && *data.Buylink != "" { buylink = *data.Buylink } 124 + 125 + if global.GetRelease(data.ID) != nil { 126 + http.Error(w, fmt.Sprintf("Release %s already exists\n", data.ID), http.StatusBadRequest) 127 + return 110 128 } 111 129 112 130 var release = model.Release{ 113 131 ID: data.ID, 114 - Visible: *data.Visible, 115 - Title: *data.Title, 116 - Description: *data.Description, 117 - ReleaseType: *data.ReleaseType, 132 + Visible: false, 133 + Title: title, 134 + Description: description, 135 + ReleaseType: releaseType, 118 136 ReleaseDate: releaseDate, 119 - Artwork: *data.Artwork, 120 - Buyname: *data.Buyname, 121 - Buylink: *data.Buylink, 137 + Artwork: artwork, 138 + Buyname: buyname, 139 + Buylink: buylink, 122 140 Links: []*model.Link{}, 123 141 Credits: []*model.Credit{}, 124 142 Tracks: []*model.Track{}, ··· 181 199 } 182 200 update.ReleaseDate = newDate 183 201 } 184 - if data.Artwork != nil { update.Artwork = *data.Artwork } 202 + if data.Artwork != nil { 203 + if strings.Contains(*data.Artwork, ";base64,") { 204 + split := strings.Split(*data.Artwork, ";base64,") 205 + header := split[0] 206 + imageData, err := base64.StdEncoding.DecodeString(split[1]) 207 + ext, _ := strings.CutPrefix(header, "data:image/") 208 + 209 + switch ext { 210 + case "png": 211 + case "jpg": 212 + case "jpeg": 213 + default: 214 + http.Error(w, "Invalid image type. Allowed: .png, .jpg, .jpeg", http.StatusBadRequest) 215 + return 216 + } 217 + 218 + artworkDirectory := filepath.Join("uploads", "musicart") 219 + // ensure directory exists 220 + os.MkdirAll(artworkDirectory, os.ModePerm) 221 + 222 + imagePath := filepath.Join(artworkDirectory, fmt.Sprintf("%s.%s", update.ID, ext)) 223 + file, err := os.Create(imagePath) 224 + if err != nil { 225 + fmt.Printf("FATAL: Failed to create file %s: %s\n", imagePath, err) 226 + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 227 + return 228 + } 229 + 230 + defer file.Close() 231 + 232 + buffer := bufio.NewWriter(file) 233 + _, err = buffer.Write(imageData) 234 + if err != nil { 235 + fmt.Printf("FATAL: Failed to write to file %s: %s\n", imagePath, err) 236 + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 237 + return 238 + } 239 + 240 + if err := buffer.Flush(); err != nil { 241 + fmt.Printf("FATAL: Failed to flush data to file %s: %s\n", imagePath, err) 242 + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 243 + return 244 + } 245 + 246 + // clean up files with this ID and different extensions 247 + err = filepath.Walk(artworkDirectory, func(path string, info fs.FileInfo, err error) error { 248 + if path == imagePath { return nil } 249 + 250 + withoutExt := strings.TrimSuffix(path, filepath.Ext(path)) 251 + if withoutExt != filepath.Join(artworkDirectory, update.ID) { return nil } 252 + 253 + return os.Remove(path) 254 + }) 255 + if err != nil { 256 + fmt.Printf("WARN: Error while cleaning up artwork files: %s\n", err) 257 + } 258 + 259 + fmt.Printf("Artwork for %s updated.\n", update.ID) 260 + update.Artwork = fmt.Sprintf("/uploads/musicart/%s.%s", update.ID, ext) 261 + } else { 262 + update.Artwork = *data.Artwork 263 + } 264 + } 185 265 if data.Buyname != nil { 186 266 if *data.Buyname == "" { 187 267 http.Error(w, "Release buy name cannot be empty", http.StatusBadRequest)
+1 -1
music/controller/release.go
··· 47 47 err := db.Select(&track_rows, 48 48 "SELECT track FROM musicreleasetrack "+ 49 49 "WHERE release=$1 "+ 50 - "ORDER BY number DESC", 50 + "ORDER BY number ASC", 51 51 release.ID, 52 52 ) 53 53 if err != nil {
+3 -3
music/view/release.go
··· 86 86 87 87 tracks := []gatewayTrack{} 88 88 for i, track := range release.Tracks { 89 - tracks = append([]gatewayTrack{{ 89 + tracks = append(tracks, gatewayTrack{ 90 90 Track: track, 91 91 Lyrics: template.HTML(strings.Replace(track.Lyrics, "\n", "<br>", -1)), 92 - Number: len(release.Tracks) - i, 93 - }}, tracks...) 92 + Number: i + 1, 93 + }) 94 94 } 95 95 96 96 lrw := global.LoggingResponseWriter{ResponseWriter: w, Code: http.StatusOK}
+6
public/img/list-grabber.svg
··· 1 + <?xml version="1.0" encoding="UTF-8" standalone="no"?> 2 + <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> 3 + <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;"> 4 + <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"/> 5 + <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"/> 6 + </svg>
res/list-grabber.afdesign

This is a binary file and will not be displayed.

res/list-grabber.afdesign~lock~

This is a binary file and will not be displayed.

+2
views/music-gateway.html
··· 64 64 <p>Releases: {{.PrintReleaseDate}}</p> 65 65 {{end}} 66 66 67 + {{if .IsReleased}} 67 68 <ul id="links"> 68 69 {{if .Buylink}} 69 70 <li> ··· 77 78 </li> 78 79 {{end}} 79 80 </ul> 81 + {{end}} 80 82 81 83 {{if .Description}} 82 84 <p id="description">
+2
views/music.html
··· 38 38 </h1> 39 39 <h2 class="music-artist">{{$Release.PrintArtists true true}}</h2> 40 40 <h3 class="music-type-{{$Release.ReleaseType}}">{{$Release.ReleaseType}}</h3> 41 + {{if $Release.IsReleased}} 41 42 <ul class="music-links"> 42 43 {{range $Link := $Release.Links}} 43 44 <li> ··· 45 46 </li> 46 47 {{end}} 47 48 </ul> 49 + {{end}} 48 50 </div> 49 51 </div> 50 52 {{end}}