Simple mobile support
This commit is contained in:
parent
924c7395cf
commit
852a053e2b
17 changed files with 207 additions and 93 deletions
|
@ -1,7 +1,9 @@
|
||||||
|
let lastWidth = 0
|
||||||
function movePlayer() {
|
function movePlayer() {
|
||||||
const pc = document.getElementById("player-container")
|
const pc = document.getElementById("player-container")
|
||||||
const playerExists = pc.querySelector("iframe")
|
const playerExists = pc.querySelector("iframe")
|
||||||
if (!playerExists) return
|
if (!playerExists) return
|
||||||
|
lastWidth = window.innerWidth
|
||||||
const pm = document.getElementById("player-marker")
|
const pm = document.getElementById("player-marker")
|
||||||
pm.style.display = "block"
|
pm.style.display = "block"
|
||||||
pm.style.height = `${pc.clientHeight}px`
|
pm.style.height = `${pc.clientHeight}px`
|
||||||
|
@ -9,12 +11,20 @@ function movePlayer() {
|
||||||
}
|
}
|
||||||
movePlayer()
|
movePlayer()
|
||||||
document.body.addEventListener("htmx:load", movePlayer)
|
document.body.addEventListener("htmx:load", movePlayer)
|
||||||
|
window.addEventListener("resize", () => {
|
||||||
|
if ((window.innerWidth > 980 && lastWidth < 980) || (window.innerWidth < 980 && lastWidth > 980)) {
|
||||||
|
showPlayer()
|
||||||
|
movePlayer()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
function addPopoverStyle() {
|
function addPopoverStyle() {
|
||||||
document.querySelectorAll("[popovertarget]").forEach(e => {
|
document.querySelectorAll("[popovertarget]").forEach(e => {
|
||||||
e.addEventListener("click", () => {
|
e.addEventListener("click", () => {
|
||||||
const rect = e.getBoundingClientRect()
|
const rect = e.getBoundingClientRect()
|
||||||
const t = `:popover-open { position: fixed; top: ${Math.floor(rect.bottom)}px; left: ${Math.floor(rect.left + rect.width / 2)}px; width: ${Math.floor(rect.width + 85)}px; transform: translateX(-50%); margin: 0 }`
|
const width = Math.floor(rect.width + 85)
|
||||||
|
const left = Math.max(Math.floor(rect.left + rect.width / 2), width / 2)
|
||||||
|
const t = `:popover-open { position: fixed; top: ${Math.floor(rect.bottom)}px; left: ${left}px; width: ${width}px; transform: translateX(-50%); margin: 0 }`
|
||||||
document.styleSheets[0].insertRule(t, document.styleSheets[0].cssRules.length)
|
document.styleSheets[0].insertRule(t, document.styleSheets[0].cssRules.length)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -23,9 +33,32 @@ addPopoverStyle()
|
||||||
document.body.addEventListener("htmx:load", addPopoverStyle)
|
document.body.addEventListener("htmx:load", addPopoverStyle)
|
||||||
|
|
||||||
document.body.addEventListener("htmx:beforeHistoryUpdate", o => {
|
document.body.addEventListener("htmx:beforeHistoryUpdate", o => {
|
||||||
console.log("beforeHistoryUpdate:", o)
|
|
||||||
const page = document.getElementById("page")
|
const page = document.getElementById("page")
|
||||||
if (o?.detail?.requestConfig?.target === page) {
|
if (o?.detail?.requestConfig?.target === page) {
|
||||||
while (page.firstChild) page.firstChild.remove()
|
while (page.firstChild) page.firstChild.remove()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function hidePlayer() {
|
||||||
|
document.getElementById("player-container").style.visibility = "hidden"
|
||||||
|
document.getElementById("toggle-player").setAttribute("aria-pressed", "false")
|
||||||
|
document.getElementById("toggle-player").classList.remove("is-selected")
|
||||||
|
}
|
||||||
|
function showPlayer() {
|
||||||
|
document.getElementById("player-container").style.visibility = "visible"
|
||||||
|
document.getElementById("toggle-player").setAttribute("aria-pressed", "true")
|
||||||
|
document.getElementById("toggle-player").classList.add("is-selected")
|
||||||
|
}
|
||||||
|
function togglePlayer() {
|
||||||
|
const pc = document.getElementById("player-container")
|
||||||
|
if (pc.style.visibility === "visible") hidePlayer()
|
||||||
|
else showPlayer()
|
||||||
|
}
|
||||||
|
function setupTogglePlayerButton(event) {
|
||||||
|
if (event?.target?.id === "player") showPlayer()
|
||||||
|
else hidePlayer()
|
||||||
|
document.getElementById("toggle-player").removeEventListener("click", togglePlayer)
|
||||||
|
document.getElementById("toggle-player").addEventListener("click", togglePlayer)
|
||||||
|
}
|
||||||
|
setupTogglePlayerButton()
|
||||||
|
document.body.addEventListener("htmx:load", setupTogglePlayerButton)
|
||||||
|
|
|
@ -1,5 +1,17 @@
|
||||||
.ws340 {
|
.ws340 {
|
||||||
width: 340px;
|
width: 340px;
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
@media screen and (max-width: 980px) {
|
||||||
|
.ws340 {
|
||||||
|
width: calc(100% - 32px);
|
||||||
|
max-width: 700px; /* bandcamp iframe body has this max-width */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media screen and (min-width: 981px) {
|
||||||
|
#player-container {
|
||||||
|
visibility: visible !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.themed {
|
.themed {
|
||||||
--theme-base-primary-color-h: 191;
|
--theme-base-primary-color-h: 191;
|
||||||
|
@ -22,6 +34,12 @@
|
||||||
.s-navigation__toggle.s-navigation {
|
.s-navigation__toggle.s-navigation {
|
||||||
--_na-item-bg: var(--black-150);
|
--_na-item-bg: var(--black-150);
|
||||||
}
|
}
|
||||||
|
@media screen and (max-width: 450px) {
|
||||||
|
.s-navigation__toggle.s-navigation .is-selected svg {
|
||||||
|
visibility: hidden;
|
||||||
|
width: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
.duration-last-col td:last-child {
|
.duration-last-col td:last-child {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
white-space: pre;
|
white-space: pre;
|
||||||
|
@ -48,6 +66,28 @@ button.s-link.is-loading {
|
||||||
.s-btn__dropdown:has(+ :popover-open) {
|
.s-btn__dropdown:has(+ :popover-open) {
|
||||||
background-color: var(--theme-topbar-item-background-hover, var(--black-200)) !important;
|
background-color: var(--theme-topbar-item-background-hover, var(--black-200)) !important;
|
||||||
}
|
}
|
||||||
|
@media screen and (max-width: 700px) {
|
||||||
|
.preview-cover:nth-of-type(n + 5) {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media screen and (max-width: 550px) {
|
||||||
|
.preview-cover:nth-of-type(n + 3) {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.album-grid {
|
||||||
|
grid-template-columns: repeat(auto-fit, 210px)
|
||||||
|
}
|
||||||
|
@media screen and (max-width: 980px) {
|
||||||
|
.album-grid {
|
||||||
|
grid-template-columns: repeat(auto-fit, 150px);
|
||||||
|
}
|
||||||
|
.cover img {
|
||||||
|
width: 150px;
|
||||||
|
height: 150px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* album covers are done with styles instead of attributes to reduce bytes of html needing to be downloaded and parsed */
|
/* album covers are done with styles instead of attributes to reduce bytes of html needing to be downloaded and parsed */
|
||||||
.cover {
|
.cover {
|
||||||
|
@ -71,17 +111,17 @@ button.s-link.is-loading {
|
||||||
top: 50%;
|
top: 50%;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
transition: 1s ease-out;
|
transition: opacity 1s ease-out;
|
||||||
color: var(--black);
|
color: var(--black);
|
||||||
}
|
}
|
||||||
.cover:hover svg {
|
.cover:hover svg {
|
||||||
transition: 1.5s ease-out 0.7s;
|
transition: opacity 1.5s ease-out 0.7s;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
.cover img {
|
.cover img {
|
||||||
transition: 1s ease-out;
|
transition: opacity 1s ease-out;
|
||||||
}
|
}
|
||||||
.cover:hover img {
|
.cover:hover img {
|
||||||
transition: 2s ease-out 0.7s;
|
transition: opacity 2s ease-out 0.7s;
|
||||||
opacity: 0.3;
|
opacity: 0.3;
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,7 +6,7 @@ block title
|
||||||
block view
|
block view
|
||||||
.mx-auto.w100.wmx11.fs-body1#content
|
.mx-auto.w100.wmx11.fs-body1#content
|
||||||
!= icons.useTemplate(["star-solid", "play-solid", "music-note", "compact-disc", "people-tag", "flower"])
|
!= icons.useTemplate(["star-solid", "play-solid", "music-note", "compact-disc", "people-tag", "flower"])
|
||||||
.d-grid.gx8.gy12.jc-center.break-word(style="grid-template-columns: repeat(auto-fit, 210px)")
|
.d-grid.gx8.gy12.jc-center.break-word.album-grid
|
||||||
each item in items
|
each item in items
|
||||||
div
|
div
|
||||||
a.cover&attributes(getAlbumCoverAttributes(event, item))
|
a.cover&attributes(getAlbumCoverAttributes(event, item))
|
||||||
|
@ -16,7 +16,7 @@ block view
|
||||||
p= item.item_title
|
p= item.item_title
|
||||||
.d-flex.fw-wrap.g4
|
.d-flex.fw-wrap.g4
|
||||||
if item.why
|
if item.why
|
||||||
a.s-tag.s-tag__xs.fc-orange-400(title=item.why href=and({filter_field: "why", filter: "reviewed"}))
|
a.s-tag.s-tag__xs.fc-orange-400(title=(item.why + (item.featured_track_title ? ` -- favourite track: ${item.featured_track_title}` : "")) href=and({filter_field: "why", filter: "reviewed"}))
|
||||||
!= icons.use("star-solid", 16)
|
!= icons.use("star-solid", 16)
|
||||||
if hasFullTrackData
|
if hasFullTrackData
|
||||||
a.s-tag.s-tag__xs(href=and({arrange: "track", filter_field: "item_id", filter: item.item_id, filter_fuzzy: null}))
|
a.s-tag.s-tag__xs(href=and({arrange: "track", filter_field: "item_id", filter: item.item_id, filter_fuzzy: null}))
|
||||||
|
|
|
@ -10,7 +10,7 @@ block view
|
||||||
each item in items
|
each item in items
|
||||||
.d-flex.g4
|
.d-flex.g4
|
||||||
.fl-grow1.pb12
|
.fl-grow1.pb12
|
||||||
.fs-headline1= item.band_name
|
.fs-headline1.break-word= item.band_name
|
||||||
.d-flex.fw-wrap.g4
|
.d-flex.fw-wrap.g4
|
||||||
a.s-tag.s-tag__xs(href=and({arrange: "album", filter_field: "band_name", filter: item.band_name, filter_fuzzy: null}))
|
a.s-tag.s-tag__xs(href=and({arrange: "album", filter_field: "band_name", filter: item.band_name, filter_fuzzy: null}))
|
||||||
span.s-tag--sponsor!= icons.use("album", 16)
|
span.s-tag--sponsor!= icons.use("album", 16)
|
||||||
|
@ -28,5 +28,5 @@ block view
|
||||||
span.s-tag--sponsor!= icons.use("flower", 16)
|
span.s-tag--sponsor!= icons.use("flower", 16)
|
||||||
= label
|
= label
|
||||||
each preview in item.previews
|
each preview in item.previews
|
||||||
a.d-flex&attributes(getAlbumCoverAttributes(event, preview))
|
a.d-flex.preview-cover(title=preview.item_title)&attributes(getAlbumCoverAttributes(event, preview))
|
||||||
img(loading="lazy" src=preview.item_art_url width=210 height=210 style="height: 70px; width: 70px;")
|
img(loading="lazy" src=preview.item_art_url width=210 height=210 style="height: 70px; width: 70px;")
|
||||||
|
|
5
pug/collection-stats.pug
Normal file
5
pug/collection-stats.pug
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
extends includes/layout.pug
|
||||||
|
|
||||||
|
block view
|
||||||
|
.mx-auto.w100.wmx11.fs-body1#content
|
||||||
|
include includes/collection-stats.pug
|
|
@ -5,7 +5,7 @@ html
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
title BC Explorer
|
title BC Explorer
|
||||||
link(rel="icon" href="/favicon.png")
|
link(rel="icon" href="/favicon.png")
|
||||||
link(rel="stylesheet" type="text/css" href="/static/stacks.min.css")
|
link(rel="stylesheet" type="text/css" href="/static/stacks.css")
|
||||||
script(src="/static/htmx.js")
|
script(src="/static/htmx.js")
|
||||||
meta(name="htmx-config" content='{"requestClass":"is-loading"}')
|
meta(name="htmx-config" content='{"requestClass":"is-loading"}')
|
||||||
body.themed.theme-system.overflow-y-scroll
|
body.themed.theme-system.overflow-y-scroll
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
mixin navi(key, value, icon, text)
|
mixin navi(key, value, icon, text)
|
||||||
a.s-navigation--item(href=and({[key]: value}) class={"is-selected": query[key] === value})&attributes(attributes)
|
a.s-navigation--item(href="./" + and({shape: query && query.shape || "grid", [key]: value}) class={"is-selected": query && query[key] === value})&attributes(attributes)
|
||||||
if icon
|
if icon
|
||||||
!= icons.get(icon)
|
!= icons.get(icon)
|
||||||
if text
|
if text
|
||||||
|
@ -19,7 +19,7 @@ html
|
||||||
- title = `${searchText} | ${title}`
|
- title = `${searchText} | ${title}`
|
||||||
title#title= title
|
title#title= title
|
||||||
link(rel="icon" href="/favicon.png")
|
link(rel="icon" href="/favicon.png")
|
||||||
link(rel="stylesheet" type="text/css" href="/static/stacks.min.css")
|
link(rel="stylesheet" type="text/css" href="/static/stacks.css")
|
||||||
link(rel="stylesheet" type="text/css" href="/static/style.css")
|
link(rel="stylesheet" type="text/css" href="/static/style.css")
|
||||||
script(src="/static/htmx.js")
|
script(src="/static/htmx.js")
|
||||||
script(src="/static/wordcloud.js")
|
script(src="/static/wordcloud.js")
|
||||||
|
@ -60,7 +60,7 @@ html
|
||||||
each currency in currencies
|
each currency in currencies
|
||||||
option(selected=(currency === count.displayCurrency))= currency
|
option(selected=(currency === count.displayCurrency))= currency
|
||||||
.fl-grow1
|
.fl-grow1
|
||||||
nav
|
nav.d-block(class="md:d-none")
|
||||||
ul.s-navigation
|
ul.s-navigation
|
||||||
li: +navi("arrange", "album", "album", "Album")
|
li: +navi("arrange", "album", "album", "Album")
|
||||||
li: +navi("arrange", "artist", "people-tag", "Artist")
|
li: +navi("arrange", "artist", "people-tag", "Artist")
|
||||||
|
@ -68,16 +68,38 @@ html
|
||||||
//- asana, flower, component, circle-spark, rhombus, sphere, union-alt, color-wheel, community, combine
|
//- asana, flower, component, circle-spark, rhombus, sphere, union-alt, color-wheel, community, combine
|
||||||
li: +navi("arrange", "tag", "label", "Tag")
|
li: +navi("arrange", "tag", "label", "Tag")
|
||||||
li: +navi("arrange", "track", "music-note", "Track")
|
li: +navi("arrange", "track", "music-note", "Track")
|
||||||
.px16
|
li.d-none(class="md:d-block")
|
||||||
|
a.s-navigation--item(href="collection-stats" class={"is-selected": isStatsPage})
|
||||||
|
!= icons.get("graph-up")
|
||||||
|
span.ml4 Stats
|
||||||
|
ul.s-navigation.s-navigation__toggle.d-none(class="md:d-block")
|
||||||
|
button.s-navigation--item.s-navigation--item__dropdown(popovertarget="arranges")
|
||||||
|
!= icons.get("lens")
|
||||||
|
span.ml4 Arrange
|
||||||
|
#arranges(popover data-popper-placement="bottom" style="display: revert;").s-popover.overflow-visible
|
||||||
|
.s-popover--arrow.s-popover--arrow__tc
|
||||||
|
ul.s-navigation.s-navigation__vertical
|
||||||
|
li: +navi("arrange", "album", "album", "Album")
|
||||||
|
li: +navi("arrange", "artist", "people-tag", "Artist")
|
||||||
|
li: +navi("arrange", "label", "flower", "Label")
|
||||||
|
li: +navi("arrange", "tag", "label", "Tag")
|
||||||
|
li: +navi("arrange", "track", "music-note", "Track")
|
||||||
|
li
|
||||||
|
a.s-navigation--item(href="collection-stats" class={"is-selected": isStatsPage})
|
||||||
|
!= icons.get("graph-up")
|
||||||
|
span.ml4 Stats
|
||||||
|
if !isStatsPage
|
||||||
|
.px16(class="md:px4")
|
||||||
nav
|
nav
|
||||||
ul.s-navigation.s-navigation__toggle.g0
|
ul.s-navigation.s-navigation__toggle.g0
|
||||||
li: +navi("shape", "grid").brr0!= icons.get("view-grid")
|
li: +navi("shape", "grid").brr0!= icons.get("view-grid")
|
||||||
li: +navi("shape", "list").blr0!= icons.get("align-justify")
|
li: +navi("shape", "list").blr0!= icons.get("align-justify")
|
||||||
.fl-grow1
|
.fl-grow1
|
||||||
|
button#toggle-player.s-btn.s-btn__xs.d-none.mr4(class="md:d-block")!= icons.get("playlist")
|
||||||
|
|
||||||
.d-flex.py24.px16.g24.fs-body1.fd-row-reverse
|
.d-flex.py24.px16.g24.fs-body1.fd-row-reverse(class="md:fd-column")
|
||||||
aside.ws340.fl-shrink0
|
aside.ws340.fl-shrink0
|
||||||
.ps-fixed.ws340.d-flex.fd-column.g12(style="top: 80px")
|
.ps-fixed.ws340.d-flex.fd-column.g12(class="md:ps-static md:jc-center" style="top: 80px")
|
||||||
if arrange === "tag"
|
if arrange === "tag"
|
||||||
include tag-status.pug
|
include tag-status.pug
|
||||||
|
|
||||||
|
@ -99,14 +121,15 @@ html
|
||||||
input(type="hidden" name="shape" value=shape)
|
input(type="hidden" name="shape" value=shape)
|
||||||
button.s-btn.s-btn__xs.s-btn__icon.s-btn__outlined.s-btn__muted#search-submit(style="height: 38px")!= icons.get("search")
|
button.s-btn.s-btn__xs.s-btn__icon.s-btn__outlined.s-btn__muted#search-submit(style="height: 38px")!= icons.get("search")
|
||||||
|
|
||||||
#player-marker.pe-none(style="display: none")
|
#player-marker.pe-none(class="md:d-none" style="display: none")
|
||||||
|
|
||||||
#collection-sync.d-none
|
#collection-sync.d-none
|
||||||
|
|
||||||
|
div(class="md:d-none")
|
||||||
include collection-stats.pug
|
include collection-stats.pug
|
||||||
|
|
||||||
main.fl-grow1
|
main.fl-grow1
|
||||||
block view
|
block view
|
||||||
|
|
||||||
#player-container.ps-fixed.r16.ws340(hx-preserve="true")
|
#player-container.ps-fixed.r16.ws340.z-modal(class="md:t64 md:l16 md:r16 md:b16" hx-preserve="true")
|
||||||
#player
|
#player
|
||||||
|
|
|
@ -28,7 +28,7 @@ if downloader.total > 0 || downloader.outcome
|
||||||
!= icons.get("cloud-check")
|
!= icons.get("cloud-check")
|
||||||
.fl-grow1 Tags downloaded.
|
.fl-grow1 Tags downloaded.
|
||||||
- downloader.resolve()
|
- downloader.resolve()
|
||||||
a.s-btn.s-btn__outlined(href="") Refresh
|
a.s-btn.s-btn__outlined(href=and({arrange: "tag"}) hx-boost="true") Refresh
|
||||||
|
|
||||||
else
|
else
|
||||||
.s-notice.s-notice__danger.p8.gx16.pl16.d-flex.ai-center
|
.s-notice.s-notice__danger.p8.gx16.pl16.d-flex.ai-center
|
||||||
|
|
|
@ -12,7 +12,7 @@ block view
|
||||||
.fl-grow1.pb12
|
.fl-grow1.pb12
|
||||||
- let minBandURL = item.band_url.replace(/https?:\/\/(.*?)\.bandcamp\.com.*/, "$1")
|
- let minBandURL = item.band_url.replace(/https?:\/\/(.*?)\.bandcamp\.com.*/, "$1")
|
||||||
- let label = item.display_name.replace(/https?:\/\/(.*?)\.bandcamp\.com.*/, "$1")
|
- let label = item.display_name.replace(/https?:\/\/(.*?)\.bandcamp\.com.*/, "$1")
|
||||||
.fs-headline1= label
|
.fs-headline1.break-word= label
|
||||||
.d-flex.fw-wrap.g4
|
.d-flex.fw-wrap.g4
|
||||||
a.s-tag.s-tag__xs(href=and({arrange: "album", filter_field: "band_url", filter: minBandURL, filter_fuzzy: null}))
|
a.s-tag.s-tag__xs(href=and({arrange: "album", filter_field: "band_url", filter: minBandURL, filter_fuzzy: null}))
|
||||||
span.s-tag--sponsor!= icons.use("album", 16)
|
span.s-tag--sponsor!= icons.use("album", 16)
|
||||||
|
@ -28,5 +28,5 @@ block view
|
||||||
span.s-tag--sponsor!= icons.use("compact-disc", 16)
|
span.s-tag--sponsor!= icons.use("compact-disc", 16)
|
||||||
= item.total_duration
|
= item.total_duration
|
||||||
each preview in item.previews
|
each preview in item.previews
|
||||||
a.d-flex&attributes(getAlbumCoverAttributes(event, preview))
|
a.d-flex.preview-cover(title=preview.item_title)&attributes(getAlbumCoverAttributes(event, preview))
|
||||||
img(loading="lazy" src=preview.item_art_url width=210 height=210 style="height: auto; width: auto; max-height: 70px")
|
img(loading="lazy" src=preview.item_art_url width=210 height=210 style="height: 70px; width: 70px;")
|
||||||
|
|
|
@ -18,7 +18,7 @@ sync.require("./settings")
|
||||||
|
|
||||||
// Files
|
// Files
|
||||||
|
|
||||||
router.get("/static/stacks.min.css", defineEventHandler({
|
router.get("/static/stacks.css", defineEventHandler({
|
||||||
onBeforeResponse: pugSync.compressResponse,
|
onBeforeResponse: pugSync.compressResponse,
|
||||||
handler: async event => {
|
handler: async event => {
|
||||||
handleCacheHeaders(event, {maxAge: 86400})
|
handleCacheHeaders(event, {maxAge: 86400})
|
||||||
|
@ -65,7 +65,7 @@ router.get("/static/player-marker.js", defineEventHandler({
|
||||||
router.get("/favicon.png", defineEventHandler({
|
router.get("/favicon.png", defineEventHandler({
|
||||||
handler: async event => {
|
handler: async event => {
|
||||||
handleCacheHeaders(event, {maxAge: 86400})
|
handleCacheHeaders(event, {maxAge: 86400})
|
||||||
defaultContentType(event, "text/javascript")
|
defaultContentType(event, "image/png")
|
||||||
return fs.promises.readFile("public/favicon.png")
|
return fs.promises.readFile("public/favicon.png")
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
|
|
|
@ -1,14 +1,13 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
const {z} = require("zod")
|
|
||||||
const {defineEventHandler, getQuery, getValidatedQuery, sendRedirect, createError, getValidatedRouterParams, getCookie} = require("h3")
|
const {defineEventHandler, getQuery, getValidatedQuery, sendRedirect, createError, getValidatedRouterParams, getCookie} = require("h3")
|
||||||
const {router, db, sync, select, from} = require("../passthrough")
|
const {router, db, sync} = require("../passthrough")
|
||||||
|
|
||||||
/** @type {import("../pug-sync")} */
|
/** @type {import("../pug-sync")} */
|
||||||
const pugSync = sync.require("../pug-sync")
|
const pugSync = sync.require("../pug-sync")
|
||||||
|
|
||||||
/** @type {import("./load-tags")} */
|
/** @type {import("./schema")} */
|
||||||
const loadTags = sync.require("./load-tags")
|
const schema = sync.require("./schema")
|
||||||
|
|
||||||
const sqls = {
|
const sqls = {
|
||||||
album_grid: "SELECT item.*, count(*) AS track_count, iif(sum(duration) > 3600, cast(total(duration)/3600 AS INTEGER) || 'h ' || cast(total(duration)/60%60 AS INTEGER) || 'm', cast(total(duration)/60 AS INTEGER) || 'm') AS total_duration FROM item INNER JOIN track USING (account, item_id) {JOIN TAG} WHERE account = ? {WHERE} GROUP BY item_id {ORDER}",
|
album_grid: "SELECT item.*, count(*) AS track_count, iif(sum(duration) > 3600, cast(total(duration)/3600 AS INTEGER) || 'h ' || cast(total(duration)/60%60 AS INTEGER) || 'm', cast(total(duration)/60 AS INTEGER) || 'm') AS total_duration FROM item INNER JOIN track USING (account, item_id) {JOIN TAG} WHERE account = ? {WHERE} GROUP BY item_id {ORDER}",
|
||||||
|
@ -24,7 +23,7 @@ const sqls = {
|
||||||
|
|
||||||
function loadPreviews(locals, field, number, whereClause, account, filter_field, filter, filter_fuzzy) {
|
function loadPreviews(locals, field, number, whereClause, account, filter_field, filter, filter_fuzzy) {
|
||||||
const params = [account, number]
|
const params = [account, number]
|
||||||
let sql = `SELECT ${field}, item_id, item_type, item_url, item_art_url FROM (SELECT ${field}, item_id, item_type, item_url, item_art_url, row_number() OVER (PARTITION BY ${field} ORDER BY purchased DESC) AS row_number FROM item {JOIN TAG} WHERE account = ? {WHERE}) WHERE row_number <= ?`
|
let sql = `SELECT ${field}, item_id, item_title, item_type, item_url, item_art_url FROM (SELECT ${field}, item_title, item_id, item_type, item_url, item_art_url, row_number() OVER (PARTITION BY ${field} ORDER BY purchased DESC) AS row_number FROM item {JOIN TAG} WHERE account = ? {WHERE}) WHERE row_number <= ?`
|
||||||
sql = sql.replace("{WHERE}", whereClause)
|
sql = sql.replace("{WHERE}", whereClause)
|
||||||
if (whereClause) {
|
if (whereClause) {
|
||||||
if (filter_field === "band_url" || filter_fuzzy) {
|
if (filter_field === "band_url" || filter_fuzzy) {
|
||||||
|
@ -69,25 +68,12 @@ pugSync.addGlobals({
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const schema = {
|
|
||||||
query: z.object({
|
|
||||||
arrange: z.enum(["album", "artist", "label", "tag", "track"]),
|
|
||||||
shape: z.enum(["grid", "list"]),
|
|
||||||
filter_field: z.enum(["band_name", "band_url", "item_title", "item_id", "tag", "why"]).optional(),
|
|
||||||
filter: z.string().optional(),
|
|
||||||
filter_fuzzy: z.enum(["true"]).optional()
|
|
||||||
}),
|
|
||||||
params: z.object({
|
|
||||||
account: z.string()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
router.get("/:account/", defineEventHandler({
|
router.get("/:account/", defineEventHandler({
|
||||||
onBeforeResponse: pugSync.compressResponse,
|
onBeforeResponse: pugSync.compressResponse,
|
||||||
handler: async event => {
|
handler: async event => {
|
||||||
|
const {account} = await getValidatedRouterParams(event, schema.schema.account.parse)
|
||||||
try {
|
try {
|
||||||
var {account} = await getValidatedRouterParams(event, schema.params.parse)
|
var {arrange, shape, filter, filter_field, filter_fuzzy} = await getValidatedQuery(event, schema.schema.appQuery.parse)
|
||||||
var {arrange, shape, filter, filter_field, filter_fuzzy} = await getValidatedQuery(event, schema.query.parse)
|
|
||||||
if (filter_field === "why" && arrange !== "album") throw new Error("filter not compatible with arrangement")
|
if (filter_field === "why" && arrange !== "album") throw new Error("filter not compatible with arrangement")
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return sendRedirect(event, "?arrange=album&shape=grid", 302)
|
return sendRedirect(event, "?arrange=album&shape=grid", 302)
|
||||||
|
|
|
@ -1,12 +1,14 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
const {getCookie, defineEventHandler, readValidatedBody, setCookie} = require("h3")
|
const {getCookie, defineEventHandler, readValidatedBody, setCookie, getValidatedRouterParams} = require("h3")
|
||||||
const {z} = require("zod")
|
|
||||||
const {sync, select, db, router} = require("../passthrough")
|
const {sync, select, db, router} = require("../passthrough")
|
||||||
|
|
||||||
/** @type {import("../pug-sync")} */
|
/** @type {import("../pug-sync")} */
|
||||||
const pugSync = sync.require("../pug-sync")
|
const pugSync = sync.require("../pug-sync")
|
||||||
|
|
||||||
|
/** @type {import("./schema")} */
|
||||||
|
const schema = sync.require("./schema")
|
||||||
|
|
||||||
const currencyExchange = new Map([
|
const currencyExchange = new Map([
|
||||||
["AUD", 0.63],
|
["AUD", 0.63],
|
||||||
["BRL", 0.17],
|
["BRL", 0.17],
|
||||||
|
@ -21,6 +23,12 @@ const currencyExchange = new Map([
|
||||||
])
|
])
|
||||||
const currencies = [...currencyExchange.keys()]
|
const currencies = [...currencyExchange.keys()]
|
||||||
|
|
||||||
|
pugSync.beforeInclude("includes/layout.pug", async (from, event, locals) => {
|
||||||
|
return {
|
||||||
|
currencies
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
pugSync.beforeInclude("includes/collection-stats.pug", async (from, event, {account, currency}) => {
|
pugSync.beforeInclude("includes/collection-stats.pug", async (from, event, {account, currency}) => {
|
||||||
let displayCurrency = currency || getCookie(event, "bcex-currency") || ""
|
let displayCurrency = currency || getCookie(event, "bcex-currency") || ""
|
||||||
if (!currencyExchange.has(displayCurrency)) displayCurrency = "NZD"
|
if (!currencyExchange.has(displayCurrency)) displayCurrency = "NZD"
|
||||||
|
@ -44,20 +52,17 @@ pugSync.beforeInclude("includes/collection-stats.pug", async (from, event, {acco
|
||||||
}).reduce((a, c) => a + c, 0)) * currencyRoundTo,
|
}).reduce((a, c) => a + c, 0)) * currencyRoundTo,
|
||||||
displayCurrency,
|
displayCurrency,
|
||||||
taste: db.prepare("with popularity (a) as (select avg(also_collected_count) from item WHERE account = ? group by band_url) select sum(iif(a >= 0 and a < 20, 1, 0)) as cold, sum(iif(a >= 20 and a < 200, 1, 0)) as warm, sum(iif(a >= 200 and a < 2000, 1, 0)) as hot, sum(iif(a >= 2000, 1, 0)) as supernova from popularity").raw().get(account)
|
taste: db.prepare("with popularity (a) as (select avg(also_collected_count) from item WHERE account = ? group by band_url) select sum(iif(a >= 0 and a < 20, 1, 0)) as cold, sum(iif(a >= 20 and a < 200, 1, 0)) as warm, sum(iif(a >= 200 and a < 2000, 1, 0)) as hot, sum(iif(a >= 2000, 1, 0)) as supernova from popularity").raw().get(account)
|
||||||
},
|
}
|
||||||
currencies
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const schema = {
|
router.get("/:account/collection-stats", defineEventHandler(async event => {
|
||||||
currency: z.object({
|
const {account} = await getValidatedRouterParams(event, schema.schema.account.parse)
|
||||||
currency: z.string().regex(/^[A-Z]{3}$/),
|
return pugSync.render(event, "collection-stats.pug", {account, isStatsPage: true})
|
||||||
account: z.string()
|
}))
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
router.post("/api/settings/currency", defineEventHandler(async event => {
|
router.post("/api/settings/currency", defineEventHandler(async event => {
|
||||||
const {currency, account} = await readValidatedBody(event, schema.currency.parse)
|
const {currency, account} = await readValidatedBody(event, schema.schema.postCurrency.parse)
|
||||||
setCookie(event, "bcex-currency", currency)
|
setCookie(event, "bcex-currency", currency)
|
||||||
return pugSync.render(event, "includes/collection-stats.pug", {account, currency})
|
return pugSync.render(event, "includes/collection-stats.pug", {account, currency})
|
||||||
}))
|
}))
|
||||||
|
|
|
@ -1,23 +1,26 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
const assert = require("assert/strict")
|
const assert = require("assert/strict")
|
||||||
const fs = require("fs")
|
|
||||||
const sqlite = require("better-sqlite3")
|
|
||||||
const domino = require("domino")
|
const domino = require("domino")
|
||||||
const {defineEventHandler, readValidatedBody, setCookie, getCookie} = require("h3")
|
const {defineEventHandler, readValidatedBody, setCookie, getCookie} = require("h3")
|
||||||
const {z} = require("zod")
|
|
||||||
|
|
||||||
const {sync, db, router} = require("../passthrough")
|
const {sync, db, router} = require("../passthrough")
|
||||||
|
|
||||||
|
/** @type {import("../pug-sync")} */
|
||||||
const pugSync = sync.require("../pug-sync")
|
const pugSync = sync.require("../pug-sync")
|
||||||
|
|
||||||
/** @type {import("./load-tags")} */
|
/** @type {import("./load-tags")} */
|
||||||
const loadTags = sync.require("./load-tags")
|
const loadTags = sync.require("./load-tags")
|
||||||
|
|
||||||
|
/** @type {import("./schema")} */
|
||||||
|
const schema = sync.require("./schema")
|
||||||
|
|
||||||
async function loadCollection(inputUsername) {
|
async function loadCollection(inputUsername) {
|
||||||
assert.match(inputUsername, /^[a-z0-9_-]+$/)
|
assert.match(inputUsername, /^[a-z0-9_-]+$/)
|
||||||
const html = await fetch(`https://bandcamp.com/${inputUsername}`).then(res => res.text())
|
const html = await fetch(`https://bandcamp.com/${inputUsername}`).then(res => res.text())
|
||||||
const doc = domino.createDocument(html)
|
const doc = domino.createDocument(html)
|
||||||
|
|
||||||
const first = doc.querySelector(".collection-item-container")
|
const first = doc.querySelector(".collection-item-container[data-token]")
|
||||||
assert(first)
|
assert(first)
|
||||||
const token = first.getAttribute("data-token")
|
const token = first.getAttribute("data-token")
|
||||||
assert(token)
|
assert(token)
|
||||||
|
@ -49,6 +52,7 @@ async function loadCollection(inputUsername) {
|
||||||
const preparedItem = db.prepare(`INSERT INTO item (${columns.join(", ")}) VALUES (${columns.map(x => "@" + x).join(", ")}) ON CONFLICT DO UPDATE SET ${upsert_columns.map(x => `${x} = @${x}`).join(", ")}`)
|
const preparedItem = db.prepare(`INSERT INTO item (${columns.join(", ")}) VALUES (${columns.map(x => "@" + x).join(", ")}) ON CONFLICT DO UPDATE SET ${upsert_columns.map(x => `${x} = @${x}`).join(", ")}`)
|
||||||
db.transaction(() => {
|
db.transaction(() => {
|
||||||
for (const item of items.items) {
|
for (const item of items.items) {
|
||||||
|
if (!item.tralbum_type.match(/[at]/)) continue // p=product and s=subscription not supported
|
||||||
preparedItem.run({
|
preparedItem.run({
|
||||||
account,
|
account,
|
||||||
...item,
|
...item,
|
||||||
|
@ -88,12 +92,8 @@ async function loadCollection(inputUsername) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const schema = z.object({
|
|
||||||
account: z.string()
|
|
||||||
})
|
|
||||||
|
|
||||||
router.post("/api/load-collection", defineEventHandler(async event => {
|
router.post("/api/load-collection", defineEventHandler(async event => {
|
||||||
const {account} = await readValidatedBody(event, schema.parse)
|
const {account} = await readValidatedBody(event, schema.schema.account.parse)
|
||||||
const result = await loadCollection(account)
|
const result = await loadCollection(account)
|
||||||
setCookie(event, "accounts", (getCookie(event, "accounts") || "").split("|").concat(account).join("|"))
|
setCookie(event, "accounts", (getCookie(event, "accounts") || "").split("|").concat(account).join("|"))
|
||||||
return pugSync.render(event, "collection-loaded.pug", result)
|
return pugSync.render(event, "collection-loaded.pug", result)
|
||||||
|
|
|
@ -1,12 +1,16 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
const domino = require("domino")
|
const domino = require("domino")
|
||||||
const {z} = require("zod")
|
|
||||||
const {getValidatedQuery, readValidatedBody, defineEventHandler} = require("h3")
|
const {getValidatedQuery, readValidatedBody, defineEventHandler} = require("h3")
|
||||||
|
|
||||||
const {sync, db, from, router} = require("../passthrough")
|
const {sync, db, router} = require("../passthrough")
|
||||||
|
|
||||||
|
/** @type {import("../pug-sync")} */
|
||||||
const pugSync = sync.require("../pug-sync")
|
const pugSync = sync.require("../pug-sync")
|
||||||
|
|
||||||
|
/** @type {import("./schema")} */
|
||||||
|
const schema = sync.require("./schema")
|
||||||
|
|
||||||
const insertTag = db.prepare("INSERT OR IGNORE INTO item_tag (account, item_id, tag) VALUES (?, ?, ?)")
|
const insertTag = db.prepare("INSERT OR IGNORE INTO item_tag (account, item_id, tag) VALUES (?, ?, ?)")
|
||||||
const insertTrack = db.prepare("INSERT OR IGNORE INTO track (account, item_id, track_id, title, artist, track_number, duration) VALUES (@account, @item_id, @track_id, @title, @artist, @track_number, @duration)")
|
const insertTrack = db.prepare("INSERT OR IGNORE INTO track (account, item_id, track_id, title, artist, track_number, duration) VALUES (@account, @item_id, @track_id, @title, @artist, @track_number, @duration)")
|
||||||
|
|
||||||
|
@ -118,17 +122,13 @@ const downloadManager = new class {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const schema = z.object({
|
|
||||||
account: z.string()
|
|
||||||
})
|
|
||||||
|
|
||||||
router.get("/api/tag-download", defineEventHandler(async event => {
|
router.get("/api/tag-download", defineEventHandler(async event => {
|
||||||
const {account} = await getValidatedQuery(event, schema.parse)
|
const {account} = await getValidatedQuery(event, schema.schema.account.parse)
|
||||||
return pugSync.render(event, "includes/tag-status.pug", {account})
|
return pugSync.render(event, "includes/tag-status.pug", {account})
|
||||||
}))
|
}))
|
||||||
|
|
||||||
router.post("/api/tag-download", defineEventHandler(async event => {
|
router.post("/api/tag-download", defineEventHandler(async event => {
|
||||||
const {account} = await readValidatedBody(event, schema.parse)
|
const {account} = await readValidatedBody(event, schema.schema.account.parse)
|
||||||
downloadManager.start(account)
|
downloadManager.start(account)
|
||||||
return pugSync.render(event, "includes/tag-status.pug", {account})
|
return pugSync.render(event, "includes/tag-status.pug", {account})
|
||||||
}))
|
}))
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
const {z} = require("zod")
|
|
||||||
const {sync, router} = require("../passthrough")
|
const {sync, router} = require("../passthrough")
|
||||||
const {defineEventHandler} = require("h3")
|
const {defineEventHandler} = require("h3")
|
||||||
const {getValidatedRouterParams} = require("h3")
|
const {getValidatedRouterParams} = require("h3")
|
||||||
|
@ -8,14 +7,11 @@ const {getValidatedRouterParams} = require("h3")
|
||||||
/** @type {import("../pug-sync")} */
|
/** @type {import("../pug-sync")} */
|
||||||
const pugSync = sync.require("../pug-sync")
|
const pugSync = sync.require("../pug-sync")
|
||||||
|
|
||||||
const schema = z.object({
|
/** @type {import("./schema")} */
|
||||||
item_type: z.enum(["album", "track"]),
|
const schema = sync.require("./schema")
|
||||||
item_id: z.number({coerce: true}),
|
|
||||||
track_id: z.number({coerce: true}).optional()
|
|
||||||
})
|
|
||||||
|
|
||||||
const play = defineEventHandler(async event => {
|
const play = defineEventHandler(async event => {
|
||||||
const locals = await getValidatedRouterParams(event, schema.parse)
|
const locals = await getValidatedRouterParams(event, schema.schema.play.parse)
|
||||||
return pugSync.render(event, "player.pug", locals)
|
return pugSync.render(event, "player.pug", locals)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
30
routes/schema.js
Normal file
30
routes/schema.js
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
// @ts-check
|
||||||
|
|
||||||
|
const {z} = require("zod")
|
||||||
|
|
||||||
|
const schema = {
|
||||||
|
appQuery: z.object({
|
||||||
|
arrange: z.enum(["album", "artist", "label", "tag", "track"]),
|
||||||
|
shape: z.enum(["grid", "list"]),
|
||||||
|
filter_field: z.enum(["band_name", "band_url", "item_title", "item_id", "tag", "why"]).optional(),
|
||||||
|
filter: z.string().optional(),
|
||||||
|
filter_fuzzy: z.enum(["true"]).optional()
|
||||||
|
}),
|
||||||
|
account: z.object({
|
||||||
|
account: z.string().regex(/^[a-z0-9_-]+$/)
|
||||||
|
}),
|
||||||
|
postCurrency: z.object({
|
||||||
|
currency: z.string().regex(/^[A-Z]{3}$/),
|
||||||
|
account: z.string()
|
||||||
|
}),
|
||||||
|
play: z.object({
|
||||||
|
item_type: z.enum(["album", "track"]),
|
||||||
|
item_id: z.number({coerce: true}),
|
||||||
|
track_id: z.number({coerce: true}).optional()
|
||||||
|
}),
|
||||||
|
inlinePlayer: z.object({
|
||||||
|
inline_player: z.string().optional()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports.schema = schema
|
|
@ -1,17 +1,13 @@
|
||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
const {z} = require("zod")
|
const {sync, router} = require("../passthrough")
|
||||||
const {router} = require("../passthrough")
|
|
||||||
const {defineEventHandler, readValidatedBody, setCookie, setResponseHeader} = require("h3")
|
const {defineEventHandler, readValidatedBody, setCookie, setResponseHeader} = require("h3")
|
||||||
|
|
||||||
const schema = {
|
/** @type {import("./schema")} */
|
||||||
inline_player: z.object({
|
const schema = sync.require("./schema")
|
||||||
inline_player: z.string().optional()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
router.post("/api/settings/inline-player", defineEventHandler(async event => {
|
router.post("/api/settings/inline-player", defineEventHandler(async event => {
|
||||||
const {inline_player} = await readValidatedBody(event, schema.inline_player.parse)
|
const {inline_player} = await readValidatedBody(event, schema.schema.inlinePlayer.parse)
|
||||||
setCookie(event, "bcex-inline-player-disabled", String(!inline_player))
|
setCookie(event, "bcex-inline-player-disabled", String(!inline_player))
|
||||||
setResponseHeader(event, "HX-Refresh", "true")
|
setResponseHeader(event, "HX-Refresh", "true")
|
||||||
return null
|
return null
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue