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:
parent
ac9640e64b
commit
326f947b2b
|
|
@ -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' })
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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't available on this build.
|
||||
</p>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
)
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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'])
|
||||
|
|
|
|||
Loading…
Reference in New Issue