feat(creator-packs): use branded pack banners on the cards

Replace the generic colored-block cards with the branded 1060x175 banners we
build into each pack ZIM, shown on both the settings page and the Easy Setup
wizard step (and the Content Explorer block, via the shared card).

- Bundle the seed-pack banners as local webp under admin/public/creator-packs/
  so they render offline (no external image dependency, matching the existing
  /rogue-support-banner.webp convention).
- CreatorPackCard is now banner-forward: the banner is the hero, with a compact
  status/metadata footer (videos · size, Installed/Downloading/Selected/Install,
  update pill). Falls back to a simple header if an image is ever missing.
- Prefer an optional catalog `banner_url` (future remote creators) over the
  bundled-by-id path; add banner_url to the CreatorPack type + validator.
- Widen the card grids to 1–2 columns since banners are wide.

Browser-tested on NOMAD3: banners render on the settings page and wizard step;
selection/installed/downloading states and storage projection still correct.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Sherwood 2026-07-15 11:14:30 -07:00
parent 326f947b2b
commit 9a864cb41b
7 changed files with 65 additions and 54 deletions

View File

@ -86,6 +86,7 @@ export const creatorPacksSpecSchema = vine.object({
video_count: vine.number().min(0),
size_mb: vine.number().min(0),
license_id: vine.string(),
banner_url: vine.string().url().optional(),
poster_url: vine.string().url().optional(),
logo_url: vine.string().url().optional(),
})

View File

@ -1,3 +1,4 @@
import { useState } from 'react'
import { formatBytes } from '~/lib/util'
import type { CreatorPackWithStatus } from '../../types/collections'
import classNames from 'classnames'
@ -22,72 +23,75 @@ const CreatorPackCard: React.FC<CreatorPackCardProps> = ({ pack, selected, onCli
const sizeBytes = pack.size_mb * 1024 * 1024
// Installed packs are inert unless a newer version is available (click = update).
// Selected (wizard) and available packs are always clickable.
const clickable = selected || !isInstalled || hasUpdate
const highlighted = selected || isDownloading || (isInstalled && !hasUpdate)
// Prefer a catalog-supplied banner (future remote creators); otherwise the
// banner bundled with the app by pack id. Both are the branded 1060x175 art we
// build into the ZIM. Fall back to a simple header only if the image is absent.
const [bannerFailed, setBannerFailed] = useState(false)
const bannerSrc = pack.banner_url || `/creator-packs/${pack.id}.webp`
const statusBadge = selected ? (
<span className="flex items-center text-lime-600 dark:text-lime-400 text-sm font-medium">
<IconCircleCheck className="w-5 h-5 mr-1" />
Selected
</span>
) : isDownloading ? (
<span className="flex items-center text-lime-600 dark:text-lime-400 text-sm font-medium">
<IconLoader2 className="w-5 h-5 mr-1 animate-spin" />
Downloading
</span>
) : isInstalled ? (
<span className="flex items-center text-lime-600 dark:text-lime-400 text-sm font-medium">
<IconCircleCheck className="w-5 h-5 mr-1" />
Installed
</span>
) : (
<span className="flex items-center text-text-muted text-sm font-medium">
Install
<IconChevronRight className="w-5 h-5 ml-1" />
</span>
)
return (
<div
className={classNames(
'flex flex-col bg-desert-green rounded-lg p-6 text-white border shadow-sm transition-shadow h-80',
highlighted ? 'border-lime-400 border-2' : 'border-desert-green',
clickable ? 'cursor-pointer hover:shadow-lg' : 'opacity-65 cursor-not-allowed'
'flex flex-col rounded-lg overflow-hidden bg-surface-primary border shadow-sm transition-shadow',
highlighted ? 'border-lime-400 border-2' : 'border-border-subtle',
clickable ? 'cursor-pointer hover:shadow-lg' : 'opacity-70 cursor-not-allowed'
)}
onClick={() => {
if (!clickable) return
onClick?.(pack)
}}
>
<div className="flex items-center mb-2">
<div className="flex justify-between w-full items-center">
<div className="flex items-center min-w-0">
{pack.logo_url ? (
<img
src={pack.logo_url}
alt=""
className="w-6 h-6 mr-2 rounded object-cover shrink-0"
/>
) : (
<IconMovie className="w-6 h-6 mr-2 shrink-0" />
)}
<h3 className="text-lg font-semibold truncate">{pack.name}</h3>
</div>
{selected ? (
<div className="flex items-center shrink-0">
<IconCircleCheck className="w-5 h-5 text-lime-400" />
<span className="text-lime-400 text-sm ml-1">Selected</span>
</div>
) : isDownloading ? (
<div className="flex items-center shrink-0">
<IconLoader2 className="w-5 h-5 text-lime-400 animate-spin" />
<span className="text-lime-400 text-sm ml-1">Downloading</span>
</div>
) : isInstalled ? (
<div className="flex items-center shrink-0">
<IconCircleCheck className="w-5 h-5 text-lime-400" />
<span className="text-lime-400 text-sm ml-1">Installed</span>
</div>
) : (
<IconChevronRight className="w-5 h-5 text-white opacity-70 shrink-0" />
)}
{!bannerFailed ? (
<img
src={bannerSrc}
alt={pack.name}
className="w-full block aspect-[1060/175] object-cover"
onError={() => setBannerFailed(true)}
/>
) : (
<div className="flex items-center gap-2 bg-desert-green text-white px-5 py-6">
<IconMovie className="w-6 h-6 shrink-0" />
<h3 className="text-lg font-semibold truncate">{pack.name}</h3>
</div>
</div>
<p className="text-gray-300 text-xs mb-3">by {pack.creator}</p>
<p className="text-gray-200 grow">{pack.description}</p>
{hasUpdate && (
<span className="self-start mt-2 text-xs px-2 py-1 rounded bg-lime-500/30 text-lime-200">
Update available
</span>
)}
<div className="mt-4 pt-4 border-t border-white/20">
<p className="text-gray-300 text-xs">
{pack.video_count} videos | {formatBytes(sizeBytes, 0)}
</p>
<div className="flex items-center justify-between gap-3 px-5 py-3">
<div className="min-w-0">
<p className="text-sm text-text-secondary truncate">
{pack.video_count} videos &middot; {formatBytes(sizeBytes, 0)}
</p>
{hasUpdate && (
<span className="inline-block mt-1 text-xs px-2 py-0.5 rounded bg-lime-500/20 text-lime-700 dark:text-lime-300">
Update available
</span>
)}
</div>
{statusBadge}
</div>
</div>
)

View File

@ -58,7 +58,7 @@ const CreatorPacksSection: React.FC = () => {
</div>
{packs.length > 0 ? (
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div className="mt-4 grid grid-cols-1 lg:grid-cols-2 gap-6">
{packs.map((pack) => (
<CreatorPackCard key={pack.id} pack={pack} onClick={setPackToInstall} />
))}

View File

@ -1037,7 +1037,7 @@ export default function EasySetupWizard(props: {
</div>
{creatorPacks.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{creatorPacks.map((pack) => (
<CreatorPackCard
key={pack.id}

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

View File

@ -77,7 +77,13 @@ export type CreatorPack = {
video_count: number
size_mb: number
license_id: string
/** Optional card art (Phase 2). The Kiwix grid icon is baked into the ZIM. */
/**
* Optional branded 1060x175 banner (the card hero). When omitted, the app
* uses the banner it bundles by pack id (`/creator-packs/<id>.webp`). The
* Kiwix grid icon is baked into the ZIM separately.
*/
banner_url?: string
/** Optional card art. */
poster_url?: string
logo_url?: string
}