Simple mobile support

This commit is contained in:
Cadence Ember 2025-04-09 17:42:51 +12:00
parent 924c7395cf
commit 852a053e2b
17 changed files with 207 additions and 93 deletions

View file

@ -18,7 +18,7 @@ sync.require("./settings")
// Files
router.get("/static/stacks.min.css", defineEventHandler({
router.get("/static/stacks.css", defineEventHandler({
onBeforeResponse: pugSync.compressResponse,
handler: async event => {
handleCacheHeaders(event, {maxAge: 86400})
@ -65,7 +65,7 @@ router.get("/static/player-marker.js", defineEventHandler({
router.get("/favicon.png", defineEventHandler({
handler: async event => {
handleCacheHeaders(event, {maxAge: 86400})
defaultContentType(event, "text/javascript")
defaultContentType(event, "image/png")
return fs.promises.readFile("public/favicon.png")
}
}))

View file

@ -1,14 +1,13 @@
// @ts-check
const {z} = require("zod")
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")} */
const pugSync = sync.require("../pug-sync")
/** @type {import("./load-tags")} */
const loadTags = sync.require("./load-tags")
/** @type {import("./schema")} */
const schema = sync.require("./schema")
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}",
@ -24,7 +23,7 @@ const sqls = {
function loadPreviews(locals, field, number, whereClause, account, filter_field, filter, filter_fuzzy) {
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)
if (whereClause) {
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({
onBeforeResponse: pugSync.compressResponse,
handler: async event => {
const {account} = await getValidatedRouterParams(event, schema.schema.account.parse)
try {
var {account} = await getValidatedRouterParams(event, schema.params.parse)
var {arrange, shape, filter, filter_field, filter_fuzzy} = await getValidatedQuery(event, schema.query.parse)
var {arrange, shape, filter, filter_field, filter_fuzzy} = await getValidatedQuery(event, schema.schema.appQuery.parse)
if (filter_field === "why" && arrange !== "album") throw new Error("filter not compatible with arrangement")
} catch (e) {
return sendRedirect(event, "?arrange=album&shape=grid", 302)

View file

@ -1,12 +1,14 @@
// @ts-check
const {getCookie, defineEventHandler, readValidatedBody, setCookie} = require("h3")
const {z} = require("zod")
const {getCookie, defineEventHandler, readValidatedBody, setCookie, getValidatedRouterParams} = require("h3")
const {sync, select, db, router} = require("../passthrough")
/** @type {import("../pug-sync")} */
const pugSync = sync.require("../pug-sync")
/** @type {import("./schema")} */
const schema = sync.require("./schema")
const currencyExchange = new Map([
["AUD", 0.63],
["BRL", 0.17],
@ -21,6 +23,12 @@ const currencyExchange = new Map([
])
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}) => {
let displayCurrency = currency || getCookie(event, "bcex-currency") || ""
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,
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)
},
currencies
}
}
})
const schema = {
currency: z.object({
currency: z.string().regex(/^[A-Z]{3}$/),
account: z.string()
})
}
router.get("/:account/collection-stats", defineEventHandler(async event => {
const {account} = await getValidatedRouterParams(event, schema.schema.account.parse)
return pugSync.render(event, "collection-stats.pug", {account, isStatsPage: true})
}))
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)
return pugSync.render(event, "includes/collection-stats.pug", {account, currency})
}))

View file

@ -1,23 +1,26 @@
// @ts-check
const assert = require("assert/strict")
const fs = require("fs")
const sqlite = require("better-sqlite3")
const domino = require("domino")
const {defineEventHandler, readValidatedBody, setCookie, getCookie} = require("h3")
const {z} = require("zod")
const {sync, db, router} = require("../passthrough")
/** @type {import("../pug-sync")} */
const pugSync = sync.require("../pug-sync")
/** @type {import("./load-tags")} */
const loadTags = sync.require("./load-tags")
/** @type {import("./schema")} */
const schema = sync.require("./schema")
async function loadCollection(inputUsername) {
assert.match(inputUsername, /^[a-z0-9_-]+$/)
const html = await fetch(`https://bandcamp.com/${inputUsername}`).then(res => res.text())
const doc = domino.createDocument(html)
const first = doc.querySelector(".collection-item-container")
const first = doc.querySelector(".collection-item-container[data-token]")
assert(first)
const token = first.getAttribute("data-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(", ")}`)
db.transaction(() => {
for (const item of items.items) {
if (!item.tralbum_type.match(/[at]/)) continue // p=product and s=subscription not supported
preparedItem.run({
account,
...item,
@ -88,12 +92,8 @@ async function loadCollection(inputUsername) {
}
}
const schema = z.object({
account: z.string()
})
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)
setCookie(event, "accounts", (getCookie(event, "accounts") || "").split("|").concat(account).join("|"))
return pugSync.render(event, "collection-loaded.pug", result)

View file

@ -1,12 +1,16 @@
// @ts-check
const domino = require("domino")
const {z} = require("zod")
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")
/** @type {import("./schema")} */
const schema = sync.require("./schema")
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)")
@ -118,17 +122,13 @@ const downloadManager = new class {
}
}
const schema = z.object({
account: z.string()
})
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})
}))
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)
return pugSync.render(event, "includes/tag-status.pug", {account})
}))

View file

@ -1,6 +1,5 @@
// @ts-check
const {z} = require("zod")
const {sync, router} = require("../passthrough")
const {defineEventHandler} = require("h3")
const {getValidatedRouterParams} = require("h3")
@ -8,14 +7,11 @@ const {getValidatedRouterParams} = require("h3")
/** @type {import("../pug-sync")} */
const pugSync = sync.require("../pug-sync")
const schema = z.object({
item_type: z.enum(["album", "track"]),
item_id: z.number({coerce: true}),
track_id: z.number({coerce: true}).optional()
})
/** @type {import("./schema")} */
const schema = sync.require("./schema")
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)
})

30
routes/schema.js Normal file
View 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

View file

@ -1,17 +1,13 @@
// @ts-check
const {z} = require("zod")
const {router} = require("../passthrough")
const {sync, router} = require("../passthrough")
const {defineEventHandler, readValidatedBody, setCookie, setResponseHeader} = require("h3")
const schema = {
inline_player: z.object({
inline_player: z.string().optional()
})
}
/** @type {import("./schema")} */
const schema = sync.require("./schema")
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))
setResponseHeader(event, "HX-Refresh", "true")
return null