feat(creator-packs): Phase 2 UI — Content Explorer, settings page, wizard step

Adds the three user-facing surfaces for Creator Packs on top of the Phase 1
backend rail. All surfaces HIDE entirely on builds without the release-injected
key (configured=false) so a fork never shows a broken install button.

- Backend: CreatorPackService.isConfigured() + `configured` flag on the
  GET /api/creator-packs response; /settings/creator-packs route + controller
  action.
- api client + useCreatorPacks hook (shared cached source of configured/packs/
  downloads for every surface) + CreatorPackCard (status badges, art fallback,
  update pill) + CreatorPacksSection (install-on-click grid + confirm modal with
  the license note), reused by the Content Explorer block and settings page.
- Content Explorer: Creator Packs section before Additional Content.
- Settings: new /settings/creator-packs manage page + conditional nav item.
- Easy Setup wizard: new Creator Packs step after Content. Replaces the fragile
  hardcoded AI-skip math with a computed activeSteps list so the two optional
  steps (Creator Packs + AI) navigate correctly in every on/off combination;
  selection state, storage projection, review summary, and finish-install wired.

Browser-tested on NOMAD3 across all surfaces (configured + fork paths), incl.
live install→download→cancel and AI-on/off wizard renumbering.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Sherwood 2026-07-15 11:03:02 -07:00
parent ac9640e64b
commit 326f947b2b
12 changed files with 458 additions and 35 deletions

View File

@ -14,10 +14,11 @@ export default class CreatorPacksController {
*/
async index({ response }: HttpContext) {
try {
const configured = this.creatorPackService.isConfigured()
const packs = await this.creatorPackService.listPacksWithStatus()
const downloadService = new DownloadService(QueueService.getInstance())
const downloads = await downloadService.listDownloadJobs('zim')
return { packs, downloads }
return { configured, packs, downloads }
} catch (error: any) {
logger.error('[CreatorPacksController] Failed to list creator packs:', error?.message || error)
return response.status(500).send({ message: 'Failed to load creator packs' })

View File

@ -99,6 +99,10 @@ export default class SettingsController {
return inertia.render('settings/zim/remote-explorer')
}
async creatorPacks({ inertia }: HttpContext) {
return inertia.render('settings/creator-packs')
}
async benchmark({ inertia }: HttpContext) {
const latestResult = await this.benchmarkService.getLatestResult()
const status = this.benchmarkService.getStatus()

View File

@ -48,6 +48,16 @@ export class CreatorPackService {
return (env.get('CREATOR_PACKS_WORKER_BASE') || DEFAULT_WORKER_BASE).replace(/\/+$/, '')
}
/**
* Whether this build can actually install packs i.e. the release-injected
* app key is present. Forks built from source have no key; the UI uses this to
* HIDE the Creator Packs surfaces entirely rather than show install buttons
* that would 503. The public catalog still lists, but nothing is installable.
*/
isConfigured(): boolean {
return !!env.get('CREATOR_PACKS_APP_KEY')
}
/** Stable, per-version download URL. Not signed — the gate is the auth header. */
packUrl(id: string, version: string): string {
return `${this.workerBase}/packs/${id}_${version}.zim`

View File

@ -0,0 +1,96 @@
import { formatBytes } from '~/lib/util'
import type { CreatorPackWithStatus } from '../../types/collections'
import classNames from 'classnames'
import {
IconChevronRight,
IconCircleCheck,
IconLoader2,
IconMovie,
} from '@tabler/icons-react'
export interface CreatorPackCardProps {
pack: CreatorPackWithStatus
/** In-session wizard selection highlight (before anything is installed). */
selected?: boolean
onClick?: (pack: CreatorPackWithStatus) => void
}
const CreatorPackCard: React.FC<CreatorPackCardProps> = ({ pack, selected, onClick }) => {
const isInstalled = pack.status === 'installed'
const isDownloading = pack.status === 'downloading'
const hasUpdate = !!pack.available_update_version
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)
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'
)}
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" />
)}
</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>
</div>
)
}
export default CreatorPackCard

View File

@ -0,0 +1,100 @@
import { useState } from 'react'
import { IconMovie } from '@tabler/icons-react'
import api from '~/lib/api'
import useCreatorPacks from '~/hooks/useCreatorPacks'
import useDownloads from '~/hooks/useDownloads'
import { useNotifications } from '~/context/NotificationContext'
import CreatorPackCard from '~/components/CreatorPackCard'
import StyledModal from '~/components/StyledModal'
import { formatBytes } from '~/lib/util'
import type { CreatorPackWithStatus } from '../../types/collections'
/**
* Install-on-click grid of Creator Packs + a confirm modal. Shared by the
* Content Explorer block and the /settings/creator-packs page. Renders NOTHING
* when the build isn't configured (fork / key unset) a fork never sees a
* broken install button. The Easy Setup wizard does NOT use this (it needs
* selection semantics, not install-on-click) and drives CreatorPackCard itself.
*/
const CreatorPacksSection: React.FC = () => {
const { configured, packs, invalidate: invalidateCreatorPacks } = useCreatorPacks()
const { invalidate: invalidateDownloads } = useDownloads({ filetype: 'zim' })
const { addNotification } = useNotifications()
const [packToInstall, setPackToInstall] = useState<CreatorPackWithStatus | null>(null)
const [installing, setInstalling] = useState(false)
if (!configured) return null
const handleConfirm = async () => {
if (!packToInstall) return
setInstalling(true)
try {
await api.installCreatorPack(packToInstall.id)
addNotification({ message: `Started installing "${packToInstall.name}"`, type: 'success' })
invalidateCreatorPacks()
invalidateDownloads()
setPackToInstall(null)
} catch (error) {
console.error('Error installing creator pack:', error)
addNotification({ message: 'An error occurred while starting the install.', type: 'error' })
} finally {
setInstalling(false)
}
}
return (
<>
<div className="flex items-center gap-3 mt-8 mb-4">
<div className="w-10 h-10 rounded-full bg-surface-primary border border-border-subtle flex items-center justify-center shadow-sm">
<IconMovie className="w-6 h-6 text-text-primary" />
</div>
<div>
<h3 className="text-xl font-semibold text-text-primary">Creator Packs</h3>
<p className="text-sm text-text-muted">
Branded video collections from creators, for offline viewing
</p>
</div>
</div>
{packs.length > 0 ? (
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{packs.map((pack) => (
<CreatorPackCard key={pack.id} pack={pack} onClick={setPackToInstall} />
))}
</div>
) : (
<p className="text-text-muted mt-4">No creator packs available.</p>
)}
<StyledModal
open={!!packToInstall}
title={packToInstall ? `Install ${packToInstall.name}?` : 'Install Creator Pack'}
onClose={() => !installing && setPackToInstall(null)}
onCancel={() => setPackToInstall(null)}
onConfirm={handleConfirm}
confirmText={
packToInstall?.available_update_version ? 'Update pack' : 'Install pack'
}
confirmIcon="IconDownload"
confirmLoading={installing}
icon={<IconMovie className="w-6 h-6" />}
>
{packToInstall && (
<div className="space-y-3 text-text-secondary">
<p>
{packToInstall.video_count} videos from {packToInstall.creator}, about{' '}
{formatBytes(packToInstall.size_mb * 1024 * 1024, 0)}. It will download in the
background and appear in Kiwix when ready.
</p>
<p className="text-sm text-text-muted">
Licensed content personal use, not for redistribution.
</p>
</div>
)}
</StyledModal>
</>
)
}
export default CreatorPacksSection

View File

@ -0,0 +1,45 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'
import api from '~/lib/api'
export const CREATOR_PACKS_KEY = 'creator-packs'
/**
* Shared, cached source of Creator Packs state for every surface (Content
* Explorer block, settings page, settings nav gate, Easy Setup wizard step).
*
* `configured` reflects whether this build carries the release-injected app key.
* When false (forks / key unset) the surfaces HIDE themselves the catalog is
* public so packs would otherwise list with install buttons that only 503.
*
* Polls while a pack download is in flight so the card status flips promptly;
* otherwise refetches lazily.
*/
const useCreatorPacks = (enabled = true) => {
const queryClient = useQueryClient()
const queryData = useQuery({
queryKey: [CREATOR_PACKS_KEY],
queryFn: () => api.getCreatorPacks(),
refetchInterval: (query) => {
const anyDownloading = query.state.data?.packs?.some((p) => p.status === 'downloading')
return anyDownloading ? 2000 : false
},
refetchOnWindowFocus: false,
staleTime: 60_000,
enabled,
})
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: [CREATOR_PACKS_KEY] })
}
return {
...queryData,
configured: queryData.data?.configured ?? false,
packs: queryData.data?.packs ?? [],
downloads: queryData.data?.downloads ?? [],
invalidate,
}
}
export default useCreatorPacks

View File

@ -8,6 +8,7 @@ import {
IconGavel,
IconHeart,
IconMapRoute,
IconMovie,
IconSettings,
IconWand,
IconZoom
@ -16,11 +17,15 @@ import { usePage } from '@inertiajs/react'
import StyledSidebar from '~/components/StyledSidebar'
import { getServiceLink } from '~/lib/navigation'
import useServiceInstalledStatus from '~/hooks/useServiceInstalledStatus'
import useCreatorPacks from '~/hooks/useCreatorPacks'
import { SERVICE_NAMES } from '../../constants/service_names'
export default function SettingsLayout({ children }: { children: React.ReactNode }) {
const { aiAssistantName } = usePage<{ aiAssistantName: string }>().props
const aiAssistantInstallStatus = useServiceInstalledStatus(SERVICE_NAMES.OLLAMA)
// Only show the Creator Packs entry on builds that can actually install packs
// (release-injected key present) — a fork built from source has no key.
const { configured: creatorPacksConfigured } = useCreatorPacks()
const navigation = [
...(aiAssistantInstallStatus.isInstalled ? [{ name: aiAssistantName, href: '/settings/models', icon: IconWand, current: false }] : []),
@ -28,6 +33,7 @@ export default function SettingsLayout({ children }: { children: React.ReactNode
{ name: 'Benchmark', href: '/settings/benchmark', icon: IconChartBar, current: false },
{ name: 'Content Explorer', href: '/settings/zim/remote-explorer', icon: IconZoom, current: false },
{ name: 'Content Manager', href: '/settings/zim', icon: IconFolder, current: false },
...(creatorPacksConfigured ? [{ name: 'Creator Packs', href: '/settings/creator-packs', icon: IconMovie, current: false }] : []),
{ name: 'Maps Manager', href: '/settings/maps', icon: IconMapRoute, current: false },
{
name: 'Service Logs & Metrics',

View File

@ -6,7 +6,7 @@ import { AppAutoUpdateStatus, AutoUpdateStatus, CheckLatestVersionResult, Conten
import { DownloadJobWithProgress, WikipediaState } from '../../types/downloads'
import type { Country, CountryCode, CountryGroup, MapExtractPreflight } from '../../types/maps'
import { EmbedJobWithProgress, FileWarningsResult, StoredFileInfo } from '../../types/rag'
import type { CategoryWithStatus, CollectionWithStatus, ContentUpdateCheckResult, ResourceUpdateInfo } from '../../types/collections'
import type { CategoryWithStatus, CollectionWithStatus, ContentUpdateCheckResult, CreatorPackWithStatus, ResourceUpdateInfo } from '../../types/collections'
import { catchInternal } from './util'
import { NomadChatResponse, NomadInstalledModel, NomadOllamaModel, OllamaChatRequest } from '../../types/ollama'
import BenchmarkResult from '#models/benchmark_result'
@ -697,6 +697,26 @@ class API {
})()
}
async getCreatorPacks() {
return catchInternal(async () => {
const response = await this.client.get<{
configured: boolean
packs: CreatorPackWithStatus[]
downloads: DownloadJobWithProgress[]
}>('/creator-packs')
return response.data
})()
}
async installCreatorPack(id: string) {
return catchInternal(async () => {
const response = await this.client.post<{ message: string; filename?: string }>(
`/creator-packs/${id}/install`
)
return response.data
})()
}
async listDocs() {
return catchInternal(async () => {
const response = await this.client.get<Array<{ title: string; slug: string }>>('/docs/list')

View File

@ -7,6 +7,7 @@ import api from '~/lib/api'
import { ServiceSlim } from '../../../types/services'
import CuratedCollectionCard from '~/components/CuratedCollectionCard'
import CategoryCard from '~/components/CategoryCard'
import CreatorPackCard from '~/components/CreatorPackCard'
import TierSelectionModal from '~/components/TierSelectionModal'
import WikipediaSelector from '~/components/WikipediaSelector'
import LoadingSpinner from '~/components/LoadingSpinner'
@ -15,6 +16,7 @@ import { IconCheck, IconChevronDown, IconChevronUp, IconCpu, IconBooks } from '@
import StorageProjectionBar from '~/components/StorageProjectionBar'
import { useNotifications } from '~/context/NotificationContext'
import useInternetStatus from '~/hooks/useInternetStatus'
import useCreatorPacks from '~/hooks/useCreatorPacks'
import { useSystemInfo } from '~/hooks/useSystemInfo'
import { getPrimaryDiskInfo } from '~/hooks/useDiskDisplayData'
import classNames from 'classnames'
@ -106,7 +108,19 @@ const ADDITIONAL_TOOLS: Capability[] = [
},
]
type WizardStep = 1 | 2 | 3 | 4 | 5
// Stable step IDs. Creator Packs (4) and AI (5) are BOTH optional, so the set of
// active steps is computed at runtime (see `activeSteps`) and navigation walks
// that ordered list rather than doing hardcoded skip math.
type WizardStep = 1 | 2 | 3 | 4 | 5 | 6
const STEP_LABELS: Record<WizardStep, string> = {
1: 'Apps',
2: 'Maps',
3: 'Content',
4: 'Creator Packs',
5: 'AI',
6: 'Review',
}
const CURATED_MAP_COLLECTIONS_KEY = 'curated-map-collections'
const CURATED_CATEGORIES_KEY = 'curated-categories'
@ -121,6 +135,7 @@ export default function EasySetupWizard(props: {
const [currentStep, setCurrentStep] = useState<WizardStep>(1)
const [selectedServices, setSelectedServices] = useState<string[]>([])
const [selectedMapCollections, setSelectedMapCollections] = useState<string[]>([])
const [selectedCreatorPacks, setSelectedCreatorPacks] = useState<string[]>([])
const [selectedAiModels, setSelectedAiModels] = useState<string[]>([])
// Auto-index policy for the AI Assistant Knowledge Base. Defaults to
// 'Always' so a new user who keeps the default behavior gets the "just
@ -149,10 +164,13 @@ export default function EasySetupWizard(props: {
const { isOnline } = useInternetStatus()
const queryClient = useQueryClient()
const { data: systemInfo } = useSystemInfo({ enabled: true })
// Creator Packs are hidden entirely on builds without the release-injected key.
const { configured: creatorPacksConfigured, packs: creatorPacks } = useCreatorPacks()
const anySelectionMade =
selectedServices.length > 0 ||
selectedMapCollections.length > 0 ||
selectedCreatorPacks.length > 0 ||
selectedTiers.size > 0 ||
selectedAiModels.length > 0 ||
(selectedWikipedia !== null && selectedWikipedia !== 'none')
@ -212,12 +230,29 @@ export default function EasySetupWizard(props: {
[selectedServices, installedServices, remoteOllamaEnabled]
)
// Ordered list of the steps actually shown to THIS user. Creator Packs (4) and
// AI (5) are both optional; everything else is fixed. Navigation walks this
// list (see handleNext/handleBack), so a step's absence needs no skip math.
const activeSteps = useMemo<WizardStep[]>(() => {
const steps: WizardStep[] = [1, 2, 3]
if (creatorPacksConfigured) steps.push(4)
if (isAiInSetup) steps.push(5)
steps.push(6)
return steps
}, [creatorPacksConfigured, isAiInSetup])
const toggleMapCollection = (slug: string) => {
setSelectedMapCollections((prev) =>
prev.includes(slug) ? prev.filter((s) => s !== slug) : [...prev, slug]
)
}
const toggleCreatorPack = (id: string) => {
setSelectedCreatorPacks((prev) =>
prev.includes(id) ? prev.filter((p) => p !== id) : [...prev, id]
)
}
const toggleAiModel = (modelName: string) => {
setSelectedAiModels((prev) =>
prev.includes(modelName) ? prev.filter((m) => m !== modelName) : [...prev, modelName]
@ -280,6 +315,14 @@ export default function EasySetupWizard(props: {
})
}
// Add creator packs
selectedCreatorPacks.forEach((id) => {
const pack = creatorPacks.find((p) => p.id === id)
if (pack) {
totalBytes += pack.size_mb * 1024 * 1024
}
})
// Add AI models
if (recommendedModels) {
selectedAiModels.forEach((modelName) => {
@ -315,10 +358,12 @@ export default function EasySetupWizard(props: {
}, [
selectedTiers,
selectedMapCollections,
selectedCreatorPacks,
selectedAiModels,
selectedWikipedia,
categories,
mapCollections,
creatorPacks,
recommendedModels,
wikipediaState,
])
@ -326,10 +371,9 @@ export default function EasySetupWizard(props: {
// Get primary disk/filesystem info for storage projection
const storageInfo = getPrimaryDiskInfo(systemInfo?.disk, systemInfo?.fsSize)
// Final step number (4 when AI is off, 5 when AI is on). Centralizing this
// here so canProceedToNextStep / handleNext / handleBack / the bottom-bar
// Next-vs-Finish switch all read the same value.
const finalStep: WizardStep = isAiInSetup ? 5 : 4
// The review step is always the last active step. Read by canProceedToNextStep
// and the bottom-bar Next-vs-Finish switch.
const finalStep: WizardStep = activeSteps[activeSteps.length - 1]
const canProceedToNextStep = () => {
if (!isOnline) return false // Must be online to proceed
@ -337,18 +381,18 @@ export default function EasySetupWizard(props: {
return currentStep < finalStep
}
// Navigate to the next/previous ACTIVE step relative to the current one. Using
// a value comparison (not indexOf) keeps nav correct even if currentStep goes
// momentarily stale — e.g. the user disables AI while standing on the AI step,
// dropping it from activeSteps; the next click still lands on the right step.
const handleNext = () => {
if (currentStep >= finalStep) return
// Skip the AI step (4) on forward nav when isAiInSetup is false.
const next = currentStep === 3 && !isAiInSetup ? 5 : currentStep + 1
setCurrentStep(next as WizardStep)
const next = activeSteps.find((s) => s > currentStep)
if (next !== undefined) setCurrentStep(next)
}
const handleBack = () => {
if (currentStep <= 1) return
// Skip the AI step (4) on back nav when isAiInSetup is false.
const prev = currentStep === 5 && !isAiInSetup ? 3 : currentStep - 1
setCurrentStep(prev as WizardStep)
const prev = [...activeSteps].reverse().find((s) => s < currentStep)
if (prev !== undefined) setCurrentStep(prev)
}
const handleFinish = async () => {
@ -408,6 +452,7 @@ export default function EasySetupWizard(props: {
const downloadPromises = [
...selectedMapCollections.map((slug) => api.downloadMapCollection(slug)),
...categoryTierPromises,
...selectedCreatorPacks.map((id) => api.installCreatorPack(id)),
...selectedAiModels.map((modelName) => api.downloadModel(modelName)),
]
@ -471,24 +516,14 @@ export default function EasySetupWizard(props: {
const renderStepIndicator = () => {
// `step` is the stable WizardStep value (1=Apps, 2=Maps, 3=Content,
// 4=AI, 5=Review). `displayNumber` is the sequential position shown in
// the dot (always 1..N) so users see "1 2 3 4" when AI is off and
// "1 2 3 4 5" when AI is on, with no gap.
const baseSteps: Array<{ step: WizardStep; label: string }> = isAiInSetup
? [
{ step: 1, label: 'Apps' },
{ step: 2, label: 'Maps' },
{ step: 3, label: 'Content' },
{ step: 4, label: 'AI' },
{ step: 5, label: 'Review' },
]
: [
{ step: 1, label: 'Apps' },
{ step: 2, label: 'Maps' },
{ step: 3, label: 'Content' },
{ step: 5, label: 'Review' },
]
const steps = baseSteps.map((s, idx) => ({ ...s, displayNumber: idx + 1 }))
// 4=Creator Packs, 5=AI, 6=Review). Only the ACTIVE steps are shown, and
// `displayNumber` is the sequential position in the dot (always 1..N) so
// there's no gap when Creator Packs and/or AI are absent.
const steps = activeSteps.map((step, idx) => ({
step,
label: STEP_LABELS[step],
displayNumber: idx + 1,
}))
return (
<nav aria-label="Progress" className="px-6 pt-6">
@ -988,6 +1023,43 @@ export default function EasySetupWizard(props: {
)
}
const renderCreatorPacks = () => {
// Creator Packs step. Only present when this build is configured (see
// activeSteps); a fork built without the key never reaches it. Selection is
// by pack id, held in selectedCreatorPacks; installs fire in handleFinish.
return (
<div className="space-y-6">
<div className="text-center mb-6">
<h2 className="text-3xl font-bold text-text-primary mb-2">Stock Creator Packs</h2>
<p className="text-text-secondary">
Branded video collections from creators, downloaded for offline viewing in Kiwix.
</p>
</div>
{creatorPacks.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{creatorPacks.map((pack) => (
<CreatorPackCard
key={pack.id}
pack={pack}
selected={selectedCreatorPacks.includes(pack.id)}
onClick={
pack.status === 'installed' && !pack.available_update_version
? undefined
: () => isOnline && toggleCreatorPack(pack.id)
}
/>
))}
</div>
) : (
<div className="text-center py-12">
<p className="text-text-secondary text-lg">No creator packs available right now.</p>
</div>
)}
</div>
)
}
const renderStep4 = () => {
// AI step (issue #905). Only rendered when isAiInSetup is true; otherwise
// the wizard's step array drops it and forward/back nav jumps Content → Review.
@ -1135,6 +1207,7 @@ export default function EasySetupWizard(props: {
const hasSelections =
selectedServices.length > 0 ||
selectedMapCollections.length > 0 ||
selectedCreatorPacks.length > 0 ||
selectedTiers.size > 0 ||
selectedAiModels.length > 0 ||
(selectedWikipedia !== null && selectedWikipedia !== 'none')
@ -1197,6 +1270,25 @@ export default function EasySetupWizard(props: {
</div>
)}
{selectedCreatorPacks.length > 0 && (
<div className="bg-surface-primary rounded-lg border-2 border-desert-stone-light p-6">
<h3 className="text-xl font-semibold text-text-primary mb-4">
Creator Packs to Install ({selectedCreatorPacks.length})
</h3>
<ul className="space-y-2">
{selectedCreatorPacks.map((id) => {
const pack = creatorPacks.find((p) => p.id === id)
return (
<li key={id} className="flex items-center">
<IconCheck size={20} className="text-desert-green mr-2" />
<span className="text-text-primary">{pack?.name || id}</span>
</li>
)
})}
</ul>
</div>
)}
{selectedTiers.size > 0 && (
<div className="bg-surface-primary rounded-lg border-2 border-desert-stone-light p-6">
<h3 className="text-xl font-semibold text-text-primary mb-4">
@ -1335,8 +1427,9 @@ export default function EasySetupWizard(props: {
{currentStep === 1 && renderStep1()}
{currentStep === 2 && renderStep2()}
{currentStep === 3 && renderStep3()}
{currentStep === 4 && isAiInSetup && renderStep4()}
{currentStep === 5 && renderStep5()}
{currentStep === 4 && creatorPacksConfigured && renderCreatorPacks()}
{currentStep === 5 && isAiInSetup && renderStep4()}
{currentStep === 6 && renderStep5()}
<div className="flex justify-between mt-8 pt-4 border-t border-desert-stone-light">
<div className="flex space-x-4 items-center">
@ -1361,6 +1454,12 @@ export default function EasySetupWizard(props: {
, {selectedMapCollections.length} map region
{selectedMapCollections.length !== 1 && 's'}, {selectedTiers.size}{' '}
content categor{selectedTiers.size !== 1 ? 'ies' : 'y'},{' '}
{creatorPacksConfigured && (
<>
{selectedCreatorPacks.length} creator pack
{selectedCreatorPacks.length !== 1 && 's'},{' '}
</>
)}
{selectedAiModels.length} AI model{selectedAiModels.length !== 1 && 's'} selected
</p>
</div>

View File

@ -0,0 +1,37 @@
import { Head } from '@inertiajs/react'
import SettingsLayout from '~/layouts/SettingsLayout'
import CreatorPacksSection from '~/components/CreatorPacksSection'
import ActiveDownloads from '~/components/ActiveDownloads'
import useCreatorPacks from '~/hooks/useCreatorPacks'
export default function CreatorPacksPage() {
const { configured } = useCreatorPacks()
return (
<SettingsLayout>
<Head title="Creator Packs" />
<div className="xl:pl-72 w-full">
<main className="px-12 py-6">
<h1 className="text-4xl font-semibold mb-2">Creator Packs</h1>
<p className="text-text-muted mb-4">
Install branded video collections from creators. Packs download in the background and
play offline through Kiwix.
</p>
{configured ? (
<>
<CreatorPacksSection />
<div className="mt-10">
<ActiveDownloads filetype="zim" withHeader />
</div>
</>
) : (
<p className="text-text-muted mt-4">
Creator Packs aren&apos;t available on this build.
</p>
)}
</main>
</div>
</SettingsLayout>
)
}

View File

@ -33,6 +33,7 @@ import {
} from '@tabler/icons-react'
import useDebounce from '~/hooks/useDebounce'
import CategoryCard from '~/components/CategoryCard'
import CreatorPacksSection from '~/components/CreatorPacksSection'
import TierSelectionModal from '~/components/TierSelectionModal'
import WikipediaSelector from '~/components/WikipediaSelector'
import StyledSectionHeader from '~/components/StyledSectionHeader'
@ -506,6 +507,9 @@ export default function ZimRemoteExplorer() {
</div>
) : null}
{/* Creator Packs (hidden entirely when this build isn't configured) */}
<CreatorPacksSection />
{/* Tiered Category Collections */}
<div className="flex items-center gap-3 mt-8 mb-4">
<div className="w-10 h-10 rounded-full bg-surface-primary border border-border-subtle flex items-center justify-center shadow-sm">

View File

@ -56,6 +56,7 @@ router
router.get('/update', [SettingsController, 'update'])
router.get('/zim', [SettingsController, 'zim'])
router.get('/zim/remote-explorer', [SettingsController, 'zimRemote'])
router.get('/creator-packs', [SettingsController, 'creatorPacks'])
router.get('/benchmark', [SettingsController, 'benchmark'])
router.get('/support', [SettingsController, 'support'])
router.get('/advanced', [SettingsController, 'advanced'])