mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-14 08:53:11 +00:00
96 lines
2.9 KiB
TypeScript
96 lines
2.9 KiB
TypeScript
import { slugify } from '@/lib/slug'
|
|
|
|
/**
|
|
* S21 — pure shapes of the public jingles page. The server read resolves the
|
|
* cover/audio media and hands the client a serializable view model, so the
|
|
* download name and the fail-closed card rules live here (client-safe, no
|
|
* Payload imports).
|
|
*/
|
|
|
|
export type JingleMedia = {
|
|
url?: string | null
|
|
alt?: string | null
|
|
filename?: string | null
|
|
}
|
|
|
|
export type JingleSource = {
|
|
id: number
|
|
title: string
|
|
slug?: string | null
|
|
}
|
|
|
|
export type JingleViewModel = {
|
|
id: number
|
|
title: string
|
|
coverUrl: string
|
|
coverAlt: string
|
|
audioUrl: string
|
|
downloadFilename: string
|
|
}
|
|
|
|
const DOWNLOAD_PREFIX = 'jorge-solla-1313'
|
|
const FALLBACK_SLUG = 'jingle'
|
|
const FALLBACK_EXTENSION = 'mp3'
|
|
|
|
/**
|
|
* Legible download name shown under the button and sent in the `download`
|
|
* attribute: the stored upload filename can carry accents/spaces, so the base
|
|
* comes from the slug and the extension from the actual file (fallback MP3).
|
|
*/
|
|
export const jingleDownloadFilename = (
|
|
slug: string | null | undefined,
|
|
audioFilename?: string | null,
|
|
): string => {
|
|
const base = slugify(slug ?? '') || FALLBACK_SLUG
|
|
const lastDot = audioFilename?.lastIndexOf('.') ?? -1
|
|
const extension = lastDot > 0 ? audioFilename?.slice(lastDot + 1).toLowerCase() : null
|
|
const safeExtension =
|
|
extension && /^[a-z0-9]{1,5}$/.test(extension) ? extension : FALLBACK_EXTENSION
|
|
|
|
return `${DOWNLOAD_PREFIX}-${base}.${safeExtension}`
|
|
}
|
|
|
|
const FEAT_CREDIT_PATTERN = /\s*\(\s*feat\.\s+([^()]+?)\s*\)\s*$/i
|
|
|
|
/**
|
|
* S26 — display-only split of the featured artist. Published titles carry the
|
|
* guest as a trailing `(feat. …)` segment; the card shows the base on its own
|
|
* heading and the credit on a second line. The stored title is never rewritten:
|
|
* the full string keeps feeding the aria-labels and the cover alt, and a title
|
|
* without the segment falls back to itself with no credit line.
|
|
*/
|
|
export const splitJingleTitle = (title: string) => {
|
|
const match = title.match(FEAT_CREDIT_PATTERN)
|
|
if (!match) return { base: title, credit: null }
|
|
|
|
const base = title.slice(0, match.index).trim()
|
|
const artist = match[1].trim()
|
|
if (!base || !artist) return { base: title, credit: null }
|
|
|
|
return { base, credit: `feat. ${artist}` }
|
|
}
|
|
|
|
/**
|
|
* A card only exists when both media are readable: an unpopulated/missing
|
|
* cover or audio fails closed (the jingle is skipped, never rendered broken).
|
|
*/
|
|
export const toJingleViewModel = ({
|
|
jingle,
|
|
cover,
|
|
audio,
|
|
}: {
|
|
jingle: JingleSource
|
|
cover: JingleMedia | null
|
|
audio: JingleMedia | null
|
|
}): JingleViewModel | null => {
|
|
if (!cover?.url || !audio?.url) return null
|
|
|
|
return {
|
|
id: jingle.id,
|
|
title: jingle.title,
|
|
coverUrl: cover.url,
|
|
coverAlt: cover.alt?.trim() || `Capa do jingle ${jingle.title} de Jorge Solla 1313`,
|
|
audioUrl: audio.url,
|
|
downloadFilename: jingleDownloadFilename(jingle.slug, audio.filename),
|
|
}
|
|
}
|