mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-03-28 03:29:25 +01:00
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:
parent
64e6e11389
commit
5afc3a270a
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
21
admin/app/models/installed_tier.ts
Normal file
21
admin/app/models/installed_tier.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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) => ({
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
})
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@ export type CuratedCategory = {
|
|||
description: string
|
||||
language: string
|
||||
tiers: CategoryTier[]
|
||||
installedTierSlug?: string
|
||||
}
|
||||
|
||||
export type CuratedCategoriesFile = {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user