mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-04-04 15:56:16 +02: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,
|
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)
|
||||||
|
|
||||||
|
|
|
||||||
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 { 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) => ({
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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 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)}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user