diff --git a/pug-sync.js b/pug-sync.js index fb3ae9e..dd18999 100644 --- a/pug-sync.js +++ b/pug-sync.js @@ -5,7 +5,7 @@ const fs = require("fs") const {join} = require("path") const h3 = require("h3") const {defineEventHandler, defaultContentType, setResponseStatus, getQuery, getRequestHeader, setResponseHeader} = h3 -const {compileFile} = require("@cloudrac3r/pug") +const pug = require("@cloudrac3r/pug") const pretty = process.argv.join(" ").includes("test") const {sync} = require("./passthrough") @@ -20,6 +20,34 @@ function addGlobals(obj) { Object.assign(globals, obj) } +/** @type {Map) => Promise>} */ +const beforeIncludes = new Map() + +/** + * So the idea with this one is that because I'm using htmx I want to be able to render certain page fragments on their own. + * Like if I change the currency, I should re-render just the collection stats because that's where the currency display is. + * So I do this by separating the stats template into its own file, then the main page can include the stats template. Easy. + * The template can be a mixin or it can just be the thing. If it's a mixin, I need another mini-template to include it, ok. + * This requires setting up the mini-endpoint as well just for the template (or mini-template), ok. The gross part is that both + * the mini-endpoint and main endpoint need to gather the variables, like collection stats from the database, and give them to the template. Hmm. + * Even if that variable getter is in its own isolated function, I think it feels bad to have to manually call that in every endpoint whose page + * happens to eventually include collection stats. It has bad separation of concerns, and scales badly as more templates get split out this way. + * The collection stats template ALWAYS needs its same variables in the same way, no matter which page it happens to be included in. + * So here's how this is going to work. You can define a function through pugSync that will be called automatically during render if + * the main page happens to include the relevant template. We get the dependencies through pug.compileClientWithDependenciesTracked. + * @example ```js +beforeInclude("pug/includes/collection-stats.pug", (from, event, locals) => { +locals.stats = { +total: db.prepare(... +``` + * @param {string} filename + * @param {(from: string, event: import("h3").H3Event, locals: Record) => Promise} getLocals + */ +function beforeInclude(filename, getLocals) { + const path = join(__dirname, "pug", filename) + beforeIncludes.set(path, getLocals) +} + function and(event, params) { const newParams = Object.assign(getQuery(event), params) Object.keys(newParams).forEach(key => !newParams[key] && delete newParams[key]) @@ -36,9 +64,18 @@ function render(event, filename, locals) { function compile() { try { - const template = compileFile(path, {pretty}) + const content = fs.readFileSync(path, "utf8") + const template = pug.compile(content, {filename: path, pretty}) + const dependencies = pug.compileClientWithDependenciesTracked(content, {filename: path, pretty}).dependencies.concat(path) pugCache.set(path, async (event, locals) => { const localAnd = params => and(event, params) + // automatically generate locals required by included templates + console.log([...beforeIncludes.keys()]) + for (const dep of dependencies) { + console.log(dep) + const fn = beforeIncludes.get(dep) + if (fn) Object.assign(locals, await fn(path, event, locals)) + } defaultContentType(event, "text/html; charset=utf-8") return template(Object.assign({}, getQuery(event), // Query parameters can be easily accessed on the top level but don't allow them to overwrite anything @@ -95,3 +132,4 @@ module.exports.render = render module.exports.createRoute = createRoute module.exports.compressResponse = compressResponse module.exports.and = and +module.exports.beforeInclude = beforeInclude diff --git a/pug/includes/collection-stats.pug b/pug/includes/collection-stats.pug new file mode 100644 index 0000000..48b9e49 --- /dev/null +++ b/pug/includes/collection-stats.pug @@ -0,0 +1,58 @@ +#collection-stats.s-sidebarwidget + .s-sidebarwidget--header Collection + .s-sidebarwidget--action + input(type="hidden" name="account" value=account) + button.s-link(hx-post="/api/load-collection" hx-target="#collection-sync" hx-select="#collection-sync" hx-include="previous input") Sync + + table.s-sidebarwidget--content.s-sidebarwidget__items + tr.s-sidebarwidget--item + th items + td= count.total + tr.s-sidebarwidget--item + th runtime + td= count.runtime + tr.s-sidebarwidget--item + th format + td + = count.albums + span.fc-black-400= ` albums` + span.fc-black-250= ` / ` + = count.singles + span.fc-black-400= ` singles` + tr.s-sidebarwidget--item + th price + td + = count.free + span.fc-black-400= ` free` + span.fc-black-250= ` / ` + = count.paid + span.fc-black-400= ` paid` + tr.s-sidebarwidget--item + th tracks + td + = count.tracks + span.pl8.fc-black-250= ` / ` + span.fc-black-400 avg #{count.avgTracks} + tr.s-sidebarwidget--item + th tags + td + = count.tags + span.pl8.fc-black-250= ` / ` + span.fc-black-400 avg #{count.avgTags} + span.fc-black-250= ` / ` + span.fc-black-400 lonely #{count.lonelyTags} + tr.s-sidebarwidget--item + th value + td + = `${count.value} ` + span.fc-black-400 #{count.displayCurrency} + tr.s-sidebarwidget--item + th diversity + //- supernova red-500, warm yellow-500, hot orange-500 + //- 0-9 black, 10-99 yellow, 100-999 orange, 1000+ red + td.w100 + .s-progress.d-grid.g2.h4.mtn6(style=`grid-template-columns: ${count.taste.map(t => t + "fr").join(" ")}`).bg-white.fc-black-400.fs-fine.lh-xxl + .s-progress--bar.bg-black-400(title=`${count.taste[0]} labels with <20 fans`)= count.taste[0] + .s-progress--bar.bg-yellow-400(title=`${count.taste[1]} labels with 20-199 fans`)= count.taste[1] + .s-progress--bar.bg-orange-400(title=`${count.taste[2]} labels with 200-1999 fans`)= count.taste[2] + .s-progress--bar.bg-red-400(title=`${count.taste[3]} labels with >2000 fans`)= count.taste[3] diff --git a/pug/includes/layout.pug b/pug/includes/layout.pug index 507b378..ffaf5ef 100644 --- a/pug/includes/layout.pug +++ b/pug/includes/layout.pug @@ -103,64 +103,7 @@ html #collection-sync.d-none - #collection-stats.s-sidebarwidget - .s-sidebarwidget--header Collection - .s-sidebarwidget--action - input(type="hidden" name="account" value=account) - button.s-link(hx-post="/api/load-collection" hx-target="#collection-sync" hx-select="#collection-sync" hx-include="previous input") Sync - - table.s-sidebarwidget--content.s-sidebarwidget__items - tr.s-sidebarwidget--item - th items - td= count.total - tr.s-sidebarwidget--item - th runtime - td= count.runtime - tr.s-sidebarwidget--item - th format - td - = count.albums - span.fc-black-400= ` albums` - span.fc-black-250= ` / ` - = count.singles - span.fc-black-400= ` singles` - tr.s-sidebarwidget--item - th price - td - = count.free - span.fc-black-400= ` free` - span.fc-black-250= ` / ` - = count.paid - span.fc-black-400= ` paid` - tr.s-sidebarwidget--item - th tracks - td - = count.tracks - span.pl8.fc-black-250= ` / ` - span.fc-black-400 avg #{count.avgTracks} - tr.s-sidebarwidget--item - th tags - td - = count.tags - span.pl8.fc-black-250= ` / ` - span.fc-black-400 avg #{count.avgTags} - span.fc-black-250= ` / ` - span.fc-black-400 lonely #{count.lonelyTags} - tr.s-sidebarwidget--item - th value - td - = `${count.value} ` - span.fc-black-400 #{count.displayCurrency} - tr.s-sidebarwidget--item - th diversity - //- supernova red-500, warm yellow-500, hot orange-500 - //- 0-9 black, 10-99 yellow, 100-999 orange, 1000+ red - td.w100 - .s-progress.d-grid.g2.h4.mtn6(style=`grid-template-columns: ${count.taste.map(t => t + "fr").join(" ")}`).bg-white.fc-black-400.fs-fine.lh-xxl - .s-progress--bar.bg-black-400(title=`${count.taste[0]} labels with <20 fans`)= count.taste[0] - .s-progress--bar.bg-yellow-400(title=`${count.taste[1]} labels with 20-199 fans`)= count.taste[1] - .s-progress--bar.bg-orange-400(title=`${count.taste[2]} labels with 200-1999 fans`)= count.taste[2] - .s-progress--bar.bg-red-400(title=`${count.taste[3]} labels with >2000 fans`)= count.taste[3] + include collection-stats.pug main.fl-grow1 block view diff --git a/routes/app.js b/routes/app.js index 1a0a6f8..2c32ff8 100644 --- a/routes/app.js +++ b/routes/app.js @@ -8,19 +8,6 @@ const pugSync = sync.require("../pug-sync") /** @type {import("./load-tags")} */ const loadTags = sync.require("./load-tags") -const currencyExchange = new Map([ - ["AUD", 0.63], - ["BRL", 0.17], - ["CAD", 0.7], - ["CHF", 1.13], - ["EUR", 1.08], - ["GBP", 1.3], - ["JPY", 0.0067], - ["NOK", 0.1], - ["NZD", 0.57], - ["USD", 1], -]) - 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_list: "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 BY band_url, band_name COLLATE NOCASE, item_title COLLATE NOCASE", @@ -150,33 +137,10 @@ router.get("/:account/", defineEventHandler({ throw e } - let displayCurrency = getCookie(event, "bcex-currency") || "" - if (!currencyExchange.has(displayCurrency)) displayCurrency = "NZD" - const currencyRoundTo = displayCurrency === "JPY" ? 1000 : 10 - const locals = { items, account, query, - count: { - total: db.prepare("SELECT count(*) FROM item WHERE account = ?").pluck().get(account), - runtime: db.prepare("SELECT iif(sum(duration) > 86400, cast(total(duration)/86400 AS INTEGER) || 'd ' || cast(total(duration)/3600%24 AS INTEGER) || 'h', cast(total(duration)/3600 AS INTEGER) || 'h') FROM track WHERE account = ?").pluck().get(account), - albums: db.prepare("SELECT count(*) FROM item WHERE item_type = 'album' AND account = ?").pluck().get(account), - singles: db.prepare("SELECT count(*) FROM item WHERE item_type = 'track' AND account = ?").pluck().get(account), - free: db.prepare("SELECT count(*) FROM item WHERE price = 0 AND account = ?").pluck().get(account), - paid: db.prepare("SELECT count(*) FROM item WHERE price > 0 AND account = ?").pluck().get(account), - tracks: db.prepare("SELECT count(*) FROM track WHERE account = ?").pluck().get(account), - avgTracks: Math.round(db.prepare("SELECT avg(count) FROM (SELECT count(*) AS count FROM track INNER JOIN item USING (account, item_id) WHERE item_type = 'album' AND account = ? GROUP BY item_id)").pluck().get(account)*10)/10, - tags: db.prepare("SELECT count(*) FROM item_tag WHERE account = ?").pluck().get(account), - avgTags: Math.round(db.prepare("SELECT avg(count) FROM (SELECT count(*) AS count FROM item_tag WHERE account = ? GROUP BY item_id)").pluck().get(account)*10)/10, - lonelyTags: db.prepare("SELECT count(*) FROM (SELECT tag FROM item_tag WHERE account = ? GROUP BY tag HAVING count(*) = 1)").pluck().get(account), - value: Math.round(select("item", ["currency", "price"], {account}).all().map(c => { - return (currencyExchange.get(c.currency) || 0.6) * c.price / (currencyExchange.get(displayCurrency) || 1) / currencyRoundTo - }).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: [...currencyExchange.keys()] } if (mode === "artist_grid") { loadPreviews(locals, "band_name", 4, whereClause, account, filter_field, filter, filter_fuzzy) @@ -186,7 +150,8 @@ router.get("/:account/", defineEventHandler({ locals.downloadManager = loadTags.downloadManager locals.downloader = loadTags.downloadManager.check(account) } - locals.hasFullTrackData = locals.count.tracks > locals.count.total + // if there are any untagged items then we don't have full track data + locals.hasFullTrackData = !db.prepare("SELECT * FROM item LEFT JOIN item_tag USING (account, item_id) WHERE account = ? AND item_id = NULL").get(account) return pugSync.render(event, `${arrange}_${shape}.pug`, locals) } })) diff --git a/routes/collection-stats.js b/routes/collection-stats.js new file mode 100644 index 0000000..547fdd8 --- /dev/null +++ b/routes/collection-stats.js @@ -0,0 +1,63 @@ +// @ts-check + +const {getCookie, defineEventHandler, readValidatedBody, setCookie} = require("h3") +const {z} = require("zod") +const {sync, select, db, router} = require("../passthrough") + +/** @type {import("../pug-sync")} */ +const pugSync = sync.require("../pug-sync") + +const currencyExchange = new Map([ + ["AUD", 0.63], + ["BRL", 0.17], + ["CAD", 0.7], + ["CHF", 1.13], + ["EUR", 1.08], + ["GBP", 1.3], + ["JPY", 0.0067], + ["NOK", 0.1], + ["NZD", 0.57], + ["USD", 1], +]) +const currencies = [...currencyExchange.keys()] + +pugSync.beforeInclude("includes/collection-stats.pug", async (from, event, {account, currency}) => { + let displayCurrency = currency || getCookie(event, "bcex-currency") || "" + if (!currencyExchange.has(displayCurrency)) displayCurrency = "NZD" + const currencyRoundTo = displayCurrency === "JPY" ? 1000 : 10 + + return { + count: { + total: db.prepare("SELECT count(*) FROM item WHERE account = ?").pluck().get(account), + runtime: db.prepare("SELECT iif(sum(duration) > 86400, cast(total(duration)/86400 AS INTEGER) || 'd ' || cast(total(duration)/3600%24 AS INTEGER) || 'h', cast(total(duration)/3600 AS INTEGER) || 'h') FROM track WHERE account = ?").pluck().get(account), + albums: db.prepare("SELECT count(*) FROM item WHERE item_type = 'album' AND account = ?").pluck().get(account), + singles: db.prepare("SELECT count(*) FROM item WHERE item_type = 'track' AND account = ?").pluck().get(account), + free: db.prepare("SELECT count(*) FROM item WHERE price = 0 AND account = ?").pluck().get(account), + paid: db.prepare("SELECT count(*) FROM item WHERE price > 0 AND account = ?").pluck().get(account), + tracks: db.prepare("SELECT count(*) FROM track WHERE account = ?").pluck().get(account), + avgTracks: Math.round(db.prepare("SELECT avg(count) FROM (SELECT count(*) AS count FROM track INNER JOIN item USING (account, item_id) WHERE item_type = 'album' AND account = ? GROUP BY item_id)").pluck().get(account)*10)/10, + tags: db.prepare("SELECT count(*) FROM item_tag WHERE account = ?").pluck().get(account), + avgTags: Math.round(db.prepare("SELECT avg(count) FROM (SELECT count(*) AS count FROM item_tag WHERE account = ? GROUP BY item_id)").pluck().get(account)*10)/10, + lonelyTags: db.prepare("SELECT count(*) FROM (SELECT tag FROM item_tag WHERE account = ? GROUP BY tag HAVING count(*) = 1)").pluck().get(account), + value: Math.round(select("item", ["currency", "price"], {account}).all().map(c => { + return (currencyExchange.get(c.currency) || 0.6) * c.price / (currencyExchange.get(displayCurrency) || 1) / currencyRoundTo + }).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.post("/api/settings/currency", defineEventHandler(async event => { + const {currency, account} = await readValidatedBody(event, schema.currency.parse) + setCookie(event, "bcex-currency", currency) + return pugSync.render(event, "includes/collection-stats.pug", {account, currency}) +})) diff --git a/routes/settings.js b/routes/settings.js index a7cb337..f492b27 100644 --- a/routes/settings.js +++ b/routes/settings.js @@ -2,15 +2,11 @@ const {z} = require("zod") const {router} = require("../passthrough") -const {defineEventHandler, readValidatedBody, setCookie, setResponseHeader, sendRedirect} = require("h3") +const {defineEventHandler, readValidatedBody, setCookie, setResponseHeader} = require("h3") const schema = { inline_player: z.object({ inline_player: z.string().optional() - }), - currency: z.object({ - currency: z.string().regex(/^[A-Z]{3}$/), - account: z.string() }) } @@ -20,9 +16,3 @@ router.post("/api/settings/inline-player", defineEventHandler(async event => { setResponseHeader(event, "HX-Refresh", "true") return null })) - -router.post("/api/settings/currency", defineEventHandler(async event => { - const {currency, account} = await readValidatedBody(event, schema.currency.parse) - setCookie(event, "bcex-currency", currency) - return sendRedirect(event, `/${account}/?arrange=tag&shape=grid`, 302) -})) diff --git a/start.js b/start.js index b680ec7..9270bcd 100644 --- a/start.js +++ b/start.js @@ -38,6 +38,7 @@ sync.require("./routes/app") sync.require("./routes/load-collection") sync.require("./routes/play") sync.require("./routes/settings") +sync.require("./routes/collection-stats") // Files