From 5afc3a270aacdb68ab88cde85e812fa3b31a75cf Mon Sep 17 00:00:00 2001 From: Chris Sherwood Date: Fri, 23 Jan 2026 22:14:17 -0800 Subject: [PATCH] feat: Improve curated collections UX with persistent tier selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add installed_tiers table to persist user's tier selection per category - Change tier selection behavior: clicking a tier now highlights it locally, user must click "Submit" to confirm (previously clicked = immediate download) - Remove "Recommended" badge and asterisk (*) from tier displays - Highlight installed tier instead of recommended tier in CategoryCard - Add "Click to choose" hint when no tier is installed - Save installed tier when downloading from Content Explorer or Easy Setup - Pass installed tier to modal as default selection Database: - New migration: create installed_tiers table (category_slug unique, tier_slug) - New model: InstalledTier Backend: - ZimService.listCuratedCategories() now includes installedTierSlug - New ZimService.saveInstalledTier() method - New POST /api/zim/save-installed-tier endpoint Frontend: - TierSelectionModal: local selection state, "Close" → "Submit" button - CategoryCard: highlight based on installedTierSlug, add "Click to choose" - Content Explorer: save tier after download, refresh categories - Easy Setup: save tiers on wizard completion Co-Authored-By: Claude Opus 4.5 --- admin/app/controllers/zim_controller.ts | 7 +++ admin/app/models/installed_tier.ts | 21 +++++++ admin/app/services/zim_service.ts | 21 ++++++- admin/app/validators/common.ts | 7 +++ ...9400000001_create_installed_tiers_table.ts | 19 ++++++ admin/inertia/components/CategoryCard.tsx | 38 +++++++----- .../inertia/components/TierSelectionModal.tsx | 59 ++++++++++++++----- admin/inertia/lib/api.ts | 10 ++++ admin/inertia/pages/easy-setup/index.tsx | 10 +++- .../pages/settings/zim/remote-explorer.tsx | 13 ++-- admin/start/routes.ts | 1 + admin/types/downloads.ts | 1 + 12 files changed, 171 insertions(+), 36 deletions(-) create mode 100644 admin/app/models/installed_tier.ts create mode 100644 admin/database/migrations/1769400000001_create_installed_tiers_table.ts diff --git a/admin/app/controllers/zim_controller.ts b/admin/app/controllers/zim_controller.ts index 68a1c44..72de28f 100644 --- a/admin/app/controllers/zim_controller.ts +++ b/admin/app/controllers/zim_controller.ts @@ -3,6 +3,7 @@ import { downloadCollectionValidator, filenameParamValidator, remoteDownloadValidator, + saveInstalledTierValidator, } from '#validators/common' import { listRemoteZimValidator } from '#validators/zim' import { inject } from '@adonisjs/core' @@ -54,6 +55,12 @@ export default class ZimController { return { success } } + async saveInstalledTier({ request }: HttpContext) { + const payload = await request.validateUsing(saveInstalledTierValidator) + await this.zimService.saveInstalledTier(payload.categorySlug, payload.tierSlug) + return { success: true } + } + async delete({ request, response }: HttpContext) { const payload = await request.validateUsing(filenameParamValidator) diff --git a/admin/app/models/installed_tier.ts b/admin/app/models/installed_tier.ts new file mode 100644 index 0000000..b9c582e --- /dev/null +++ b/admin/app/models/installed_tier.ts @@ -0,0 +1,21 @@ +import { DateTime } from 'luxon' +import { BaseModel, column, SnakeCaseNamingStrategy } from '@adonisjs/lucid/orm' + +export default class InstalledTier extends BaseModel { + static namingStrategy = new SnakeCaseNamingStrategy() + + @column({ isPrimary: true }) + declare id: number + + @column() + declare category_slug: string + + @column() + declare tier_slug: string + + @column.dateTime({ autoCreate: true }) + declare created_at: DateTime + + @column.dateTime({ autoCreate: true, autoUpdate: true }) + declare updated_at: DateTime +} diff --git a/admin/app/services/zim_service.ts b/admin/app/services/zim_service.ts index 8cfb800..c020c66 100644 --- a/admin/app/services/zim_service.ts +++ b/admin/app/services/zim_service.ts @@ -22,6 +22,7 @@ import vine from '@vinejs/vine' import { curatedCategoriesFileSchema, curatedCollectionsFileSchema } from '#validators/curated_collections' import CuratedCollection from '#models/curated_collection' import CuratedCollectionResource from '#models/curated_collection_resource' +import InstalledTier from '#models/installed_tier' import { RunDownloadJob } from '#jobs/run_download_job' import { DownloadCollectionOperation, DownloadRemoteSuccessCallback } from '../../types/files.js' @@ -257,13 +258,31 @@ export class ZimService implements IZimService { data, }); - return validated.categories + // Look up installed tiers for all categories + const installedTiers = await InstalledTier.all() + const installedTierMap = new Map( + installedTiers.map((t) => [t.category_slug, t.tier_slug]) + ) + + // Add installedTierSlug to each category + return validated.categories.map((category) => ({ + ...category, + installedTierSlug: installedTierMap.get(category.slug), + })) } catch (error) { logger.error(`[ZimService] Failed to fetch curated categories:`, error) throw new Error('Failed to fetch curated categories or invalid format was received') } } + async saveInstalledTier(categorySlug: string, tierSlug: string): Promise { + await InstalledTier.updateOrCreate( + { category_slug: categorySlug }, + { tier_slug: tierSlug } + ) + logger.info(`[ZimService] Saved installed tier: ${categorySlug} -> ${tierSlug}`) + } + async listCuratedCollections(): Promise { const collections = await CuratedCollection.query().where('type', 'zim').preload('resources') return collections.map((collection) => ({ diff --git a/admin/app/validators/common.ts b/admin/app/validators/common.ts index 93b68ab..fb4a79d 100644 --- a/admin/app/validators/common.ts +++ b/admin/app/validators/common.ts @@ -36,3 +36,10 @@ export const downloadCollectionValidator = vine.compile( slug: vine.string(), }) ) + +export const saveInstalledTierValidator = vine.compile( + vine.object({ + categorySlug: vine.string().trim().minLength(1), + tierSlug: vine.string().trim().minLength(1), + }) +) diff --git a/admin/database/migrations/1769400000001_create_installed_tiers_table.ts b/admin/database/migrations/1769400000001_create_installed_tiers_table.ts new file mode 100644 index 0000000..269d07f --- /dev/null +++ b/admin/database/migrations/1769400000001_create_installed_tiers_table.ts @@ -0,0 +1,19 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class extends BaseSchema { + protected tableName = 'installed_tiers' + + async up() { + this.schema.createTable(this.tableName, (table) => { + table.increments('id').primary() + table.string('category_slug').notNullable().unique() + table.string('tier_slug').notNullable() + table.timestamp('created_at') + table.timestamp('updated_at') + }) + } + + async down() { + this.schema.dropTable(this.tableName) + } +} diff --git a/admin/inertia/components/CategoryCard.tsx b/admin/inertia/components/CategoryCard.tsx index 2f70c33..ebfb0fe 100644 --- a/admin/inertia/components/CategoryCard.tsx +++ b/admin/inertia/components/CategoryCard.tsx @@ -29,6 +29,9 @@ const CategoryCard: React.FC = ({ category, selectedTier, onC const minSize = getTierTotalSize(category.tiers[0], category.tiers) const maxSize = getTierTotalSize(category.tiers[category.tiers.length - 1], category.tiers) + // Determine which tier to highlight: selectedTier (wizard) > installedTierSlug (persisted) + const highlightedTierSlug = selectedTier?.slug || category.installedTierSlug + return (
= ({ category, selectedTier, onC

{category.tiers.length} tiers available + {!highlightedTierSlug && ( + - Click to choose + )}

- {category.tiers.map((tier) => ( - - {tier.name} - {tier.recommended && ' *'} - - ))} + {category.tiers.map((tier) => { + const isInstalled = tier.slug === highlightedTierSlug + return ( + + {tier.name} + + ) + })}

Size: {formatBytes(minSize, 1)} - {formatBytes(maxSize, 1)} diff --git a/admin/inertia/components/TierSelectionModal.tsx b/admin/inertia/components/TierSelectionModal.tsx index 9603627..a9f9e05 100644 --- a/admin/inertia/components/TierSelectionModal.tsx +++ b/admin/inertia/components/TierSelectionModal.tsx @@ -1,4 +1,4 @@ -import { Fragment } from 'react' +import { Fragment, useState, useEffect } from 'react' import { Dialog, Transition } from '@headlessui/react' import { IconX, IconCheck, IconInfoCircle } from '@tabler/icons-react' import { CuratedCategory, CategoryTier, CategoryResource } from '../../types/downloads' @@ -21,6 +21,16 @@ const TierSelectionModal: React.FC = ({ selectedTierSlug, onSelectTier, }) => { + // Local selection state - initialized from prop + const [localSelectedSlug, setLocalSelectedSlug] = useState(null) + + // Reset local selection when modal opens or category changes + useEffect(() => { + if (isOpen && category) { + setLocalSelectedSlug(selectedTierSlug || null) + } + }, [isOpen, category, selectedTierSlug]) + if (!category) return null // Get all resources for a tier (including inherited resources) @@ -41,6 +51,25 @@ const TierSelectionModal: React.FC = ({ return getAllResourcesForTier(tier).reduce((acc, r) => acc + r.size_mb * 1024 * 1024, 0) } + const handleTierClick = (tier: CategoryTier) => { + // Toggle selection: if clicking the same tier, deselect it + if (localSelectedSlug === tier.slug) { + setLocalSelectedSlug(null) + } else { + setLocalSelectedSlug(tier.slug) + } + } + + const handleSubmit = () => { + if (!localSelectedSlug) return + + const selectedTier = category.tiers.find(t => t.slug === localSelectedSlug) + if (selectedTier) { + onSelectTier(category, selectedTier) + } + onClose() + } + return (

@@ -99,21 +128,20 @@ const TierSelectionModal: React.FC = ({

- {category.tiers.map((tier, index) => { + {category.tiers.map((tier) => { const allResources = getAllResourcesForTier(tier) const totalSize = getTierTotalSize(tier) - const isSelected = selectedTierSlug === tier.slug + const isSelected = localSelectedSlug === tier.slug return (
onSelectTier(category, tier)} + onClick={() => handleTierClick(tier)} className={classNames( 'border-2 rounded-lg p-5 cursor-pointer transition-all', isSelected ? 'border-desert-green bg-desert-green/5 shadow-md' - : 'border-gray-200 hover:border-desert-green/50 hover:shadow-sm', - tier.recommended && !isSelected && 'border-lime-500/50' + : 'border-gray-200 hover:border-desert-green/50 hover:shadow-sm' )} >
@@ -122,11 +150,6 @@ const TierSelectionModal: React.FC = ({

{tier.name}

- {tier.recommended && ( - - Recommended - - )} {tier.includesTier && ( (includes {category.tiers.find(t => t.slug === tier.includesTier)?.name}) @@ -179,7 +202,7 @@ const TierSelectionModal: React.FC = ({

- You can change your selection at any time. Downloads will begin when you complete the setup wizard. + You can change your selection at any time. Click Submit to confirm your choice.

@@ -187,10 +210,16 @@ const TierSelectionModal: React.FC = ({ {/* Footer */}
diff --git a/admin/inertia/lib/api.ts b/admin/inertia/lib/api.ts index ff43a60..d90834b 100644 --- a/admin/inertia/lib/api.ts +++ b/admin/inertia/lib/api.ts @@ -212,6 +212,16 @@ class API { })() } + async saveInstalledTier(categorySlug: string, tierSlug: string) { + return catchInternal(async () => { + const response = await this.client.post<{ success: boolean }>('/zim/save-installed-tier', { + categorySlug, + tierSlug, + }) + 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 9c3bd97..c0d1f61 100644 --- a/admin/inertia/pages/easy-setup/index.tsx +++ b/admin/inertia/pages/easy-setup/index.tsx @@ -392,6 +392,12 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim await Promise.all(downloadPromises) + // Save installed tiers for each selected category + const tierSavePromises = Array.from(selectedTiers.entries()).map( + ([categorySlug, tier]) => api.saveInstalledTier(categorySlug, tier.slug) + ) + await Promise.all(tierSavePromises) + addNotification({ type: 'success', message: 'Setup wizard completed! Your selections are being processed.', @@ -945,7 +951,9 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim onClose={closeTierModal} category={activeCategory} selectedTierSlug={ - activeCategory ? selectedTiers.get(activeCategory.slug)?.slug : null + activeCategory + ? selectedTiers.get(activeCategory.slug)?.slug || activeCategory.installedTierSlug + : null } onSelectTier={handleTierSelect} /> diff --git a/admin/inertia/pages/settings/zim/remote-explorer.tsx b/admin/inertia/pages/settings/zim/remote-explorer.tsx index 04370ab..dc283fc 100644 --- a/admin/inertia/pages/settings/zim/remote-explorer.tsx +++ b/admin/inertia/pages/settings/zim/remote-explorer.tsx @@ -222,16 +222,23 @@ export default function ZimRemoteExplorer() { // Get all resources for this tier (including inherited ones) const resources = getAllResourcesForTier(tier, category.tiers) - // Download each resource + // Download each resource and save the installed tier try { for (const resource of resources) { await api.downloadRemoteZimFile(resource.url) } + + // Save the installed tier + await api.saveInstalledTier(category.slug, tier.slug) + addNotification({ message: `Started downloading ${resources.length} files from "${category.name} - ${tier.name}"`, type: 'success', }) invalidateDownloads() + + // Refresh categories to update the installed tier display + queryClient.invalidateQueries({ queryKey: [CURATED_CATEGORIES_KEY] }) } catch (error) { console.error('Error downloading tier resources:', error) addNotification({ @@ -239,8 +246,6 @@ export default function ZimRemoteExplorer() { type: 'error', }) } - - closeTierModal() } const closeTierModal = () => { @@ -322,7 +327,7 @@ export default function ZimRemoteExplorer() { isOpen={tierModalOpen} onClose={closeTierModal} category={activeCategory} - selectedTierSlug={null} + selectedTierSlug={activeCategory?.installedTierSlug} onSelectTier={handleTierSelect} /> diff --git a/admin/start/routes.ts b/admin/start/routes.ts index 1bb0d91..f3f4e43 100644 --- a/admin/start/routes.ts +++ b/admin/start/routes.ts @@ -120,6 +120,7 @@ router router.post('/fetch-latest-collections', [ZimController, 'fetchLatestCollections']) router.post('/download-remote', [ZimController, 'downloadRemote']) router.post('/download-collection', [ZimController, 'downloadCollection']) + router.post('/save-installed-tier', [ZimController, 'saveInstalledTier']) router.delete('/:filename', [ZimController, 'delete']) }) .prefix('/api/zim') diff --git a/admin/types/downloads.ts b/admin/types/downloads.ts index 298f1a1..beedd84 100644 --- a/admin/types/downloads.ts +++ b/admin/types/downloads.ts @@ -84,6 +84,7 @@ export type CuratedCategory = { description: string language: string tiers: CategoryTier[] + installedTierSlug?: string } export type CuratedCategoriesFile = {