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,
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)

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 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<void> {
await InstalledTier.updateOrCreate(
{ category_slug: categorySlug },
{ tier_slug: tierSlug }
)
logger.info(`[ZimService] Saved installed tier: ${categorySlug} -> ${tierSlug}`)
}
async listCuratedCollections(): Promise<CuratedCollectionWithStatus[]> {
const collections = await CuratedCollection.query().where('type', 'zim').preload('resources')
return collections.map((collection) => ({

View File

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

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 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 (
<div
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">
<p className="text-sm text-gray-300 mb-2">
{category.tiers.length} tiers available
{!highlightedTierSlug && (
<span className="text-gray-400"> - Click to choose</span>
)}
</p>
<div className="flex flex-wrap gap-2">
{category.tiers.map((tier) => (
<span
key={tier.slug}
className={classNames(
'text-xs px-2 py-1 rounded',
tier.recommended
? '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>
))}
{category.tiers.map((tier) => {
const isInstalled = tier.slug === highlightedTierSlug
return (
<span
key={tier.slug}
className={classNames(
'text-xs px-2 py-1 rounded',
isInstalled
? 'bg-lime-500/30 text-lime-200'
: 'bg-white/10 text-gray-300',
selectedTier?.slug === tier.slug && 'ring-2 ring-lime-400'
)}
>
{tier.name}
</span>
)
})}
</div>
<p className="text-gray-300 text-xs mt-3">
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 { IconX, IconCheck, IconInfoCircle } from '@tabler/icons-react'
import { CuratedCategory, CategoryTier, CategoryResource } from '../../types/downloads'
@ -21,6 +21,16 @@ const TierSelectionModal: React.FC<TierSelectionModalProps> = ({
selectedTierSlug,
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
// 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)
}
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 (
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-50" onClose={onClose}>
@ -99,21 +128,20 @@ const TierSelectionModal: React.FC<TierSelectionModalProps> = ({
</p>
<div className="space-y-4">
{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 (
<div
key={tier.slug}
onClick={() => 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'
)}
>
<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">
{tier.name}
</h3>
{tier.recommended && (
<span className="text-xs bg-lime-500 text-white px-2 py-0.5 rounded">
Recommended
</span>
)}
{tier.includesTier && (
<span className="text-xs text-gray-500">
(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">
<IconInfoCircle size={18} className="text-blue-500 flex-shrink-0 mt-0.5" />
<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>
</div>
</div>
@ -187,10 +210,16 @@ const TierSelectionModal: React.FC<TierSelectionModalProps> = ({
{/* Footer */}
<div className="bg-gray-50 px-6 py-4 flex justify-end gap-3">
<button
onClick={onClose}
className="px-4 py-2 text-gray-700 hover:text-gray-900 transition-colors"
onClick={handleSubmit}
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>
</div>
</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() {
return catchInternal(async () => {
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)
// 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}
/>

View File

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

View File

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

View File

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