feat: Improve curated collections UX with persistent tier selection

- 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 <noreply@anthropic.com>
This commit is contained in:
Chris Sherwood 2026-01-23 22:14:17 -08:00 committed by Jake Turner
parent 64e6e11389
commit 5afc3a270a
12 changed files with 171 additions and 36 deletions

View File

@ -3,6 +3,7 @@ import {
downloadCollectionValidator, downloadCollectionValidator,
filenameParamValidator, filenameParamValidator,
remoteDownloadValidator, remoteDownloadValidator,
saveInstalledTierValidator,
} from '#validators/common' } from '#validators/common'
import { listRemoteZimValidator } from '#validators/zim' import { listRemoteZimValidator } from '#validators/zim'
import { inject } from '@adonisjs/core' import { inject } from '@adonisjs/core'
@ -54,6 +55,12 @@ export default class ZimController {
return { success } 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) { async delete({ request, response }: HttpContext) {
const payload = await request.validateUsing(filenameParamValidator) const payload = await request.validateUsing(filenameParamValidator)

View File

@ -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
}

View File

@ -22,6 +22,7 @@ import vine from '@vinejs/vine'
import { curatedCategoriesFileSchema, curatedCollectionsFileSchema } from '#validators/curated_collections' import { curatedCategoriesFileSchema, curatedCollectionsFileSchema } from '#validators/curated_collections'
import CuratedCollection from '#models/curated_collection' import CuratedCollection from '#models/curated_collection'
import CuratedCollectionResource from '#models/curated_collection_resource' import CuratedCollectionResource from '#models/curated_collection_resource'
import InstalledTier from '#models/installed_tier'
import { RunDownloadJob } from '#jobs/run_download_job' import { RunDownloadJob } from '#jobs/run_download_job'
import { DownloadCollectionOperation, DownloadRemoteSuccessCallback } from '../../types/files.js' import { DownloadCollectionOperation, DownloadRemoteSuccessCallback } from '../../types/files.js'
@ -257,13 +258,31 @@ export class ZimService implements IZimService {
data, 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) { } catch (error) {
logger.error(`[ZimService] Failed to fetch curated categories:`, error) logger.error(`[ZimService] Failed to fetch curated categories:`, error)
throw new Error('Failed to fetch curated categories or invalid format was received') throw new Error('Failed to fetch curated categories or invalid format was received')
} }
} }
async saveInstalledTier(categorySlug: string, tierSlug: string): Promise<void> {
await InstalledTier.updateOrCreate(
{ category_slug: categorySlug },
{ tier_slug: tierSlug }
)
logger.info(`[ZimService] Saved installed tier: ${categorySlug} -> ${tierSlug}`)
}
async listCuratedCollections(): Promise<CuratedCollectionWithStatus[]> { async listCuratedCollections(): Promise<CuratedCollectionWithStatus[]> {
const collections = await CuratedCollection.query().where('type', 'zim').preload('resources') const collections = await CuratedCollection.query().where('type', 'zim').preload('resources')
return collections.map((collection) => ({ return collections.map((collection) => ({

View File

@ -36,3 +36,10 @@ export const downloadCollectionValidator = vine.compile(
slug: vine.string(), slug: vine.string(),
}) })
) )
export const saveInstalledTierValidator = vine.compile(
vine.object({
categorySlug: vine.string().trim().minLength(1),
tierSlug: vine.string().trim().minLength(1),
})
)

View File

@ -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)
}
}

View File

@ -29,6 +29,9 @@ const CategoryCard: React.FC<CategoryCardProps> = ({ category, selectedTier, onC
const minSize = getTierTotalSize(category.tiers[0], category.tiers) const minSize = getTierTotalSize(category.tiers[0], category.tiers)
const maxSize = getTierTotalSize(category.tiers[category.tiers.length - 1], 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 ( return (
<div <div
className={classNames( className={classNames(
@ -59,23 +62,28 @@ const CategoryCard: React.FC<CategoryCardProps> = ({ category, selectedTier, onC
<div className="mt-4 pt-4 border-t border-white/20"> <div className="mt-4 pt-4 border-t border-white/20">
<p className="text-sm text-gray-300 mb-2"> <p className="text-sm text-gray-300 mb-2">
{category.tiers.length} tiers available {category.tiers.length} tiers available
{!highlightedTierSlug && (
<span className="text-gray-400"> - Click to choose</span>
)}
</p> </p>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{category.tiers.map((tier) => ( {category.tiers.map((tier) => {
<span const isInstalled = tier.slug === highlightedTierSlug
key={tier.slug} return (
className={classNames( <span
'text-xs px-2 py-1 rounded', key={tier.slug}
tier.recommended className={classNames(
? 'bg-lime-500/30 text-lime-200' 'text-xs px-2 py-1 rounded',
: 'bg-white/10 text-gray-300', isInstalled
selectedTier?.slug === tier.slug && 'ring-2 ring-lime-400' ? 'bg-lime-500/30 text-lime-200'
)} : 'bg-white/10 text-gray-300',
> selectedTier?.slug === tier.slug && 'ring-2 ring-lime-400'
{tier.name} )}
{tier.recommended && ' *'} >
</span> {tier.name}
))} </span>
)
})}
</div> </div>
<p className="text-gray-300 text-xs mt-3"> <p className="text-gray-300 text-xs mt-3">
Size: {formatBytes(minSize, 1)} - {formatBytes(maxSize, 1)} Size: {formatBytes(minSize, 1)} - {formatBytes(maxSize, 1)}

View File

@ -1,4 +1,4 @@
import { Fragment } from 'react' import { Fragment, useState, useEffect } from 'react'
import { Dialog, Transition } from '@headlessui/react' import { Dialog, Transition } from '@headlessui/react'
import { IconX, IconCheck, IconInfoCircle } from '@tabler/icons-react' import { IconX, IconCheck, IconInfoCircle } from '@tabler/icons-react'
import { CuratedCategory, CategoryTier, CategoryResource } from '../../types/downloads' import { CuratedCategory, CategoryTier, CategoryResource } from '../../types/downloads'
@ -21,6 +21,16 @@ const TierSelectionModal: React.FC<TierSelectionModalProps> = ({
selectedTierSlug, selectedTierSlug,
onSelectTier, onSelectTier,
}) => { }) => {
// Local selection state - initialized from prop
const [localSelectedSlug, setLocalSelectedSlug] = useState<string | null>(null)
// Reset local selection when modal opens or category changes
useEffect(() => {
if (isOpen && category) {
setLocalSelectedSlug(selectedTierSlug || null)
}
}, [isOpen, category, selectedTierSlug])
if (!category) return null if (!category) return null
// Get all resources for a tier (including inherited resources) // Get all resources for a tier (including inherited resources)
@ -41,6 +51,25 @@ const TierSelectionModal: React.FC<TierSelectionModalProps> = ({
return getAllResourcesForTier(tier).reduce((acc, r) => acc + r.size_mb * 1024 * 1024, 0) 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 ( return (
<Transition appear show={isOpen} as={Fragment}> <Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-50" onClose={onClose}> <Dialog as="div" className="relative z-50" onClose={onClose}>
@ -99,21 +128,20 @@ const TierSelectionModal: React.FC<TierSelectionModalProps> = ({
</p> </p>
<div className="space-y-4"> <div className="space-y-4">
{category.tiers.map((tier, index) => { {category.tiers.map((tier) => {
const allResources = getAllResourcesForTier(tier) const allResources = getAllResourcesForTier(tier)
const totalSize = getTierTotalSize(tier) const totalSize = getTierTotalSize(tier)
const isSelected = selectedTierSlug === tier.slug const isSelected = localSelectedSlug === tier.slug
return ( return (
<div <div
key={tier.slug} key={tier.slug}
onClick={() => onSelectTier(category, tier)} onClick={() => handleTierClick(tier)}
className={classNames( className={classNames(
'border-2 rounded-lg p-5 cursor-pointer transition-all', 'border-2 rounded-lg p-5 cursor-pointer transition-all',
isSelected isSelected
? 'border-desert-green bg-desert-green/5 shadow-md' ? 'border-desert-green bg-desert-green/5 shadow-md'
: 'border-gray-200 hover:border-desert-green/50 hover:shadow-sm', : 'border-gray-200 hover:border-desert-green/50 hover:shadow-sm'
tier.recommended && !isSelected && 'border-lime-500/50'
)} )}
> >
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
@ -122,11 +150,6 @@ const TierSelectionModal: React.FC<TierSelectionModalProps> = ({
<h3 className="text-lg font-semibold text-gray-900"> <h3 className="text-lg font-semibold text-gray-900">
{tier.name} {tier.name}
</h3> </h3>
{tier.recommended && (
<span className="text-xs bg-lime-500 text-white px-2 py-0.5 rounded">
Recommended
</span>
)}
{tier.includesTier && ( {tier.includesTier && (
<span className="text-xs text-gray-500"> <span className="text-xs text-gray-500">
(includes {category.tiers.find(t => t.slug === tier.includesTier)?.name}) (includes {category.tiers.find(t => t.slug === tier.includesTier)?.name})
@ -179,7 +202,7 @@ const TierSelectionModal: React.FC<TierSelectionModalProps> = ({
<div className="mt-6 flex items-start gap-2 text-sm text-gray-500 bg-blue-50 p-3 rounded"> <div className="mt-6 flex items-start gap-2 text-sm text-gray-500 bg-blue-50 p-3 rounded">
<IconInfoCircle size={18} className="text-blue-500 flex-shrink-0 mt-0.5" /> <IconInfoCircle size={18} className="text-blue-500 flex-shrink-0 mt-0.5" />
<p> <p>
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.
</p> </p>
</div> </div>
</div> </div>
@ -187,10 +210,16 @@ const TierSelectionModal: React.FC<TierSelectionModalProps> = ({
{/* Footer */} {/* Footer */}
<div className="bg-gray-50 px-6 py-4 flex justify-end gap-3"> <div className="bg-gray-50 px-6 py-4 flex justify-end gap-3">
<button <button
onClick={onClose} onClick={handleSubmit}
className="px-4 py-2 text-gray-700 hover:text-gray-900 transition-colors" disabled={!localSelectedSlug}
className={classNames(
'px-4 py-2 rounded-md font-medium transition-colors',
localSelectedSlug
? 'bg-desert-green text-white hover:bg-desert-green/90'
: 'bg-gray-300 text-gray-500 cursor-not-allowed'
)}
> >
Close Submit
</button> </button>
</div> </div>
</Dialog.Panel> </Dialog.Panel>

View File

@ -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() { async listDocs() {
return catchInternal(async () => { return catchInternal(async () => {
const response = await this.client.get<Array<{ title: string; slug: string }>>('/docs/list') const response = await this.client.get<Array<{ title: string; slug: string }>>('/docs/list')

View File

@ -392,6 +392,12 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
await Promise.all(downloadPromises) 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({ addNotification({
type: 'success', type: 'success',
message: 'Setup wizard completed! Your selections are being processed.', message: 'Setup wizard completed! Your selections are being processed.',
@ -945,7 +951,9 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
onClose={closeTierModal} onClose={closeTierModal}
category={activeCategory} category={activeCategory}
selectedTierSlug={ selectedTierSlug={
activeCategory ? selectedTiers.get(activeCategory.slug)?.slug : null activeCategory
? selectedTiers.get(activeCategory.slug)?.slug || activeCategory.installedTierSlug
: null
} }
onSelectTier={handleTierSelect} onSelectTier={handleTierSelect}
/> />

View File

@ -222,16 +222,23 @@ export default function ZimRemoteExplorer() {
// Get all resources for this tier (including inherited ones) // Get all resources for this tier (including inherited ones)
const resources = getAllResourcesForTier(tier, category.tiers) const resources = getAllResourcesForTier(tier, category.tiers)
// Download each resource // Download each resource and save the installed tier
try { try {
for (const resource of resources) { for (const resource of resources) {
await api.downloadRemoteZimFile(resource.url) await api.downloadRemoteZimFile(resource.url)
} }
// Save the installed tier
await api.saveInstalledTier(category.slug, tier.slug)
addNotification({ addNotification({
message: `Started downloading ${resources.length} files from "${category.name} - ${tier.name}"`, message: `Started downloading ${resources.length} files from "${category.name} - ${tier.name}"`,
type: 'success', type: 'success',
}) })
invalidateDownloads() invalidateDownloads()
// Refresh categories to update the installed tier display
queryClient.invalidateQueries({ queryKey: [CURATED_CATEGORIES_KEY] })
} catch (error) { } catch (error) {
console.error('Error downloading tier resources:', error) console.error('Error downloading tier resources:', error)
addNotification({ addNotification({
@ -239,8 +246,6 @@ export default function ZimRemoteExplorer() {
type: 'error', type: 'error',
}) })
} }
closeTierModal()
} }
const closeTierModal = () => { const closeTierModal = () => {
@ -322,7 +327,7 @@ export default function ZimRemoteExplorer() {
isOpen={tierModalOpen} isOpen={tierModalOpen}
onClose={closeTierModal} onClose={closeTierModal}
category={activeCategory} category={activeCategory}
selectedTierSlug={null} selectedTierSlug={activeCategory?.installedTierSlug}
onSelectTier={handleTierSelect} onSelectTier={handleTierSelect}
/> />
</> </>

View File

@ -120,6 +120,7 @@ router
router.post('/fetch-latest-collections', [ZimController, 'fetchLatestCollections']) router.post('/fetch-latest-collections', [ZimController, 'fetchLatestCollections'])
router.post('/download-remote', [ZimController, 'downloadRemote']) router.post('/download-remote', [ZimController, 'downloadRemote'])
router.post('/download-collection', [ZimController, 'downloadCollection']) router.post('/download-collection', [ZimController, 'downloadCollection'])
router.post('/save-installed-tier', [ZimController, 'saveInstalledTier'])
router.delete('/:filename', [ZimController, 'delete']) router.delete('/:filename', [ZimController, 'delete'])
}) })
.prefix('/api/zim') .prefix('/api/zim')

View File

@ -84,6 +84,7 @@ export type CuratedCategory = {
description: string description: string
language: string language: string
tiers: CategoryTier[] tiers: CategoryTier[]
installedTierSlug?: string
} }
export type CuratedCategoriesFile = { export type CuratedCategoriesFile = {