Merge 311171f963 into 6a4f02dd46
This commit is contained in:
commit
fc9b0d97c7
|
|
@ -52,3 +52,4 @@ jobs:
|
|||
VERSION=${{ inputs.version }}
|
||||
BUILD_DATE=${{ github.event.workflow_run.created_at }}
|
||||
VCS_REF=${{ github.sha }}
|
||||
CREATOR_PACKS_APP_KEY=${{ secrets.CREATOR_PACKS_APP_KEY }}
|
||||
|
|
|
|||
11
Dockerfile
11
Dockerfile
|
|
@ -73,6 +73,17 @@ LABEL org.opencontainers.image.title="Project N.O.M.A.D" \
|
|||
org.opencontainers.image.licenses="Apache-2.0"
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Creator Packs entitlement key, injected into OFFICIAL release builds at build
|
||||
# time (--build-arg CREATOR_PACKS_APP_KEY=... from the CREATOR_PACKS_APP_KEY CI
|
||||
# secret; see build-primary-image.yml). Baked as an ENV so admin/start/env.ts
|
||||
# reads it at runtime. Empty by default, so builds from source (and any build
|
||||
# without the secret) ship UNCONFIGURED and hide the Creator Packs UI. The key
|
||||
# lands in this public image layer (extractable — the accepted ceiling); rotate
|
||||
# via `wrangler secret put APP_KEY` + a new image if it leaks.
|
||||
ARG CREATOR_PACKS_APP_KEY=""
|
||||
ENV CREATOR_PACKS_APP_KEY=$CREATOR_PACKS_APP_KEY
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=production-deps /app/node_modules /app/node_modules
|
||||
COPY --from=build /app/build /app
|
||||
|
|
|
|||
|
|
@ -22,4 +22,9 @@ REDIS_PORT=6379
|
|||
# Storage path for NOMAD content (ZIM files, maps, etc.)
|
||||
# On Windows dev, use an absolute path like: C:/nomad-storage
|
||||
# On Linux production, use: /opt/project-nomad/storage
|
||||
NOMAD_STORAGE_PATH=/opt/project-nomad/storage
|
||||
NOMAD_STORAGE_PATH=/opt/project-nomad/storage
|
||||
# Creator Packs (gated content downloads). Official release builds inject
|
||||
# CREATOR_PACKS_APP_KEY at build time; leave unset for a build that can't install
|
||||
# packs. CREATOR_PACKS_WORKER_BASE overrides the entitlement Worker origin.
|
||||
# CREATOR_PACKS_APP_KEY=
|
||||
# CREATOR_PACKS_WORKER_BASE=https://nomad-packs-worker.chris-556.workers.dev
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
import { CreatorPackService } from '#services/creator_pack_service'
|
||||
import { DownloadService } from '#services/download_service'
|
||||
import { QueueService } from '#services/queue_service'
|
||||
import logger from '@adonisjs/core/services/logger'
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
|
||||
export default class CreatorPacksController {
|
||||
private creatorPackService = new CreatorPackService()
|
||||
|
||||
/**
|
||||
* Catalog with per-pack status, plus live progress for any in-flight pack
|
||||
* downloads (ZIM download jobs — filtered client-side by collection_ref once
|
||||
* the UI needs it; for now the raw zim job list is enough for progress).
|
||||
*/
|
||||
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 { 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' })
|
||||
}
|
||||
}
|
||||
|
||||
async install({ params, response }: HttpContext) {
|
||||
const packId = params.id as string
|
||||
const result = await this.creatorPackService.installPack(packId)
|
||||
|
||||
switch (result.code) {
|
||||
case 'dispatched':
|
||||
return response.status(202).send({
|
||||
message: 'Pack download started',
|
||||
filename: result.filename,
|
||||
})
|
||||
case 'already_installed':
|
||||
return { message: 'Pack is already installed' }
|
||||
case 'already_downloading':
|
||||
return { message: 'Pack download is already in progress' }
|
||||
case 'not_found':
|
||||
return response.status(404).send({ message: `Creator pack not found: ${packId}` })
|
||||
case 'not_configured':
|
||||
return response.status(503).send({
|
||||
message: 'Creator Packs are not configured on this server',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async uninstall({ params, response }: HttpContext) {
|
||||
const packId = params.id as string
|
||||
const result = await this.creatorPackService.uninstallPack(packId)
|
||||
|
||||
switch (result.code) {
|
||||
case 'uninstalled':
|
||||
return { message: 'Pack uninstalled', filename: result.filename }
|
||||
case 'not_installed':
|
||||
return response.status(404).send({ message: `Creator pack is not installed: ${packId}` })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ export class RunDownloadJob {
|
|||
}
|
||||
|
||||
async handle(job: Job) {
|
||||
const { url, filepath, timeout, allowedMimeTypes, forceNew, filetype, resourceMetadata } =
|
||||
const { url, filepath, timeout, allowedMimeTypes, forceNew, filetype, resourceMetadata, requestHeaders } =
|
||||
job.data as RunDownloadJobParams
|
||||
|
||||
// Register abort controller for this job
|
||||
|
|
@ -110,6 +110,7 @@ export class RunDownloadJob {
|
|||
timeout,
|
||||
allowedMimeTypes,
|
||||
forceNew,
|
||||
requestHeaders,
|
||||
signal: abortController.signal,
|
||||
onProgress(progress) {
|
||||
const progressPercent = (progress.downloadedBytes / (progress.totalBytes || 1)) * 100
|
||||
|
|
@ -208,9 +209,11 @@ export class RunDownloadJob {
|
|||
const zimService = new ZimService(dockerService)
|
||||
await zimService.downloadRemoteSuccessCallback([url], true)
|
||||
|
||||
// Only touch the knowledge base if AI Assistant (Ollama) is installed
|
||||
// Only touch the knowledge base if AI Assistant (Ollama) is installed.
|
||||
// skip_embedding opts a ZIM out entirely — Creator Pack video ZIMs are
|
||||
// media galleries, not text, so they must never be embedded or KB-reconciled.
|
||||
const ollamaUrl = await dockerService.getServiceURL('nomad_ollama')
|
||||
if (ollamaUrl) {
|
||||
if (ollamaUrl && !resourceMetadata?.skip_embedding) {
|
||||
// A content UPDATE replaces a prior file at a DIFFERENT path
|
||||
// (version is in the filename). A fresh install has no prior row;
|
||||
// a same-version re-download keeps the same path. The two cases
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import InstalledResource from '#models/installed_resource'
|
|||
import WikipediaSelection from '#models/wikipedia_selection'
|
||||
import { QueueService } from './queue_service.js'
|
||||
import { RunDownloadJob } from '#jobs/run_download_job'
|
||||
import { zimCategoriesSpecSchema, mapsSpecSchema, wikipediaSpecSchema } from '#validators/curated_collections'
|
||||
import { zimCategoriesSpecSchema, mapsSpecSchema, wikipediaSpecSchema, creatorPacksSpecSchema } from '#validators/curated_collections'
|
||||
import {
|
||||
ensureDirectoryExists,
|
||||
listDirectoryContents,
|
||||
|
|
@ -19,8 +19,10 @@ import type {
|
|||
ManifestType,
|
||||
ZimCategoriesSpec,
|
||||
MapsSpec,
|
||||
CreatorPacksSpec,
|
||||
CategoryWithStatus,
|
||||
CollectionWithStatus,
|
||||
CreatorPackWithStatus,
|
||||
SpecResource,
|
||||
SpecTier,
|
||||
} from '../../types/collections.js'
|
||||
|
|
@ -29,12 +31,14 @@ const SPEC_URLS: Record<ManifestType, string> = {
|
|||
zim_categories: 'https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/collections/kiwix-categories.json',
|
||||
maps: 'https://github.com/Crosstalk-Solutions/project-nomad/raw/refs/heads/main/collections/maps.json',
|
||||
wikipedia: 'https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/collections/wikipedia.json',
|
||||
creator_packs: 'https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/collections/creator-packs.json',
|
||||
}
|
||||
|
||||
const VALIDATORS: Record<ManifestType, any> = {
|
||||
zim_categories: zimCategoriesSpecSchema,
|
||||
maps: mapsSpecSchema,
|
||||
wikipedia: wikipediaSpecSchema,
|
||||
creator_packs: creatorPacksSpecSchema,
|
||||
}
|
||||
|
||||
export class CollectionManifestService {
|
||||
|
|
@ -189,6 +193,39 @@ export class CollectionManifestService {
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-pack install status for Creator Packs. Packs are single ZIMs, so this is
|
||||
* a one-resource-per-pack join (simpler than the tiered category logic):
|
||||
* catalog ⋈ InstalledResource (resource_type 'zim', matched on resource_id) ⋈
|
||||
* the in-flight download queue. `available_update_version` is set when an
|
||||
* installed pack's version trails the catalog (a creator published a rebuild).
|
||||
*/
|
||||
async getCreatorPacksWithStatus(): Promise<CreatorPackWithStatus[]> {
|
||||
const spec = await this.getSpecWithFallback<CreatorPacksSpec>('creator_packs')
|
||||
if (!spec) return []
|
||||
|
||||
const installedResources = await InstalledResource.query().where('resource_type', 'zim')
|
||||
const installedMap = new Map(installedResources.map((r) => [r.resource_id, r]))
|
||||
const inFlightIds = await this.getInFlightZimResourceIds()
|
||||
|
||||
return spec.packs.map((pack) => {
|
||||
const installed = installedMap.get(pack.resource_id)
|
||||
if (installed) {
|
||||
const hasUpdate = installed.version !== pack.version
|
||||
return {
|
||||
...pack,
|
||||
status: 'installed' as const,
|
||||
installed_version: installed.version,
|
||||
...(hasUpdate ? { available_update_version: pack.version } : {}),
|
||||
}
|
||||
}
|
||||
if (inFlightIds.has(pack.resource_id)) {
|
||||
return { ...pack, status: 'downloading' as const }
|
||||
}
|
||||
return { ...pack, status: 'available' as const }
|
||||
})
|
||||
}
|
||||
|
||||
// ---- Tier resolution ----
|
||||
|
||||
static resolveTierResources(tier: SpecTier, allTiers: SpecTier[]): SpecResource[] {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,178 @@
|
|||
import env from '#start/env'
|
||||
import logger from '@adonisjs/core/services/logger'
|
||||
import { join } from 'node:path'
|
||||
import { DockerService } from '#services/docker_service'
|
||||
import { ZimService } from '#services/zim_service'
|
||||
import { CollectionManifestService } from '#services/collection_manifest_service'
|
||||
import { RunDownloadJob } from '#jobs/run_download_job'
|
||||
import { ZIM_STORAGE_PATH } from '../utils/fs.js'
|
||||
import { SERVICE_NAMES } from '../../constants/service_names.js'
|
||||
import type { CreatorPacksSpec, CreatorPackWithStatus } from '../../types/collections.js'
|
||||
|
||||
// ZIMs report as octet-stream from the Worker (Content-Type is set explicitly to
|
||||
// application/octet-stream in worker/src/index.js). Mirror the ZimService allowlist.
|
||||
const ZIM_MIME_TYPES = ['application/x-zim', 'application/x-openzim', 'application/octet-stream']
|
||||
|
||||
// Default entitlement Worker origin. Overridable via CREATOR_PACKS_WORKER_BASE so
|
||||
// we can move to a branded packs.projectnomad.us domain without a code change.
|
||||
const DEFAULT_WORKER_BASE = 'https://nomad-packs-worker.chris-556.workers.dev'
|
||||
|
||||
/**
|
||||
* Outcome of an install request. Discriminated on `code` so the controller can
|
||||
* map each case to an HTTP status without string-matching thrown messages.
|
||||
* (See feedback: typed failure codes over ad-hoc Error parsing.)
|
||||
*/
|
||||
export type InstallPackResult =
|
||||
| { code: 'dispatched'; filename: string }
|
||||
| { code: 'already_installed' }
|
||||
| { code: 'already_downloading' }
|
||||
| { code: 'not_found' }
|
||||
| { code: 'not_configured' }
|
||||
|
||||
export type UninstallPackResult =
|
||||
| { code: 'uninstalled'; filename: string }
|
||||
| { code: 'not_installed' }
|
||||
|
||||
/**
|
||||
* Creator Packs install rail. Diverges from the curated-collections rail in
|
||||
* exactly one way: instead of a static manifest URL, the ZIM is fetched from the
|
||||
* entitlement Worker with a bearer `Authorization` header. From RunDownloadJob
|
||||
* onward everything is the shared rail (resumable download → InstalledResource →
|
||||
* Kiwix rebuildFromDisk). Video ZIMs opt out of KB embedding via skip_embedding.
|
||||
*/
|
||||
export class CreatorPackService {
|
||||
private dockerService: DockerService
|
||||
private manifestService: CollectionManifestService
|
||||
|
||||
constructor(dockerService?: DockerService, manifestService?: CollectionManifestService) {
|
||||
this.dockerService = dockerService ?? new DockerService()
|
||||
this.manifestService = manifestService ?? new CollectionManifestService()
|
||||
}
|
||||
|
||||
private get workerBase(): string {
|
||||
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`
|
||||
}
|
||||
|
||||
async listPacksWithStatus(): Promise<CreatorPackWithStatus[]> {
|
||||
return this.manifestService.getCreatorPacksWithStatus()
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a pack by catalog id: ensure Kiwix is present (auto-install if not),
|
||||
* then dispatch the gated ZIM download onto the shared rail.
|
||||
*/
|
||||
async installPack(packId: string): Promise<InstallPackResult> {
|
||||
const appKey = env.get('CREATOR_PACKS_APP_KEY')
|
||||
if (!appKey) {
|
||||
logger.error('[CreatorPackService] CREATOR_PACKS_APP_KEY is not set; cannot install packs')
|
||||
return { code: 'not_configured' }
|
||||
}
|
||||
|
||||
const spec = await this.manifestService.getSpecWithFallback<CreatorPacksSpec>('creator_packs')
|
||||
const pack = spec?.packs.find((p) => p.id === packId)
|
||||
if (!pack) {
|
||||
return { code: 'not_found' }
|
||||
}
|
||||
|
||||
const { default: InstalledResource } = await import('#models/installed_resource')
|
||||
const existing = await InstalledResource.query()
|
||||
.where('resource_type', 'zim')
|
||||
.where('resource_id', pack.resource_id)
|
||||
.first()
|
||||
if (existing && existing.version === pack.version) {
|
||||
return { code: 'already_installed' }
|
||||
}
|
||||
|
||||
const url = this.packUrl(pack.id, pack.version)
|
||||
if (await RunDownloadJob.getActiveByUrl(url)) {
|
||||
return { code: 'already_downloading' }
|
||||
}
|
||||
|
||||
// Kiwix is a hard prerequisite — it's what serves the pack. Auto-install it if
|
||||
// absent (its own preinstall bootstraps a mini-wiki ZIM so the container can
|
||||
// start). Best-effort: if the install can't be kicked off we still download the
|
||||
// pack so the bytes are on disk; Kiwix hot-reloads it once installed.
|
||||
await this.ensureKiwixInstalled()
|
||||
|
||||
const filename = `${pack.id}_${pack.version}.zim`
|
||||
const filepath = join(process.cwd(), ZIM_STORAGE_PATH, filename)
|
||||
|
||||
await RunDownloadJob.dispatch({
|
||||
url,
|
||||
filepath,
|
||||
timeout: 30000,
|
||||
allowedMimeTypes: ZIM_MIME_TYPES,
|
||||
forceNew: true,
|
||||
filetype: 'zim',
|
||||
title: pack.name,
|
||||
totalBytes: pack.size_mb ? pack.size_mb * 1024 * 1024 : undefined,
|
||||
requestHeaders: { Authorization: `Bearer ${appKey}` },
|
||||
resourceMetadata: {
|
||||
resource_id: pack.resource_id,
|
||||
version: pack.version,
|
||||
collection_ref: 'creator-packs',
|
||||
skip_embedding: true,
|
||||
},
|
||||
})
|
||||
|
||||
logger.info(`[CreatorPackService] Dispatched download for pack ${pack.id} (${filename})`)
|
||||
return { code: 'dispatched', filename }
|
||||
}
|
||||
|
||||
/**
|
||||
* Uninstall an installed pack: delete the ZIM (which via ZimService.delete also
|
||||
* removes it from the Kiwix library and clears its InstalledResource row).
|
||||
* Uses the INSTALLED version (not the catalog's) so we remove the file that's
|
||||
* actually on disk even if a newer version has since been published.
|
||||
*/
|
||||
async uninstallPack(packId: string): Promise<UninstallPackResult> {
|
||||
const { default: InstalledResource } = await import('#models/installed_resource')
|
||||
const installed = await InstalledResource.query()
|
||||
.where('resource_type', 'zim')
|
||||
.where('resource_id', packId)
|
||||
.first()
|
||||
if (!installed) {
|
||||
return { code: 'not_installed' }
|
||||
}
|
||||
|
||||
const filename = `${packId}_${installed.version}.zim`
|
||||
const zimService = new ZimService(this.dockerService)
|
||||
await zimService.delete(filename)
|
||||
|
||||
logger.info(`[CreatorPackService] Uninstalled pack ${packId} (${filename})`)
|
||||
return { code: 'uninstalled', filename }
|
||||
}
|
||||
|
||||
private async ensureKiwixInstalled(): Promise<void> {
|
||||
try {
|
||||
const kiwixUrl = await this.dockerService.getServiceURL(SERVICE_NAMES.KIWIX)
|
||||
if (kiwixUrl) return // already installed and resolvable
|
||||
|
||||
logger.info('[CreatorPackService] Kiwix not installed; auto-installing before pack download')
|
||||
const result = await this.dockerService.createContainerPreflight(SERVICE_NAMES.KIWIX)
|
||||
if (!result.success) {
|
||||
// e.g. "already installing" — not fatal, the pack download proceeds regardless.
|
||||
logger.warn(`[CreatorPackService] Kiwix preflight did not start: ${result.message}`)
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.warn(
|
||||
`[CreatorPackService] Kiwix auto-install failed (continuing with pack download): ${error?.message || error}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -24,6 +24,7 @@ export async function doResumableDownload({
|
|||
onComplete,
|
||||
forceNew = false,
|
||||
allowedMimeTypes,
|
||||
requestHeaders,
|
||||
}: DoResumableDownloadParams): Promise<string> {
|
||||
const dirname = path.dirname(filepath)
|
||||
await ensureDirectoryExists(dirname)
|
||||
|
|
@ -41,10 +42,12 @@ export async function doResumableDownload({
|
|||
appendMode = true
|
||||
}
|
||||
|
||||
// Get file info with HEAD request first
|
||||
// Get file info with HEAD request first. Gated sources (Creator Packs) require
|
||||
// the auth header on the HEAD too, or the probe 401s before the GET is reached.
|
||||
const headResponse = await axios.head(url, {
|
||||
signal,
|
||||
timeout,
|
||||
headers: requestHeaders,
|
||||
})
|
||||
|
||||
// Some upstream hosts (notably download.kiwix.org for .zim files) don't set a
|
||||
|
|
@ -88,7 +91,10 @@ export async function doResumableDownload({
|
|||
appendMode = false
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {}
|
||||
// Seed with any caller-supplied headers (e.g. Creator Packs' Authorization
|
||||
// bearer) so every GET attempt below — including the range-restart retry —
|
||||
// carries them. The Range header composes on top.
|
||||
const headers: Record<string, string> = { ...requestHeaders }
|
||||
if (supportsRangeRequests && startByte > 0) {
|
||||
headers.Range = `bytes=${startByte}-`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,6 +68,31 @@ export const wikipediaSpecSchema = vine.object({
|
|||
).minLength(1),
|
||||
})
|
||||
|
||||
// ---- Creator Packs spec (versioned) ----
|
||||
//
|
||||
// Display metadata only — deliberately NO `url` field. The ZIM bytes are resolved
|
||||
// through the entitlement Worker at install time (CreatorPackService), never from
|
||||
// this public catalog. See project_content_creator_packs.
|
||||
export const creatorPacksSpecSchema = vine.object({
|
||||
spec_version: vine.string(),
|
||||
packs: vine.array(
|
||||
vine.object({
|
||||
id: vine.string(),
|
||||
name: vine.string(),
|
||||
creator: vine.string(),
|
||||
description: vine.string(),
|
||||
version: vine.string(),
|
||||
resource_id: vine.string(),
|
||||
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(),
|
||||
})
|
||||
).minLength(1),
|
||||
})
|
||||
|
||||
// ---- Wikipedia validators (used by ZimService) ----
|
||||
|
||||
export const wikipediaOptionSchema = vine.object({
|
||||
|
|
|
|||
|
|
@ -0,0 +1,119 @@
|
|||
import { useState } from 'react'
|
||||
import { formatBytes } from '~/lib/util'
|
||||
import type { CreatorPackWithStatus } from '../../types/collections'
|
||||
import classNames from 'classnames'
|
||||
import {
|
||||
IconChevronRight,
|
||||
IconCircleCheck,
|
||||
IconLoader2,
|
||||
IconMovie,
|
||||
IconTrash,
|
||||
} from '@tabler/icons-react'
|
||||
|
||||
export interface CreatorPackCardProps {
|
||||
pack: CreatorPackWithStatus
|
||||
/** In-session wizard selection highlight (before anything is installed). */
|
||||
selected?: boolean
|
||||
onClick?: (pack: CreatorPackWithStatus) => void
|
||||
/** When set, an installed pack shows an uninstall control (settings surface only). */
|
||||
onUninstall?: (pack: CreatorPackWithStatus) => void
|
||||
}
|
||||
|
||||
const CreatorPackCard: React.FC<CreatorPackCardProps> = ({ pack, selected, onClick, onUninstall }) => {
|
||||
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).
|
||||
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 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)
|
||||
}}
|
||||
>
|
||||
{!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 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 · {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>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{statusBadge}
|
||||
{onUninstall && isInstalled && (
|
||||
<button
|
||||
type="button"
|
||||
title="Uninstall pack"
|
||||
aria-label="Uninstall pack"
|
||||
className="p-1 rounded text-text-muted hover:text-red-500 hover:bg-red-500/10 transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onUninstall(pack)
|
||||
}}
|
||||
>
|
||||
<IconTrash className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CreatorPackCard
|
||||
|
|
@ -0,0 +1,163 @@
|
|||
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'
|
||||
|
||||
// Canonical Creator Pack License (one license across the seed packs). Opened in a
|
||||
// new tab from the install modal; install is an online action so an external link
|
||||
// is fine. A per-pack catalog `license_url` can supersede this later if needed.
|
||||
const LICENSE_URL =
|
||||
'https://github.com/Crosstalk-Solutions/project-nomad/blob/main/collections/creator-pack-license.md'
|
||||
|
||||
export interface CreatorPacksSectionProps {
|
||||
/** Show uninstall controls on installed packs (the settings "manage" surface). */
|
||||
allowUninstall?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Install-on-click grid of Creator Packs + confirm modals. 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<CreatorPacksSectionProps> = ({ allowUninstall }) => {
|
||||
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)
|
||||
const [packToUninstall, setPackToUninstall] = useState<CreatorPackWithStatus | null>(null)
|
||||
const [uninstalling, setUninstalling] = useState(false)
|
||||
|
||||
if (!configured) return null
|
||||
|
||||
const handleConfirmInstall = 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)
|
||||
}
|
||||
}
|
||||
|
||||
const handleConfirmUninstall = async () => {
|
||||
if (!packToUninstall) return
|
||||
setUninstalling(true)
|
||||
try {
|
||||
await api.uninstallCreatorPack(packToUninstall.id)
|
||||
addNotification({ message: `Uninstalled "${packToUninstall.name}"`, type: 'success' })
|
||||
invalidateCreatorPacks()
|
||||
invalidateDownloads()
|
||||
setPackToUninstall(null)
|
||||
} catch (error) {
|
||||
console.error('Error uninstalling creator pack:', error)
|
||||
addNotification({ message: 'An error occurred while uninstalling.', type: 'error' })
|
||||
} finally {
|
||||
setUninstalling(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 lg:grid-cols-2 gap-6">
|
||||
{packs.map((pack) => (
|
||||
<CreatorPackCard
|
||||
key={pack.id}
|
||||
pack={pack}
|
||||
onClick={setPackToInstall}
|
||||
onUninstall={allowUninstall ? setPackToUninstall : undefined}
|
||||
/>
|
||||
))}
|
||||
</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={handleConfirmInstall}
|
||||
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.{' '}
|
||||
<a
|
||||
href={LICENSE_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-desert-green underline hover:no-underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
View license
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</StyledModal>
|
||||
|
||||
<StyledModal
|
||||
open={!!packToUninstall}
|
||||
title={packToUninstall ? `Uninstall ${packToUninstall.name}?` : 'Uninstall Creator Pack'}
|
||||
onClose={() => !uninstalling && setPackToUninstall(null)}
|
||||
onCancel={() => setPackToUninstall(null)}
|
||||
onConfirm={handleConfirmUninstall}
|
||||
confirmText="Uninstall pack"
|
||||
confirmIcon="IconTrash"
|
||||
confirmVariant="danger"
|
||||
confirmLoading={uninstalling}
|
||||
icon={<IconMovie className="w-6 h-6" />}
|
||||
>
|
||||
{packToUninstall && (
|
||||
<p className="text-text-secondary">
|
||||
This removes the downloaded videos (
|
||||
{formatBytes(packToUninstall.size_mb * 1024 * 1024, 0)}) from this NOMAD. You can
|
||||
reinstall the pack anytime.
|
||||
</p>
|
||||
)}
|
||||
</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,35 @@ 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 uninstallCreatorPack(id: string) {
|
||||
return catchInternal(async () => {
|
||||
const response = await this.client.delete<{ message: string; filename?: string }>(
|
||||
`/creator-packs/${id}`
|
||||
)
|
||||
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 lg:grid-cols-2 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 allowUninstall />
|
||||
<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">
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
|
|
@ -62,4 +62,18 @@ export default await Env.create(new URL('../', import.meta.url), {
|
|||
|----------------------------------------------------------
|
||||
*/
|
||||
NOMAD_API_URL: Env.schema.string.optional(),
|
||||
|
||||
/*
|
||||
|----------------------------------------------------------
|
||||
| Creator Packs (gated content downloads)
|
||||
|----------------------------------------------------------
|
||||
| CREATOR_PACKS_APP_KEY is the shared bearer key the entitlement Worker
|
||||
| requires; official release builds inject it at build time (it is NOT
|
||||
| committed to source). When unset, pack installs fail with a clear
|
||||
| "not configured" error and the rest of the app is unaffected.
|
||||
| CREATOR_PACKS_WORKER_BASE overrides the default Worker origin (e.g. to
|
||||
| point at a branded packs.projectnomad.us domain later).
|
||||
*/
|
||||
CREATOR_PACKS_APP_KEY: Env.schema.string.optional(),
|
||||
CREATOR_PACKS_WORKER_BASE: Env.schema.string.optional(),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import SettingsController from '#controllers/settings_controller'
|
|||
import SupplyDepotController from '#controllers/supply_depot_controller'
|
||||
import SystemController from '#controllers/system_controller'
|
||||
import CollectionUpdatesController from '#controllers/collection_updates_controller'
|
||||
import CreatorPacksController from '#controllers/creator_packs_controller'
|
||||
import ZimController from '#controllers/zim_controller'
|
||||
import router from '@adonisjs/core/services/router'
|
||||
import transmit from '@adonisjs/transmit/services/main'
|
||||
|
|
@ -55,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'])
|
||||
|
|
@ -225,6 +227,14 @@ router
|
|||
})
|
||||
.prefix('/api/zim')
|
||||
|
||||
router
|
||||
.group(() => {
|
||||
router.get('/', [CreatorPacksController, 'index'])
|
||||
router.post('/:id/install', [CreatorPacksController, 'install'])
|
||||
router.delete('/:id', [CreatorPacksController, 'uninstall'])
|
||||
})
|
||||
.prefix('/api/creator-packs')
|
||||
|
||||
router
|
||||
.group(() => {
|
||||
router.post('/run', [BenchmarkController, 'run'])
|
||||
|
|
|
|||
|
|
@ -58,7 +58,51 @@ export type WikipediaSpec = {
|
|||
options: WikipediaOption[]
|
||||
}
|
||||
|
||||
export type ManifestType = 'zim_categories' | 'maps' | 'wikipedia'
|
||||
// ---- Creator Packs spec ----
|
||||
//
|
||||
// A creator pack is a branded ZIM of a creator's curated YouTube videos, served
|
||||
// offline through Kiwix. The public catalog carries display metadata ONLY — there
|
||||
// is NO download url here. The bytes live in a private R2 bucket behind an
|
||||
// entitlement Worker; CreatorPackService resolves the (stable) Worker URL and
|
||||
// sends the auth header at install time. See project_content_creator_packs.
|
||||
export type CreatorPack = {
|
||||
id: string
|
||||
name: string
|
||||
creator: string
|
||||
description: string
|
||||
/** YYYY-MM; combines with id as `<id>_<version>.zim` (parseZimFilename). */
|
||||
version: string
|
||||
/** InstalledResource.resource_id used for install/status reconciliation. */
|
||||
resource_id: string
|
||||
video_count: number
|
||||
size_mb: number
|
||||
license_id: string
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
export type CreatorPacksSpec = {
|
||||
spec_version: string
|
||||
packs: CreatorPack[]
|
||||
}
|
||||
|
||||
export type CreatorPackStatus = 'installed' | 'downloading' | 'available'
|
||||
|
||||
export type CreatorPackWithStatus = CreatorPack & {
|
||||
status: CreatorPackStatus
|
||||
installed_version?: string
|
||||
/** Set when an installed pack has a newer catalog version available. */
|
||||
available_update_version?: string
|
||||
}
|
||||
|
||||
export type ManifestType = 'zim_categories' | 'maps' | 'wikipedia' | 'creator_packs'
|
||||
|
||||
export type ResourceStatus = 'installed' | 'not_installed' | 'update_available'
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,13 @@ export type DoResumableDownloadParams = {
|
|||
onProgress?: (progress: DoResumableDownloadProgress) => void
|
||||
onComplete?: (url: string, path: string) => void | Promise<void>
|
||||
forceNew?: boolean
|
||||
/**
|
||||
* Extra HTTP request headers sent on BOTH the HEAD probe and the GET stream.
|
||||
* Used by Creator Packs to carry the `Authorization: Bearer <APP_KEY>` the
|
||||
* entitlement Worker requires; the Range header still composes on top for
|
||||
* resumed downloads. Kept generic so any gated source can reuse it.
|
||||
*/
|
||||
requestHeaders?: Record<string, string>
|
||||
}
|
||||
|
||||
export type DoResumableDownloadWithRetryParams = DoResumableDownloadParams & {
|
||||
|
|
@ -48,6 +55,13 @@ export type RunDownloadJobParams = Omit<
|
|||
* `failed` handler) so manual downloads never touch the counter.
|
||||
*/
|
||||
auto?: boolean
|
||||
/**
|
||||
* Skip the RAG/knowledge-base ingestion branch in RunDownloadJob.onComplete.
|
||||
* Set for video ZIMs (Creator Packs) — they are media galleries, not text,
|
||||
* and must never be dispatched to EmbedFileJob or the replacement-reconcile
|
||||
* path. The Kiwix rebuild still runs so the pack appears in the grid.
|
||||
*/
|
||||
skip_embedding?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,88 @@
|
|||
# Project NOMAD Creator Pack License
|
||||
|
||||
**Version 1.0** · License ID: `nomad-creator-pack-1.0`
|
||||
|
||||
> **DRAFT — pending legal review.** This document is a starting point drafted for
|
||||
> convenience, not legal advice, and has not been reviewed by an attorney. The
|
||||
> terms below describe the intended arrangement; have counsel review and finalize
|
||||
> before relying on it. Nothing here is a binding offer until finalized.
|
||||
|
||||
---
|
||||
|
||||
## 1. What this covers
|
||||
|
||||
This license governs **Creator Pack content** — the curated video collections
|
||||
(and their titles, descriptions, thumbnails, and packaging) distributed through
|
||||
Project NOMAD as "Creator Packs." It does **not** cover the Project NOMAD
|
||||
software itself, which is licensed separately under Apache License 2.0. Where the
|
||||
two ever appear to conflict, the Apache 2.0 license governs the software and this
|
||||
license governs the pack content.
|
||||
|
||||
## 2. Ownership
|
||||
|
||||
Each Creator retains all copyright and other rights in their own videos and
|
||||
associated materials. Distribution through Project NOMAD does not transfer
|
||||
ownership. "Creator" means the individual or organization whose content a pack
|
||||
contains (for example, Crosstalk Solutions or Project NOMAD).
|
||||
|
||||
## 3. Grant to Project NOMAD
|
||||
|
||||
Each Creator grants Project NOMAD (Crosstalk Solutions, LLC) a non-exclusive,
|
||||
revocable right to reproduce, package, host, and distribute their Creator Pack
|
||||
**through the official Project NOMAD distribution channel only**. This grant does
|
||||
not permit Project NOMAD to sublicense the content for redistribution outside
|
||||
that official channel.
|
||||
|
||||
## 4. License to end users
|
||||
|
||||
Subject to these terms, an end user who installs a Creator Pack on their own
|
||||
Project NOMAD server is granted a **personal, non-commercial, non-transferable,
|
||||
non-exclusive** license to store and view that pack's content offline on their
|
||||
own device(s) for their own use and that of their household or immediate
|
||||
organization.
|
||||
|
||||
## 5. Restrictions
|
||||
|
||||
Except as expressly permitted above, you may **not**:
|
||||
|
||||
1. **Redistribute or re-host** any Creator Pack or its contents — including
|
||||
copying pack files to another server, mirror, CDN, bucket, torrent, or file
|
||||
share, or making them available for download by others.
|
||||
2. **Bundle or ship** Creator Pack content with any fork, derivative, or
|
||||
third-party distribution of Project NOMAD or any other product.
|
||||
3. **Serve** Creator Pack content from any distribution channel other than the
|
||||
official Project NOMAD channel, or circumvent the entitlement controls that
|
||||
gate access to it.
|
||||
4. **Sell, rent, sublicense, or commercially exploit** the content, or use it to
|
||||
train machine-learning models.
|
||||
5. **Remove or alter** creator branding, attribution, or license notices.
|
||||
|
||||
For clarity: the Apache 2.0 license on the Project NOMAD **software** permits
|
||||
forking the software, but it grants **no rights** to the Creator Pack **content**,
|
||||
which remains governed exclusively by this license. A fork may not distribute or
|
||||
serve Creator Packs.
|
||||
|
||||
## 6. Termination
|
||||
|
||||
This license terminates automatically if you breach it, and Project NOMAD or a
|
||||
Creator may revoke it at any time. On termination you must stop using and delete
|
||||
the affected Creator Pack content. Sections 2, 5, 7, and 8 survive termination.
|
||||
|
||||
## 7. No warranty
|
||||
|
||||
Creator Pack content is provided **"as is," without warranty of any kind**,
|
||||
express or implied, including merchantability, fitness for a particular purpose,
|
||||
and non-infringement.
|
||||
|
||||
## 8. Limitation of liability
|
||||
|
||||
To the maximum extent permitted by law, neither Project NOMAD, Crosstalk
|
||||
Solutions, LLC, nor any Creator is liable for any indirect, incidental, special,
|
||||
consequential, or punitive damages arising from the Creator Pack content or this
|
||||
license.
|
||||
|
||||
---
|
||||
|
||||
*Questions about this license or Creator Pack participation: contact Project NOMAD
|
||||
via https://www.projectnomad.us. Governing law and venue to be specified on legal
|
||||
review.*
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"spec_version": "1",
|
||||
"packs": [
|
||||
{
|
||||
"id": "project-nomad",
|
||||
"name": "Project NOMAD",
|
||||
"creator": "Project NOMAD",
|
||||
"description": "Official Project NOMAD videos, packaged for offline viewing on your NOMAD.",
|
||||
"version": "2026-07",
|
||||
"resource_id": "project-nomad",
|
||||
"video_count": 5,
|
||||
"size_mb": 178,
|
||||
"license_id": "nomad-creator-pack-1.0"
|
||||
},
|
||||
{
|
||||
"id": "crosstalk-solutions",
|
||||
"name": "Crosstalk Solutions",
|
||||
"creator": "Chris Sherwood",
|
||||
"description": "Chris Sherwood's networking, UniFi & home lab videos, for offline NOMAD.",
|
||||
"version": "2026-07",
|
||||
"resource_id": "crosstalk-solutions",
|
||||
"video_count": 30,
|
||||
"size_mb": 2899,
|
||||
"license_id": "nomad-creator-pack-1.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Reference in New Issue