diff --git a/admin/app/controllers/creator_packs_controller.ts b/admin/app/controllers/creator_packs_controller.ts index 0d520c8..0a90a10 100644 --- a/admin/app/controllers/creator_packs_controller.ts +++ b/admin/app/controllers/creator_packs_controller.ts @@ -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' }) diff --git a/admin/app/controllers/settings_controller.ts b/admin/app/controllers/settings_controller.ts index 4675d4b..7106972 100644 --- a/admin/app/controllers/settings_controller.ts +++ b/admin/app/controllers/settings_controller.ts @@ -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() diff --git a/admin/app/services/creator_pack_service.ts b/admin/app/services/creator_pack_service.ts index 1ca73bf..8352b4a 100644 --- a/admin/app/services/creator_pack_service.ts +++ b/admin/app/services/creator_pack_service.ts @@ -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` diff --git a/admin/inertia/components/CreatorPackCard.tsx b/admin/inertia/components/CreatorPackCard.tsx new file mode 100644 index 0000000..7752169 --- /dev/null +++ b/admin/inertia/components/CreatorPackCard.tsx @@ -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 = ({ 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 ( +
{ + if (!clickable) return + onClick?.(pack) + }} + > +
+
+
+ {pack.logo_url ? ( + + ) : ( + + )} +

{pack.name}

+
+ {selected ? ( +
+ + Selected +
+ ) : isDownloading ? ( +
+ + Downloading +
+ ) : isInstalled ? ( +
+ + Installed +
+ ) : ( + + )} +
+
+ +

by {pack.creator}

+ +

{pack.description}

+ + {hasUpdate && ( + + Update available + + )} + +
+

+ {pack.video_count} videos | {formatBytes(sizeBytes, 0)} +

+
+
+ ) +} + +export default CreatorPackCard diff --git a/admin/inertia/components/CreatorPacksSection.tsx b/admin/inertia/components/CreatorPacksSection.tsx new file mode 100644 index 0000000..9b22183 --- /dev/null +++ b/admin/inertia/components/CreatorPacksSection.tsx @@ -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(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 ( + <> +
+
+ +
+
+

Creator Packs

+

+ Branded video collections from creators, for offline viewing +

+
+
+ + {packs.length > 0 ? ( +
+ {packs.map((pack) => ( + + ))} +
+ ) : ( +

No creator packs available.

+ )} + + !installing && setPackToInstall(null)} + onCancel={() => setPackToInstall(null)} + onConfirm={handleConfirm} + confirmText={ + packToInstall?.available_update_version ? 'Update pack' : 'Install pack' + } + confirmIcon="IconDownload" + confirmLoading={installing} + icon={} + > + {packToInstall && ( +
+

+ {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. +

+

+ Licensed content — personal use, not for redistribution. +

+
+ )} +
+ + ) +} + +export default CreatorPacksSection diff --git a/admin/inertia/hooks/useCreatorPacks.ts b/admin/inertia/hooks/useCreatorPacks.ts new file mode 100644 index 0000000..e75d08c --- /dev/null +++ b/admin/inertia/hooks/useCreatorPacks.ts @@ -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 diff --git a/admin/inertia/layouts/SettingsLayout.tsx b/admin/inertia/layouts/SettingsLayout.tsx index 6dc868d..c367003 100644 --- a/admin/inertia/layouts/SettingsLayout.tsx +++ b/admin/inertia/layouts/SettingsLayout.tsx @@ -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', diff --git a/admin/inertia/lib/api.ts b/admin/inertia/lib/api.ts index 9782e9e..a068b53 100644 --- a/admin/inertia/lib/api.ts +++ b/admin/inertia/lib/api.ts @@ -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>('/docs/list') diff --git a/admin/inertia/pages/easy-setup/index.tsx b/admin/inertia/pages/easy-setup/index.tsx index da97cf8..6f75ea8 100644 --- a/admin/inertia/pages/easy-setup/index.tsx +++ b/admin/inertia/pages/easy-setup/index.tsx @@ -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 = { + 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(1) const [selectedServices, setSelectedServices] = useState([]) const [selectedMapCollections, setSelectedMapCollections] = useState([]) + const [selectedCreatorPacks, setSelectedCreatorPacks] = useState([]) const [selectedAiModels, setSelectedAiModels] = useState([]) // 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(() => { + 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 (