diff --git a/src/auctions.ts b/src/auctions.ts
index b678075..fff0a81 100644
--- a/src/auctions.ts
+++ b/src/auctions.ts
@@ -21,6 +21,7 @@ type Artwork = {
medium: string
description: string
image_type: string
+ topic: string
created_at: number
}
@@ -42,9 +43,72 @@ function themePicker(theme: AuctionTheme, path: string) {
return ``
}
+export const AUCTION_TOPICS = {
+ dahlias: { name: 'Dahlias', blurb: 'Dinner plates, pompons and cactus blooms in every medium.' },
+ mums: { name: 'Mums', blurb: 'Chrysanthemums: spiders, spoons and autumn color.' },
+ roses: { name: 'Roses', blurb: 'Garden roses, climbers and the classic bloom.' },
+ seacliffs: { name: 'Sea cliffs', blurb: 'Coastal bluffs, surf and the edge of the land.' },
+} as const
+export type AuctionTopic = keyof typeof AUCTION_TOPICS
+export const isTopic = (value: unknown): value is AuctionTopic => typeof value === 'string' && Object.hasOwn(AUCTION_TOPICS, value)
+const TOPIC_SLUGS = Object.keys(AUCTION_TOPICS) as AuctionTopic[]
+
+function mediaTag(item: Artwork, lazy = true) {
+ const src = `/image/${encodeURIComponent(item.id)}`
+ const label = `${esc(item.title)} by ${esc(item.artist)}`
+ return isVideo(item.image_type)
+ ? ``
+ : `
`
+}
+
+/** Drawn stand-in art for a topic with nothing on its walls yet. */
+const flower = (slug: AuctionTopic) => slug === 'seacliffs'
+ ? ``
+ : ``
+
+function topicDirectory(house: AuctionHouse) {
+ const cards = TOPIC_SLUGS.map((slug) => {
+ const t = AUCTION_TOPICS[slug]
+ const count = house.countTopic(slug)
+ const cover = house.list(1, slug)[0]
+ return `${cover ? mediaTag(cover) : flower(slug)}${count ? `${count} ${count === 1 ? 'PIECE' : 'PIECES'}` : 'OPEN CALL'}${t.name}
${t.blurb}
`
+ }).join('')
+ return ``
+}
+
+function placeholderTiles(slug: AuctionTopic) {
+ const name = AUCTION_TOPICS[slug].name.replace(/s$/, '').toLowerCase()
+ return `
`
+}
+
+function topicPage(house: AuctionHouse, slug: AuctionTopic, theme: AuctionTheme) {
+ const t = AUCTION_TOPICS[slug]
+ const works = house.list(48, slug)
+ const others = TOPIC_SLUGS.filter((s) => s !== slug).map((s) => `${AUCTION_TOPICS[s].name}`).join('')
+ return page(theme, t.name, `← All topicsTOPIC
${t.name} ${works.length.toString().padStart(2, '0')}
${t.blurb}
Submit a piece ↗ ${works.length ? `${works.map(artworkCard).join('')}
` : placeholderTiles(slug)}`, `${t.name} in the Art Auctions House: ${t.blurb}`, `/topics/${slug}`)
+}
+
+function topicsIndex(house: AuctionHouse, theme: AuctionTheme) {
+ return page(theme, 'Topics', `${topicDirectory(house).replace('All topics ↗', 'Submit a piece ↗')}`, 'Browse the Art Auctions House by topic.', '/topics')
+}
+
type ArtworkImage = { image_type: string; image: Uint8Array }
const MAX_IMAGE_BYTES = 8 * 1024 * 1024
+// Caddy and the relay both cap a request body at 30 MiB, so a video stays under that with room for the form.
+const MAX_VIDEO_BYTES = 25 * 1024 * 1024
+const VIDEO_TYPES = new Set(['video/mp4', 'video/webm', 'video/quicktime'])
+const isVideo = (type: string) => type.startsWith('video/')
+
+/** The real video type from the file's first bytes, whatever the browser declared (phones often send none). */
+export function sniffVideo(bytes: Uint8Array): string | null {
+ if (bytes.length >= 4 && bytes[0] === 0x1a && bytes[1] === 0x45 && bytes[2] === 0xdf && bytes[3] === 0xa3) return 'video/webm'
+ if (bytes.length < 12 || String.fromCharCode(...bytes.slice(4, 8)) !== 'ftyp') return null
+ const brand = String.fromCharCode(...bytes.slice(8, 12))
+ if (brand === 'qt ') return 'video/quicktime'
+ if (/^(isom|iso[2-9]|mp41|mp42|avc1|M4V |M4VP|dash|3gp[4-6]|3g2a|MSNV|f4v |mmp4)$/.test(brand)) return 'video/mp4'
+ return null
+}
const IMAGE_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/avif', 'image/heic', 'image/heif'])
const esc = (value: unknown) => String(value ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!)
const clean = (value: unknown, length: number) => String(value ?? '').replace(/[\u0000-\u001f\u007f]/g, ' ').trim().slice(0, length)
@@ -70,11 +134,11 @@ const cssHref = () => `/style.css?v=${(cssVersion ??= createHash('sha256').updat
function page(theme: AuctionTheme, title: string, body: string, description = 'A home for art, artists and the stories behind every piece.', canonicalPath = '/') {
return `
${esc(title)} · Art Auctions
-${body}`
+${body}