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) + ? `` + : `${label}` +} + +/** 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 `

THE DIRECTORY

Browse by topic

All topics
${cards}
` +} + +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, `
`, `${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 -
AArt AuctionsTHE HOUSE FOR ORIGINAL WORK
${body}` +
AArt AuctionsTHE HOUSE FOR ORIGINAL WORK
${body}` } function artworkCard(item: Artwork) { - return `
${esc(item.title)} by ${esc(item.artist)}
${esc(item.medium || 'ORIGINAL WORK')}

${esc(item.title)}

by ${esc(item.artist)}

` + return `
${mediaTag(item)}
${esc(item.medium || 'ORIGINAL WORK')}

${esc(item.title)}

by ${esc(item.artist)}

` } function home(house: AuctionHouse, theme: AuctionTheme) { @@ -85,16 +149,17 @@ function home(house: AuctionHouse, theme: AuctionTheme) { return page(theme, 'Art Auctions House', `

ART · PEOPLE · STORIES

Good art deserves
a good home.

A new house for original art, made by people with something to say.

Bring us a piece

Made for artists. Ready for your camera roll.

01 / 01
+ ${topicDirectory(house)}

AN OPEN DOOR

Make room
for your work.

Send a photo and a few words about the piece. It will appear in the gallery with your name.

Add your artwork Only share work you own or have permission to display. The gallery is public.
`) } -function formPage(theme: AuctionTheme, error = '') { - return page(theme, 'Submit artwork', `
← Back to the house

ADD A PIECE TO THE WALLS

Show us
what you made.

Share a clear photo and a little about the work. Your submission will appear in the public gallery.

${error ? `` : ''}

Submissions are public as soon as they are added. Bidding and payment features are not active yet.

`, undefined, '/new') +function formPage(theme: AuctionTheme, error = '', topic = '') { + return page(theme, 'Submit artwork', `
← Back to the house

ADD A PIECE TO THE WALLS

Show us
what you made.

Share a clear photo or a short video and a little about the work. Your submission will appear in the public gallery.

${error ? `` : ''}

Submissions are public as soon as they are added. Bidding and payment features are not active yet.

`, undefined, '/new') } function detail(item: Artwork, theme: AuctionTheme) { - return page(theme, item.title, `
← Back to the gallery
${esc(item.title)} by ${esc(item.artist)}

${esc(item.medium || 'ORIGINAL WORK')}

${esc(item.title)}

by ${esc(item.artist)}

${item.description ? `

${esc(item.description).replace(/\n/g, '
')}

` : ''}Add your work

Bidding and payment features are not active yet.

`, 'A piece in the Art Auctions House gallery.', `/art/${encodeURIComponent(item.id)}`) + return page(theme, item.title, `
← Back to the gallery
${isVideo(item.image_type) ? `` : `${esc(item.title)} by ${esc(item.artist)}`}

${esc(item.medium || 'ORIGINAL WORK')}

${esc(item.title)}

by ${esc(item.artist)}

${isTopic(item.topic) ? `

${AUCTION_TOPICS[item.topic].name}

` : ''}${item.description ? `

${esc(item.description).replace(/\n/g, '
')}

` : ''}Add your work

Bidding and payment features are not active yet.

`, 'A piece in the Art Auctions House gallery.', `/art/${encodeURIComponent(item.id)}`) } export class AuctionHouse { @@ -115,24 +180,33 @@ export class AuctionHouse { image BLOB NOT NULL, created_at INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS auction_artworks_created ON auction_artworks(created_at DESC);`) + const columns = this.db.query<{ name: string }, []>('PRAGMA table_info(auction_artworks)').all().map((c) => c.name) + if (!columns.includes('topic')) this.db.exec("ALTER TABLE auction_artworks ADD COLUMN topic TEXT NOT NULL DEFAULT ''") + this.db.exec('CREATE INDEX IF NOT EXISTS auction_artworks_topic ON auction_artworks(topic, created_at DESC)') } - list(limit = 48): Artwork[] { - return this.db.query('SELECT id, title, artist, medium, description, image_type, created_at FROM auction_artworks ORDER BY created_at DESC LIMIT ?').all(Math.max(1, Math.min(100, limit))) + list(limit = 48, topic?: AuctionTopic): Artwork[] { + const n = Math.max(1, Math.min(100, limit)) + if (topic) return this.db.query('SELECT id, title, artist, medium, description, image_type, topic, created_at FROM auction_artworks WHERE topic = ? ORDER BY created_at DESC LIMIT ?').all(topic, n) + return this.db.query('SELECT id, title, artist, medium, description, image_type, topic, created_at FROM auction_artworks ORDER BY created_at DESC LIMIT ?').all(n) + } + + countTopic(topic: AuctionTopic): number { + return this.db.query<{ n: number }, [string]>('SELECT COUNT(*) AS n FROM auction_artworks WHERE topic = ?').get(topic)?.n ?? 0 } get(id: string): Artwork | null { - return this.db.query('SELECT id, title, artist, medium, description, image_type, created_at FROM auction_artworks WHERE id = ?').get(id) ?? null + return this.db.query('SELECT id, title, artist, medium, description, image_type, topic, created_at FROM auction_artworks WHERE id = ?').get(id) ?? null } image(id: string): ArtworkImage | null { return this.db.query('SELECT image_type, image FROM auction_artworks WHERE id = ?').get(id) ?? null } - add(input: { title: string; artist: string; medium: string; description: string; imageType: string; image: Uint8Array }, now = Date.now()): string { + add(input: { title: string; artist: string; medium: string; description: string; imageType: string; image: Uint8Array; topic?: AuctionTopic | '' }, now = Date.now()): string { const id = randomBytes(12).toString('base64url') - this.db.query('INSERT INTO auction_artworks (id, title, artist, medium, description, image_type, image, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)') - .run(id, input.title, input.artist, input.medium, input.description, input.imageType, input.image, now) + this.db.query('INSERT INTO auction_artworks (id, title, artist, medium, description, image_type, image, topic, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)') + .run(id, input.title, input.artist, input.medium, input.description, input.imageType, input.image, input.topic ?? '', now) return id } @@ -162,14 +236,31 @@ export function createAuctionApp(house: AuctionHouse) { app.get('/image/:id', (c) => { const image = house.image(c.req.param('id')) if (!image) return c.text('Not found', 404) - const bytes = image.image.slice().buffer as ArrayBuffer - return c.body(bytes, 200, { 'content-type': image.image_type, 'cache-control': 'public, max-age=86400', 'x-content-type-options': 'nosniff' }) + const all = image.image + const headers: Record = { 'content-type': image.image_type, 'cache-control': 'public, max-age=86400', 'x-content-type-options': 'nosniff', 'accept-ranges': 'bytes' } + // Safari will not play a video unless the server answers byte ranges. + const range = /^bytes=(\d*)-(\d*)$/.exec(c.req.header('range') ?? '') + if (range && (range[1] || range[2])) { + const size = all.length + let start = range[1] ? Number(range[1]) : size - Number(range[2]) + let end = range[1] && range[2] ? Number(range[2]) : size - 1 + start = Math.max(0, start) + end = Math.min(end, size - 1) + if (start > end || start >= size) return c.body(null, 416, { 'content-range': `bytes */${size}` }) + return c.body(all.slice(start, end + 1).buffer as ArrayBuffer, 206, { ...headers, 'content-range': `bytes ${start}-${end}/${size}` }) + } + return c.body(all.slice().buffer as ArrayBuffer, 200, headers) }) app.get('/art/:id', (c) => { const item = house.get(c.req.param('id')) return item ? c.html(detail(item, themeOf(c))) : c.html(page(themeOf(c), 'Not found', '

This piece is not here.

Back to the house
'), 404) }) - app.get('/new', (c) => c.html(formPage(themeOf(c)))) + app.get('/new', (c) => { const topic = c.req.query('topic'); return c.html(formPage(themeOf(c), '', isTopic(topic) ? topic : '')) }) + app.get('/topics', (c) => c.html(topicsIndex(house, themeOf(c)))) + app.get('/topics/:slug', (c) => { + const slug = c.req.param('slug').toLowerCase() + return isTopic(slug) ? c.html(topicPage(house, slug, themeOf(c))) : c.html(page(themeOf(c), 'Not found', '

That topic is not in the house yet.

See all topics
'), 404) + }) app.post('/new', async (c) => { const host = String(c.req.header('host') ?? new URL(c.req.url).host).trim().toLowerCase().replace(/:\d+$/, '') const origin = c.req.header('origin') @@ -194,17 +285,28 @@ export function createAuctionApp(house: AuctionHouse) { const title = clean(body.title, 120) const artist = clean(body.artist, 80) const medium = clean(body.medium, 80) + const topic = isTopic(body.topic) ? body.topic : '' const description = clean(body.description, 1600) if (!title || !artist) return c.html(formPage(theme, 'Add a title and artist name to continue.'), 400) if (body.permission !== 'yes') return c.html(formPage(theme, 'Please confirm you have permission to share this work.'), 400) const image = body.image - if (!(image instanceof File) || image.size === 0) return c.html(formPage(theme, 'Choose a photo of your work to continue.'), 400) - if (image.size > MAX_IMAGE_BYTES) return c.html(formPage(theme, 'That photo is over 8 MB. Choose a smaller image and try again.'), 413) - const imageType = image.type.toLowerCase() - if (!IMAGE_TYPES.has(imageType)) return c.html(formPage(theme, 'Choose a JPEG, PNG, WebP, AVIF or HEIC image.'), 415) + if (!(image instanceof File) || image.size === 0) return c.html(formPage(theme, 'Choose a photo or video of your work to continue.'), 400) + const declared = image.type.toLowerCase() + const wantsVideo = isVideo(declared) || (!IMAGE_TYPES.has(declared) && /\.(mp4|m4v|mov|webm)$/i.test(image.name)) + if (wantsVideo) { + if (image.size > MAX_VIDEO_BYTES) return c.html(formPage(theme, 'That video is over 25 MB. Trim it or export a smaller version and try again.'), 413) + } else { + if (image.size > MAX_IMAGE_BYTES) return c.html(formPage(theme, 'That photo is over 8 MB. Choose a smaller image and try again.'), 413) + if (!IMAGE_TYPES.has(declared)) return c.html(formPage(theme, 'Choose a JPEG, PNG, WebP, AVIF or HEIC photo, or an MP4, MOV or WebM video.'), 415) + } const bytes = new Uint8Array(await image.arrayBuffer()) - if (!validImage(imageType, bytes)) return c.html(formPage(theme, 'That file does not look like a supported image.'), 415) - const id = house.add({ title, artist, medium, description, imageType, image: bytes }) + let imageType = declared + if (wantsVideo) { + const sniffed = sniffVideo(bytes) + if (!sniffed || !VIDEO_TYPES.has(sniffed)) return c.html(formPage(theme, 'That file does not look like an MP4, MOV or WebM video.'), 415) + imageType = sniffed + } else if (!validImage(imageType, bytes)) return c.html(formPage(theme, 'That file does not look like a supported image.'), 415) + const id = house.add({ title, artist, medium, description, imageType, image: bytes, topic }) return c.redirect(`/art/${encodeURIComponent(id)}`, 303) }) app.get('/', (c) => c.html(home(house, themeOf(c)))) @@ -217,4 +319,4 @@ const AUCTIONS_CSS = `/* Themes: "warm" (the original cream and green house look /* Blue must come after the :root default: equal specificity, so source order decides. */ html[data-theme=blue]{--ink:#16213a;--muted:#5f6b82;--soft:#46526a;--paper:#eef3fa;--white:#fbfdff;--green:#1f4f8f;--on-green:#f6f9ff;--on-green-accent:#9cc7ff;--line:#d2dcea;--accent:#2f7fe0;--frame:#dbe4f1;--panel:#e2eaf5;--art-1:#c9d8ee;--art-2:#8eaad3;--art-3:#3d6aa8;--art-4:#13294d;--art-shadow:#d6e1f1;--art-frame:#f2f7ff;--art-sun:#f1f5fb;--shape-1:#183865;--shape-2:#4b7fc4;--shape-3:#dbe7f6;--shape-4:#7aa3d9;--invite-eyebrow:#a8c9f2;--invite-em:#c6dcf8;--invite-small:#c5d1e4;--input-line:#c0cbdc;--input-bg:#fff;--upload-line:#98a8c2;--upload-bg:#f4f7fc;--error-bg:#fbe4e4;--error-ink:#8a2b2b;--focus:#3b82f6} .theme-pick{display:flex;align-items:center;gap:6px;color:var(--muted);font-size:11px;letter-spacing:.08em}.theme-pick a{display:inline-flex;align-items:center;gap:6px;padding:6px 10px;border:1px solid var(--line);border-radius:999px;color:var(--ink)}.theme-pick a[aria-current=true]{border-color:var(--green);font-weight:700}.theme-pick i{display:inline-block;width:10px;height:10px;border-radius:50%}.swatch-blue{background:#1f4f8f}.swatch-warm{background:#384b36} -:root{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;font-synthesis:none;text-rendering:optimizeLegibility}*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:var(--paper);color:var(--ink)}a{color:inherit;text-decoration:none}.top{height:82px;padding:0 max(6vw,24px);display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line)}.brand{display:flex;align-items:center;gap:12px;font-family:Georgia,serif;font-size:20px;line-height:1}.brand small{display:block;margin-top:7px;font:9px/1.1 ui-sans-serif,system-ui,sans-serif;letter-spacing:.15em;color:var(--muted)}.brand-mark{display:grid;place-items:center;width:38px;height:38px;border:1px solid var(--green);border-radius:50%;color:var(--green);font:italic 24px Georgia,serif}.top nav{display:flex;align-items:center;gap:32px;font-size:13px}.top nav a:first-child{color:var(--muted)}.nav-cta,.text-link{font-weight:650}.nav-cta span,.text-link span,.button span{margin-left:8px;color:var(--accent)}main{max-width:1440px;margin:0 auto;padding:0 max(6vw,24px)}.hero{position:relative;min-height:540px;padding:76px 0 86px;display:grid;grid-template-columns:1fr 1fr;align-items:center;gap:8%;border-bottom:1px solid var(--line)}.hero-copy{position:relative;z-index:1}.eyebrow{margin:0 0 18px;color:var(--green);font-size:10px;font-weight:750;letter-spacing:.19em}.hero h1,.form-wrap h1{margin:0;font:clamp(52px,6.5vw,88px)/.96 Georgia,"Times New Roman",serif;letter-spacing:-.055em;font-weight:400}.hero h1 em,.form-wrap h1 em,.invite h2 em{color:var(--green);font-weight:400}.lede{max-width:370px;margin:24px 0 28px;color:var(--soft);font:18px/1.55 Georgia,serif}.button{display:inline-flex;align-items:center;justify-content:center;min-height:48px;padding:0 22px;border:0;border-radius:2px;font:650 13px ui-sans-serif,system-ui,sans-serif;cursor:pointer}.button-dark{background:var(--green);color:var(--on-green)}.button-dark span{color:var(--on-green-accent)}.button-light{background:var(--paper);color:var(--ink)}.hero-note{margin:17px 0 0;color:var(--muted);font-size:11px}.hero-art{position:relative;overflow:hidden;width:min(100%,500px);height:380px;justify-self:end;background:linear-gradient(135deg,var(--art-1) 0%,var(--art-2) 47%,var(--art-3) 47%,var(--art-4) 100%);box-shadow:20px 24px 0 var(--art-shadow)}.hero-art:before{content:"";position:absolute;inset:18px;border:1px solid var(--art-frame);opacity:.7}.sun{position:absolute;top:46px;right:78px;width:132px;height:132px;border-radius:50%;background:var(--art-sun)}.shape{position:absolute;bottom:-50px;border-radius:50% 50% 0 0}.shape-one{left:4%;width:65%;height:63%;background:var(--shape-1);transform:rotate(-17deg)}.shape-two{right:-12%;width:62%;height:75%;background:var(--shape-2);transform:rotate(15deg)}.shape-three{left:27%;bottom:-28%;width:48%;height:65%;background:var(--shape-3);transform:rotate(33deg)}.shape-four{left:52%;bottom:-22%;width:30%;height:61%;background:var(--shape-4);transform:rotate(-24deg)}.art-caption{position:absolute;left:34px;bottom:28px;color:white;font:italic 18px/1.45 Georgia,serif}.art-caption b{font:10px ui-sans-serif,system-ui,sans-serif;letter-spacing:.12em;text-transform:uppercase}.hero-index{position:absolute;right:0;bottom:22px;color:var(--muted);font-size:10px;letter-spacing:.15em}.gallery-section{padding:74px 0 92px}.section-head{display:flex;align-items:end;justify-content:space-between;margin-bottom:30px}.section-head .eyebrow{margin-bottom:11px}.section-head h2,.invite h2{margin:0;font:clamp(30px,4vw,48px)/1.1 Georgia,serif;font-weight:400;letter-spacing:-.03em}.count{margin-left:7px;vertical-align:top;color:var(--muted);font:11px ui-sans-serif,system-ui,sans-serif;letter-spacing:0}.text-link{font-size:12px}.gallery{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:30px 22px}.art-card{min-width:0}.art-image{display:block;overflow:hidden;aspect-ratio:4/4.7;background:var(--frame)}.art-image img{width:100%;height:100%;display:block;object-fit:cover;transition:transform .35s}.art-card:hover img{transform:scale(1.025)}.art-info{padding:16px 1px}.art-info .eyebrow{font-size:9px;margin:0 0 8px}.art-info h3{margin:0;font:23px/1.2 Georgia,serif;font-weight:400}.art-info p{margin:7px 0 0;color:var(--muted);font:14px Georgia,serif}.empty{padding:72px 24px;text-align:center;background:var(--panel)}.empty-stamp{display:grid;place-items:center;width:55px;height:55px;margin:0 auto 20px;border:1px solid var(--green);border-radius:50%;color:var(--green);font-size:25px}.empty h3{margin:0;font:30px Georgia,serif;font-weight:400}.empty p{max-width:390px;margin:12px auto 22px;color:var(--muted);font:15px/1.6 Georgia,serif}.invite{margin:0 calc(-1 * max(6vw,24px));padding:76px max(6vw,24px);background:var(--green);color:var(--on-green);display:grid;grid-template-columns:1fr 1fr;gap:8%;align-items:center}.invite .eyebrow{color:var(--invite-eyebrow)}.invite h2 em{color:var(--invite-em)}.invite-copy p{max-width:360px;margin:0 0 24px;font:18px/1.55 Georgia,serif}.invite-copy small{display:block;max-width:390px;margin-top:15px;color:var(--invite-small);font-size:11px;line-height:1.5}.form-wrap{max-width:620px;margin:0 auto;padding:64px 0 100px}.back-link{display:inline-block;margin-bottom:52px;color:var(--muted);font-size:12px}.form-wrap .eyebrow{margin-bottom:16px}.form-wrap h1{font-size:clamp(48px,8vw,70px)}.form-intro{max-width:460px;margin:20px 0 30px;color:var(--soft);font:17px/1.6 Georgia,serif}.art-form{padding:30px;background:var(--white);border:1px solid var(--line)}.field{display:block;margin-bottom:21px}.field>span{display:block;margin-bottom:8px;font-size:12px;font-weight:700}.field b{color:var(--accent)}.field small{margin-left:7px;color:var(--muted);font-weight:400}.field input:not([type=file]),.field textarea{display:block;width:100%;min-height:48px;padding:12px 13px;border:1px solid var(--input-line);border-radius:2px;background:var(--input-bg);color:var(--ink);font:15px ui-sans-serif,system-ui,sans-serif}.field textarea{min-height:120px;resize:vertical}.upload-field input[type=file]{display:block;width:100%;min-height:54px;padding:13px;border:1px dashed var(--upload-line);background:var(--upload-bg);color:var(--ink);font-size:13px}.upload-field>small{display:block;margin:8px 0 0;color:var(--muted);font-size:11px}.permission{display:flex;gap:11px;align-items:flex-start;margin:24px 0;color:var(--soft);font-size:12px;line-height:1.55}.permission input{width:18px;height:18px;margin:0;flex:0 0 auto;accent-color:var(--green)}.submit-button{width:100%}.form-foot{margin:16px 0 0;color:var(--muted);font-size:11px;line-height:1.55}.error{padding:12px 14px;background:var(--error-bg);color:var(--error-ink);font-size:13px}.hp{position:absolute;left:-10000px;width:1px;height:1px;overflow:hidden}.detail-wrap{padding:48px 0 90px}.detail-wrap .back-link{margin-bottom:30px}.detail-grid{display:grid;grid-template-columns:minmax(0,1.1fr) minmax(280px,.75fr);gap:8%;align-items:start}.detail-image{display:block;width:100%;max-height:78vh;object-fit:contain;background:var(--frame)}.detail-copy{padding:10px 0}.detail-copy h1{margin:0;font:clamp(42px,6vw,72px)/1 Georgia,serif;font-weight:400;letter-spacing:-.04em}.detail-copy .artist{margin:13px 0 28px;color:var(--muted);font:18px Georgia,serif}.detail-description{margin:0 0 28px;font:16px/1.7 Georgia,serif}.footer-brand{font-size:17px}.footer-brand .brand-mark{width:33px;height:33px;font-size:21px}footer{display:flex;justify-content:space-between;align-items:center;padding:28px max(6vw,24px);border-top:1px solid var(--line)}footer p{max-width:240px;color:var(--muted);font:13px/1.5 Georgia,serif}input:focus-visible,textarea:focus-visible,a:focus-visible,button:focus-visible{outline:3px solid var(--focus);outline-offset:3px}@media(max-width:760px){.top{height:70px}.top nav{gap:16px;font-size:11px}.brand{font-size:17px}.brand small{font-size:8px}.hero{min-height:auto;padding:55px 0 65px;grid-template-columns:1fr;gap:38px}.hero h1{font-size:clamp(50px,13vw,76px)}.hero-art{width:100%;height:clamp(250px,75vw,380px);justify-self:stretch}.hero-index{bottom:18px}.gallery-section{padding:58px 0 68px}.section-head{align-items:start;gap:12px}.section-head .text-link{padding-top:20px;font-size:11px}.gallery{grid-template-columns:repeat(2,minmax(0,1fr));gap:20px 13px}.art-info h3{font-size:18px}.art-info p{font-size:12px}.invite{grid-template-columns:1fr;gap:24px;padding-top:56px;padding-bottom:58px}.form-wrap{padding:42px 0 72px}.back-link{margin-bottom:36px}.art-form{padding:20px}.detail-grid{grid-template-columns:1fr;gap:30px}.detail-image{max-height:65vh}footer{gap:18px;align-items:flex-start;flex-wrap:wrap}.theme-pick{flex-basis:100%}footer p{max-width:150px;font-size:12px}}@media(max-width:380px){.top{padding-inline:16px}.top nav{gap:12px}.top nav a:first-child{display:none}main{padding-inline:16px}.invite{margin-inline:-16px;padding-inline:16px}.hero-art{height:250px}.art-form{padding:16px}footer{padding-inline:16px}}@media(prefers-reduced-motion:reduce){html{scroll-behavior:auto}*,*:before,*:after{transition:none!important}}` +:root{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;font-synthesis:none;text-rendering:optimizeLegibility}*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:var(--paper);color:var(--ink)}a{color:inherit;text-decoration:none}.top{height:82px;padding:0 max(6vw,24px);display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line)}.brand{display:flex;align-items:center;gap:12px;font-family:Georgia,serif;font-size:20px;line-height:1}.brand small{display:block;margin-top:7px;font:9px/1.1 ui-sans-serif,system-ui,sans-serif;letter-spacing:.15em;color:var(--muted)}.brand-mark{display:grid;place-items:center;width:38px;height:38px;border:1px solid var(--green);border-radius:50%;color:var(--green);font:italic 24px Georgia,serif}.top nav{display:flex;align-items:center;gap:32px;font-size:13px}.top nav a:first-child{color:var(--muted)}.nav-cta,.text-link{font-weight:650}.nav-cta span,.text-link span,.button span{margin-left:8px;color:var(--accent)}main{max-width:1440px;margin:0 auto;padding:0 max(6vw,24px)}.hero{position:relative;min-height:540px;padding:76px 0 86px;display:grid;grid-template-columns:1fr 1fr;align-items:center;gap:8%;border-bottom:1px solid var(--line)}.hero-copy{position:relative;z-index:1}.eyebrow{margin:0 0 18px;color:var(--green);font-size:10px;font-weight:750;letter-spacing:.19em}.hero h1,.form-wrap h1{margin:0;font:clamp(52px,6.5vw,88px)/.96 Georgia,"Times New Roman",serif;letter-spacing:-.055em;font-weight:400}.hero h1 em,.form-wrap h1 em,.invite h2 em{color:var(--green);font-weight:400}.lede{max-width:370px;margin:24px 0 28px;color:var(--soft);font:18px/1.55 Georgia,serif}.button{display:inline-flex;align-items:center;justify-content:center;min-height:48px;padding:0 22px;border:0;border-radius:2px;font:650 13px ui-sans-serif,system-ui,sans-serif;cursor:pointer}.button-dark{background:var(--green);color:var(--on-green)}.button-dark span{color:var(--on-green-accent)}.button-light{background:var(--paper);color:var(--ink)}.hero-note{margin:17px 0 0;color:var(--muted);font-size:11px}.hero-art{position:relative;overflow:hidden;width:min(100%,500px);height:380px;justify-self:end;background:linear-gradient(135deg,var(--art-1) 0%,var(--art-2) 47%,var(--art-3) 47%,var(--art-4) 100%);box-shadow:20px 24px 0 var(--art-shadow)}.hero-art:before{content:"";position:absolute;inset:18px;border:1px solid var(--art-frame);opacity:.7}.sun{position:absolute;top:46px;right:78px;width:132px;height:132px;border-radius:50%;background:var(--art-sun)}.shape{position:absolute;bottom:-50px;border-radius:50% 50% 0 0}.shape-one{left:4%;width:65%;height:63%;background:var(--shape-1);transform:rotate(-17deg)}.shape-two{right:-12%;width:62%;height:75%;background:var(--shape-2);transform:rotate(15deg)}.shape-three{left:27%;bottom:-28%;width:48%;height:65%;background:var(--shape-3);transform:rotate(33deg)}.shape-four{left:52%;bottom:-22%;width:30%;height:61%;background:var(--shape-4);transform:rotate(-24deg)}.art-caption{position:absolute;left:34px;bottom:28px;color:white;font:italic 18px/1.45 Georgia,serif}.art-caption b{font:10px ui-sans-serif,system-ui,sans-serif;letter-spacing:.12em;text-transform:uppercase}.hero-index{position:absolute;right:0;bottom:22px;color:var(--muted);font-size:10px;letter-spacing:.15em}.gallery-section{padding:74px 0 92px}.section-head{display:flex;align-items:end;justify-content:space-between;margin-bottom:30px}.section-head .eyebrow{margin-bottom:11px}.section-head h2,.invite h2{margin:0;font:clamp(30px,4vw,48px)/1.1 Georgia,serif;font-weight:400;letter-spacing:-.03em}.count{margin-left:7px;vertical-align:top;color:var(--muted);font:11px ui-sans-serif,system-ui,sans-serif;letter-spacing:0}.text-link{font-size:12px}.gallery{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:30px 22px}.art-card{min-width:0}.art-image{display:block;overflow:hidden;aspect-ratio:4/4.7;background:var(--frame)}.art-image img,.art-image video{width:100%;height:100%;display:block;object-fit:cover;transition:transform .35s}.art-card:hover img,.art-card:hover video{transform:scale(1.025)}.art-info{padding:16px 1px}.art-info .eyebrow{font-size:9px;margin:0 0 8px}.art-info h3{margin:0;font:23px/1.2 Georgia,serif;font-weight:400}.art-info p{margin:7px 0 0;color:var(--muted);font:14px Georgia,serif}.empty{padding:72px 24px;text-align:center;background:var(--panel)}.empty-stamp{display:grid;place-items:center;width:55px;height:55px;margin:0 auto 20px;border:1px solid var(--green);border-radius:50%;color:var(--green);font-size:25px}.empty h3{margin:0;font:30px Georgia,serif;font-weight:400}.empty p{max-width:390px;margin:12px auto 22px;color:var(--muted);font:15px/1.6 Georgia,serif}.invite{margin:0 calc(-1 * max(6vw,24px));padding:76px max(6vw,24px);background:var(--green);color:var(--on-green);display:grid;grid-template-columns:1fr 1fr;gap:8%;align-items:center}.invite .eyebrow{color:var(--invite-eyebrow)}.invite h2 em{color:var(--invite-em)}.invite-copy p{max-width:360px;margin:0 0 24px;font:18px/1.55 Georgia,serif}.invite-copy small{display:block;max-width:390px;margin-top:15px;color:var(--invite-small);font-size:11px;line-height:1.5}.form-wrap{max-width:620px;margin:0 auto;padding:64px 0 100px}.back-link{display:inline-block;margin-bottom:52px;color:var(--muted);font-size:12px}.form-wrap .eyebrow{margin-bottom:16px}.form-wrap h1{font-size:clamp(48px,8vw,70px)}.form-intro{max-width:460px;margin:20px 0 30px;color:var(--soft);font:17px/1.6 Georgia,serif}.art-form{padding:30px;background:var(--white);border:1px solid var(--line)}.field{display:block;margin-bottom:21px}.field>span{display:block;margin-bottom:8px;font-size:12px;font-weight:700}.field b{color:var(--accent)}.field small{margin-left:7px;color:var(--muted);font-weight:400}.field input:not([type=file]),.field textarea{display:block;width:100%;min-height:48px;padding:12px 13px;border:1px solid var(--input-line);border-radius:2px;background:var(--input-bg);color:var(--ink);font:15px ui-sans-serif,system-ui,sans-serif}.field textarea{min-height:120px;resize:vertical}.upload-field input[type=file]{display:block;width:100%;min-height:54px;padding:13px;border:1px dashed var(--upload-line);background:var(--upload-bg);color:var(--ink);font-size:13px}.upload-field>small{display:block;margin:8px 0 0;color:var(--muted);font-size:11px}.permission{display:flex;gap:11px;align-items:flex-start;margin:24px 0;color:var(--soft);font-size:12px;line-height:1.55}.permission input{width:18px;height:18px;margin:0;flex:0 0 auto;accent-color:var(--green)}.submit-button{width:100%}.form-foot{margin:16px 0 0;color:var(--muted);font-size:11px;line-height:1.55}.error{padding:12px 14px;background:var(--error-bg);color:var(--error-ink);font-size:13px}.hp{position:absolute;left:-10000px;width:1px;height:1px;overflow:hidden}.detail-wrap{padding:48px 0 90px}.detail-wrap .back-link{margin-bottom:30px}.detail-grid{display:grid;grid-template-columns:minmax(0,1.1fr) minmax(280px,.75fr);gap:8%;align-items:start}.detail-image{display:block;width:100%;max-height:78vh;object-fit:contain;background:var(--frame)}.detail-copy{padding:10px 0}.detail-copy h1{margin:0;font:clamp(42px,6vw,72px)/1 Georgia,serif;font-weight:400;letter-spacing:-.04em}.detail-copy .artist{margin:13px 0 28px;color:var(--muted);font:18px Georgia,serif}.detail-description{margin:0 0 28px;font:16px/1.7 Georgia,serif}.footer-brand{font-size:17px}.footer-brand .brand-mark{width:33px;height:33px;font-size:21px}footer{display:flex;justify-content:space-between;align-items:center;padding:28px max(6vw,24px);border-top:1px solid var(--line)}footer p{max-width:240px;color:var(--muted);font:13px/1.5 Georgia,serif}.field select{display:block;width:100%;min-height:48px;padding:12px 13px;border:1px solid var(--input-line);border-radius:2px;background:var(--input-bg);color:var(--ink);font:15px ui-sans-serif,system-ui,sans-serif}.topics-section{padding:0 0 92px}.topic-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:22px}.topic-card{display:flex;flex-direction:column;border:1px solid var(--line);background:var(--white);transition:border-color .2s}.topic-card:hover{border-color:var(--green)}.topic-cover{position:relative;display:block;overflow:hidden;aspect-ratio:4/3;background:var(--panel)}.topic-cover img,.topic-cover video{width:100%;height:100%;display:block;object-fit:cover}.topic-body{display:block;padding:18px 20px 22px}.topic-body .eyebrow{display:block;margin:0 0 8px;font-size:9px}.topic-body h3{margin:0;font:26px/1.15 Georgia,serif;font-weight:400}.topic-body p{margin:8px 0 0;color:var(--muted);font:14px/1.5 Georgia,serif}.topic-lede{max-width:460px;margin:10px 0 0;color:var(--soft);font:16px/1.55 Georgia,serif}.topic-page .back-link{margin-bottom:30px}.topic-chips{display:flex;flex-wrap:wrap;gap:8px;margin:34px 0 0}.topic-chips a{padding:7px 13px;border:1px solid var(--line);border-radius:999px;font-size:12px;font-weight:650}.topic-chips a:hover{border-color:var(--green)}.detail-topic{margin:-14px 0 24px}.flower-ph{position:absolute;inset:0;display:block;background:radial-gradient(circle at 50% 120%,var(--frame),var(--panel) 70%)}.flower{position:absolute;left:50%;top:42%;width:46%;aspect-ratio:1;transform:translate(-50%,-50%);border-radius:50%;z-index:1}.flower:after{content:"";position:absolute;inset:36%;border-radius:50%;background:var(--c3)}.stem{position:absolute;left:calc(50% - 2px);top:64%;width:4px;height:40%;background:#5f7d4f;border-radius:2px}.stem:before{content:"";position:absolute;left:4px;top:30%;width:34px;height:16px;border-radius:0 100% 0 100%;background:#6f8f5c}.ph-dahlias .flower{--c3:#f2c35b;background:repeating-conic-gradient(#c2416b 0 9deg,#e98aa6 9deg 18deg);box-shadow:0 0 0 10px #d65f86 inset}.ph-mums .flower{--c3:#9a6a22;background:repeating-conic-gradient(#d9962b 0 5deg,#f6cf6a 5deg 10deg)}.ph-roses .flower{--c3:transparent;background:radial-gradient(circle,#6e1028 0 10%,#c8455e 11% 22%,#9e1f3a 23% 36%,#c8455e 37% 50%,#9e1f3a 51% 70%)}.ph-seacliffs{background:linear-gradient(#cfe0ec 0 58%,#8fb3cc 58%)}.sky-sun{position:absolute;right:20%;top:16%;width:16%;aspect-ratio:1;border-radius:50%;background:#f6d48a}.cliff{position:absolute;left:0;bottom:22%;width:58%;height:62%;background:#8a6f55;clip-path:polygon(0 18%,30% 6%,58% 14%,82% 30%,100% 100%,0 100%);box-shadow:inset -14px 0 0 #6f5640}.cliff:before{content:"";position:absolute;left:0;top:0;width:100%;height:18%;background:#6f8f5c;clip-path:polygon(0 100%,0 0,30% 0,58% 40%,82% 100%)}.sea{position:absolute;left:0;right:0;bottom:0;height:24%;background:repeating-linear-gradient(170deg,#4f7fa6 0 8px,#5f90b6 8px 16px)}.placeholder-tile{position:relative;border:1px dashed var(--upload-line)}.placeholder-label{position:absolute;left:50%;bottom:16px;transform:translateX(-50%);padding:6px 14px;border-radius:999px;background:var(--white);color:var(--soft);font:italic 14px Georgia,serif;white-space:nowrap;z-index:2}.topics-main .topics-section{padding-top:64px}.placeholder-card .art-info h3{color:var(--muted)}input:focus-visible,select:focus-visible,textarea:focus-visible,a:focus-visible,button:focus-visible{outline:3px solid var(--focus);outline-offset:3px}@media(max-width:760px){.top nav a:first-child{display:none}.topics-main .topics-section{padding-top:42px}.topic-grid{grid-template-columns:1fr;gap:16px}.topic-cover{aspect-ratio:16/9}.top{height:70px}.top nav{gap:16px;font-size:11px}.brand{font-size:17px}.brand small{font-size:8px}.hero{min-height:auto;padding:55px 0 65px;grid-template-columns:1fr;gap:38px}.hero h1{font-size:clamp(50px,13vw,76px)}.hero-art{width:100%;height:clamp(250px,75vw,380px);justify-self:stretch}.hero-index{bottom:18px}.gallery-section{padding:58px 0 68px}.section-head{align-items:start;gap:12px}.section-head .text-link{padding-top:20px;font-size:11px}.gallery{grid-template-columns:repeat(2,minmax(0,1fr));gap:20px 13px}.art-info h3{font-size:18px}.art-info p{font-size:12px}.invite{grid-template-columns:1fr;gap:24px;padding-top:56px;padding-bottom:58px}.form-wrap{padding:42px 0 72px}.back-link{margin-bottom:36px}.art-form{padding:20px}.detail-grid{grid-template-columns:1fr;gap:30px}.detail-image{max-height:65vh}footer{gap:18px;align-items:flex-start;flex-wrap:wrap}.theme-pick{flex-basis:100%}footer p{max-width:150px;font-size:12px}}@media(max-width:380px){.top{padding-inline:16px}.top nav{gap:12px}.top nav a:first-child{display:none}main{padding-inline:16px}.invite{margin-inline:-16px;padding-inline:16px}.hero-art{height:250px}.art-form{padding:16px}footer{padding-inline:16px}}@media(prefers-reduced-motion:reduce){html{scroll-behavior:auto}*,*:before,*:after{transition:none!important}}` diff --git a/test/auctions.test.ts b/test/auctions.test.ts index 38fb5fc..1520ecd 100644 --- a/test/auctions.test.ts +++ b/test/auctions.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'bun:test' -import { AuctionHouse, createAuctionApp, isAuctionHost } from '../src/auctions.js' +import { AuctionHouse, createAuctionApp, isAuctionHost, sniffVideo } from '../src/auctions.js' import { createApp } from '../src/server.js' const png = Uint8Array.from(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/l0sAAAAASUVORK5CYII=', 'base64')) @@ -137,4 +137,89 @@ describe('Art Auctions House', () => { expect(house.list()).toHaveLength(1) house.db.close() }) + + it('accepts a phone video, sniffs its real type and serves byte ranges', async () => { + const house = new AuctionHouse() + const app = createAuctionApp(house) + // A minimal ISO BMFF header: [size]ftyp + brand, then padding. + const mp4 = new Uint8Array(64) + mp4.set([0, 0, 0, 0x18], 0) + mp4.set([...'ftypqt '].map((ch) => ch.charCodeAt(0)), 4) + expect(sniffVideo(mp4)).toBe('video/quicktime') + const form = new FormData() + form.set('title', 'Dahlia timelapse') + form.set('artist', 'Ada Artist') + form.set('permission', 'yes') + form.set('image', new File([mp4], 'IMG_0001.MOV', { type: '' })) + const res = await app.fetch(new Request('https://auctions.pit.world/new', { method: 'POST', headers: { origin: 'https://auctions.pit.world' }, body: form }), { ip: '203.0.113.30' }) + expect(res.status).toBe(303) + const id = res.headers.get('location')!.split('/').at(-1)! + const page = await (await app.fetch(new Request(`https://auctions.pit.world/art/${id}`))).text() + expect(page).toContain('