mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
Add per-user home sections & unified Audible cache
Introduce per-user configurable home page sections and a unified Audible cache/category model. Adds Prisma models (UserHomeSection, AudibleCacheCategory) and migrations to create tables and remove legacy popular/new_release flags; updates schema.prisma accordingly. Add API routes for user home sections, live Audible categories, and category-based audiobook listing, and refactor popular/new-releases/covers routes to read from AudibleCacheCategory. Frontend: new HomeSection component, HomeSectionConfigModal, useHomeSections hook, and homepage changes to render dynamic sections plus image fallback to a placeholder SVG. Also add placeholder_cover.svg and tests for home sections and the audible refresh processor.
This commit is contained in:
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* Component: Home Section — renders a single audiobook discovery section
|
||||
* Documentation: documentation/features/home-sections.md
|
||||
*
|
||||
* Handles popular, new_releases, and category section types with unified rendering.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import { AudiobookGrid } from '@/components/audiobooks/AudiobookGrid';
|
||||
import { SectionToolbar } from '@/components/ui/SectionToolbar';
|
||||
import { useAudiobooks } from '@/lib/hooks/useAudiobooks';
|
||||
import { useCategoryAudiobooks } from '@/lib/hooks/useHomeSections';
|
||||
import { Cog6ToothIcon, ClockIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
const SECTION_COLORS = [
|
||||
'from-blue-500 to-indigo-500',
|
||||
'from-emerald-500 to-teal-500',
|
||||
'from-violet-500 to-purple-500',
|
||||
'from-amber-500 to-orange-500',
|
||||
'from-rose-500 to-pink-500',
|
||||
'from-cyan-500 to-sky-500',
|
||||
'from-fuchsia-500 to-pink-500',
|
||||
'from-lime-500 to-green-500',
|
||||
'from-orange-500 to-red-500',
|
||||
'from-teal-500 to-emerald-500',
|
||||
];
|
||||
|
||||
export const SECTION_DOT_COLORS = [
|
||||
'bg-blue-500', 'bg-emerald-500', 'bg-violet-500', 'bg-amber-500', 'bg-rose-500',
|
||||
'bg-cyan-500', 'bg-fuchsia-500', 'bg-lime-500', 'bg-orange-500', 'bg-teal-500',
|
||||
];
|
||||
|
||||
function getSectionTitle(sectionType: string, categoryName?: string | null): string {
|
||||
if (sectionType === 'popular') return 'Popular Audiobooks';
|
||||
if (sectionType === 'new_releases') return 'New Releases';
|
||||
return categoryName || 'Category';
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a nextRefresh ISO timestamp into a friendly, readable string.
|
||||
* Examples: "today at 6:00 PM", "tomorrow at 2:00 AM", "Saturday at 9:00 AM"
|
||||
*/
|
||||
function formatNextRefresh(isoString: string): string {
|
||||
const refreshDate = new Date(isoString);
|
||||
const now = new Date();
|
||||
|
||||
const refreshMidnight = new Date(refreshDate);
|
||||
refreshMidnight.setHours(0, 0, 0, 0);
|
||||
|
||||
const todayMidnight = new Date(now);
|
||||
todayMidnight.setHours(0, 0, 0, 0);
|
||||
|
||||
const tomorrowMidnight = new Date(todayMidnight);
|
||||
tomorrowMidnight.setDate(tomorrowMidnight.getDate() + 1);
|
||||
|
||||
const dayAfterMidnight = new Date(tomorrowMidnight);
|
||||
dayAfterMidnight.setDate(dayAfterMidnight.getDate() + 1);
|
||||
|
||||
const timeStr = refreshDate.toLocaleTimeString(undefined, {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
});
|
||||
|
||||
if (refreshMidnight.getTime() === todayMidnight.getTime()) {
|
||||
return `today at ${timeStr}`;
|
||||
}
|
||||
if (refreshMidnight.getTime() === tomorrowMidnight.getTime()) {
|
||||
return `tomorrow at ${timeStr}`;
|
||||
}
|
||||
if (refreshMidnight.getTime() < dayAfterMidnight.getTime()) {
|
||||
const dayName = refreshDate.toLocaleDateString(undefined, { weekday: 'long' });
|
||||
return `${dayName} at ${timeStr}`;
|
||||
}
|
||||
|
||||
const dateStr = refreshDate.toLocaleDateString(undefined, {
|
||||
weekday: 'long',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
return `${dateStr} at ${timeStr}`;
|
||||
}
|
||||
|
||||
interface HomeSectionProps {
|
||||
sectionType: 'popular' | 'new_releases' | 'category';
|
||||
categoryId: string | null;
|
||||
categoryName: string | null;
|
||||
colorIndex: number;
|
||||
page: number;
|
||||
onPageChange: (page: number) => void;
|
||||
sectionRef: React.RefObject<HTMLElement | null>;
|
||||
cardSize: number;
|
||||
squareCovers: boolean;
|
||||
hideAvailable: boolean;
|
||||
onToggleHideAvailable: (v: boolean) => void;
|
||||
onToggleSquareCovers: (v: boolean) => void;
|
||||
onCardSizeChange: (v: number) => void;
|
||||
onConfigOpen?: () => void;
|
||||
onTotalPagesChange?: (totalPages: number) => void;
|
||||
nextRefresh: string | null;
|
||||
}
|
||||
|
||||
function PopularOrNewSection({
|
||||
type,
|
||||
page,
|
||||
hideAvailable,
|
||||
onTotalPagesChange,
|
||||
...renderProps
|
||||
}: {
|
||||
type: 'popular' | 'new-releases';
|
||||
page: number;
|
||||
hideAvailable: boolean;
|
||||
onTotalPagesChange?: (totalPages: number) => void;
|
||||
} & RenderSectionProps) {
|
||||
const { audiobooks, isLoading, totalPages, message } = useAudiobooks(type, 20, page, hideAvailable);
|
||||
|
||||
useEffect(() => {
|
||||
onTotalPagesChange?.(totalPages);
|
||||
}, [totalPages, onTotalPagesChange]);
|
||||
|
||||
return (
|
||||
<RenderSection
|
||||
audiobooks={audiobooks}
|
||||
isLoading={isLoading}
|
||||
totalPages={totalPages}
|
||||
message={message}
|
||||
{...renderProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CategorySection({
|
||||
categoryId,
|
||||
page,
|
||||
hideAvailable,
|
||||
onTotalPagesChange,
|
||||
...renderProps
|
||||
}: {
|
||||
categoryId: string;
|
||||
page: number;
|
||||
hideAvailable: boolean;
|
||||
onTotalPagesChange?: (totalPages: number) => void;
|
||||
} & RenderSectionProps) {
|
||||
const { audiobooks, isLoading, totalPages, message } = useCategoryAudiobooks(
|
||||
categoryId,
|
||||
20,
|
||||
page,
|
||||
hideAvailable
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
onTotalPagesChange?.(totalPages);
|
||||
}, [totalPages, onTotalPagesChange]);
|
||||
|
||||
return (
|
||||
<RenderSection
|
||||
audiobooks={audiobooks}
|
||||
isLoading={isLoading}
|
||||
totalPages={totalPages}
|
||||
message={message}
|
||||
{...renderProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface RenderSectionProps {
|
||||
cardSize: number;
|
||||
squareCovers: boolean;
|
||||
nextRefresh?: string | null;
|
||||
}
|
||||
|
||||
function CategoryEmptyState({ nextRefresh }: { nextRefresh?: string | null }) {
|
||||
const refreshLabel = nextRefresh ? formatNextRefresh(nextRefresh) : null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-14 px-6 text-center">
|
||||
<div className="flex items-center justify-center w-11 h-11 rounded-full bg-gray-100 dark:bg-gray-700/60 mb-4">
|
||||
<ClockIcon className="w-5 h-5 text-gray-400 dark:text-gray-500" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
No audiobooks yet
|
||||
</p>
|
||||
<p className="text-sm text-gray-400 dark:text-gray-500 max-w-xs leading-relaxed">
|
||||
{refreshLabel
|
||||
? <>This section will fill in after the next data refresh, scheduled for <span className="text-gray-500 dark:text-gray-400">{refreshLabel}</span>.</>
|
||||
: 'This section will fill in after the next scheduled data refresh.'}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RenderSection({
|
||||
audiobooks,
|
||||
isLoading,
|
||||
totalPages,
|
||||
message,
|
||||
cardSize,
|
||||
squareCovers,
|
||||
nextRefresh,
|
||||
}: RenderSectionProps & {
|
||||
audiobooks: any[];
|
||||
isLoading: boolean;
|
||||
totalPages: number;
|
||||
message: string | null;
|
||||
}) {
|
||||
if (message && !isLoading && audiobooks.length === 0) {
|
||||
return <CategoryEmptyState nextRefresh={nextRefresh} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<AudiobookGrid
|
||||
audiobooks={audiobooks}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="No audiobooks available"
|
||||
cardSize={cardSize}
|
||||
squareCovers={squareCovers}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function HomeSection({
|
||||
sectionType,
|
||||
categoryId,
|
||||
categoryName,
|
||||
colorIndex,
|
||||
page,
|
||||
onPageChange,
|
||||
sectionRef,
|
||||
cardSize,
|
||||
squareCovers,
|
||||
hideAvailable,
|
||||
onToggleHideAvailable,
|
||||
onToggleSquareCovers,
|
||||
onCardSizeChange,
|
||||
onConfigOpen,
|
||||
onTotalPagesChange,
|
||||
nextRefresh,
|
||||
}: HomeSectionProps) {
|
||||
const gradient = SECTION_COLORS[colorIndex % SECTION_COLORS.length];
|
||||
const title = getSectionTitle(sectionType, categoryName);
|
||||
|
||||
const renderProps: RenderSectionProps = { cardSize, squareCovers, nextRefresh };
|
||||
|
||||
return (
|
||||
<section ref={sectionRef} className="relative">
|
||||
{/* Sticky Section Header */}
|
||||
<div className="sticky top-14 sm:top-16 z-30 mb-4 sm:mb-6">
|
||||
<div className="bg-white/90 dark:bg-gray-800/90 backdrop-blur-md rounded-2xl px-4 sm:px-6 py-3 border border-gray-200/50 dark:border-gray-700/50 shadow-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-1 h-6 bg-gradient-to-b ${gradient} rounded-full`} />
|
||||
<h2 className="text-xl sm:text-2xl md:text-3xl font-bold text-gray-900 dark:text-gray-100 truncate">
|
||||
{title}
|
||||
</h2>
|
||||
<SectionToolbar
|
||||
hideAvailable={hideAvailable}
|
||||
onToggleHideAvailable={onToggleHideAvailable}
|
||||
squareCovers={squareCovers}
|
||||
onToggleSquareCovers={onToggleSquareCovers}
|
||||
cardSize={cardSize}
|
||||
onCardSizeChange={onCardSizeChange}
|
||||
/>
|
||||
{onConfigOpen && (
|
||||
<button
|
||||
onClick={onConfigOpen}
|
||||
className="p-1.5 rounded-lg text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700/50 transition-colors"
|
||||
aria-label="Customize home page"
|
||||
title="Customize sections"
|
||||
>
|
||||
<Cog6ToothIcon className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section Content */}
|
||||
<div className="bg-white/40 dark:bg-gray-800/40 backdrop-blur-sm rounded-2xl p-4 sm:p-6 border border-gray-200/50 dark:border-gray-700/50 shadow-sm">
|
||||
{sectionType === 'popular' && (
|
||||
<PopularOrNewSection
|
||||
type="popular"
|
||||
page={page}
|
||||
hideAvailable={hideAvailable}
|
||||
onTotalPagesChange={onTotalPagesChange}
|
||||
{...renderProps}
|
||||
/>
|
||||
)}
|
||||
{sectionType === 'new_releases' && (
|
||||
<PopularOrNewSection
|
||||
type="new-releases"
|
||||
page={page}
|
||||
hideAvailable={hideAvailable}
|
||||
onTotalPagesChange={onTotalPagesChange}
|
||||
{...renderProps}
|
||||
/>
|
||||
)}
|
||||
{sectionType === 'category' && categoryId && (
|
||||
<CategorySection
|
||||
categoryId={categoryId}
|
||||
page={page}
|
||||
hideAvailable={hideAvailable}
|
||||
onTotalPagesChange={onTotalPagesChange}
|
||||
{...renderProps}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
/**
|
||||
* Component: Home Section Configuration Modal
|
||||
* Documentation: documentation/features/home-sections.md
|
||||
*
|
||||
* Allows users to add/remove/reorder home page sections.
|
||||
* Drag-and-drop on desktop, up/down arrows on mobile. Auto-save with debounce.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
XMarkIcon,
|
||||
PlusIcon,
|
||||
TrashIcon,
|
||||
ChevronUpIcon,
|
||||
ChevronDownIcon,
|
||||
Bars3Icon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import type { HomeSection, AudibleCategory } from '@/lib/hooks/useHomeSections';
|
||||
import { authenticatedFetcher } from '@/lib/utils/api';
|
||||
|
||||
const MAX_SECTIONS = 10;
|
||||
|
||||
const SECTION_COLORS = [
|
||||
'bg-blue-500', 'bg-emerald-500', 'bg-violet-500', 'bg-amber-500', 'bg-rose-500',
|
||||
'bg-cyan-500', 'bg-fuchsia-500', 'bg-lime-500', 'bg-orange-500', 'bg-teal-500',
|
||||
];
|
||||
|
||||
function getSectionLabel(section: { sectionType: string; categoryName?: string | null }) {
|
||||
if (section.sectionType === 'popular') return 'Popular Audiobooks';
|
||||
if (section.sectionType === 'new_releases') return 'New Releases';
|
||||
return section.categoryName || 'Category';
|
||||
}
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
sections: HomeSection[];
|
||||
onSave: (sections: Omit<HomeSection, 'id'>[]) => Promise<unknown>;
|
||||
}
|
||||
|
||||
export function HomeSectionConfigModal({ isOpen, onClose, sections, onSave }: Props) {
|
||||
const [localSections, setLocalSections] = useState<Omit<HomeSection, 'id'>[]>([]);
|
||||
const [categories, setCategories] = useState<AudibleCategory[]>([]);
|
||||
const [loadingCategories, setLoadingCategories] = useState(false);
|
||||
const [showCategoryPicker, setShowCategoryPicker] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
||||
|
||||
// Sync from prop when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setLocalSections(
|
||||
sections.map((s) => ({
|
||||
sectionType: s.sectionType,
|
||||
categoryId: s.categoryId,
|
||||
categoryName: s.categoryName,
|
||||
sortOrder: s.sortOrder,
|
||||
}))
|
||||
);
|
||||
setDirty(false);
|
||||
setShowCategoryPicker(false);
|
||||
}
|
||||
}, [isOpen, sections]);
|
||||
|
||||
// Auto-save with debounce
|
||||
useEffect(() => {
|
||||
if (!dirty) return;
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave(localSections.map((s, i) => ({ ...s, sortOrder: i })));
|
||||
} catch {
|
||||
// Silently fail — user will see stale state
|
||||
}
|
||||
setSaving(false);
|
||||
setDirty(false);
|
||||
}, 800);
|
||||
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, [dirty, localSections, onSave]);
|
||||
|
||||
// Fetch categories when picker opens
|
||||
const loadCategories = useCallback(async () => {
|
||||
if (categories.length > 0) {
|
||||
setShowCategoryPicker(true);
|
||||
return;
|
||||
}
|
||||
setLoadingCategories(true);
|
||||
try {
|
||||
const data = await authenticatedFetcher('/api/audible/categories');
|
||||
setCategories(data.categories || []);
|
||||
} catch {
|
||||
setCategories([]);
|
||||
}
|
||||
setLoadingCategories(false);
|
||||
setShowCategoryPicker(true);
|
||||
}, [categories.length]);
|
||||
|
||||
const addCategory = useCallback(
|
||||
(cat: AudibleCategory) => {
|
||||
if (localSections.length >= MAX_SECTIONS) return;
|
||||
// Prevent duplicate
|
||||
if (localSections.some((s) => s.sectionType === 'category' && s.categoryId === cat.id)) return;
|
||||
|
||||
setLocalSections((prev) => [
|
||||
...prev,
|
||||
{
|
||||
sectionType: 'category' as const,
|
||||
categoryId: cat.id,
|
||||
categoryName: cat.name,
|
||||
sortOrder: prev.length,
|
||||
},
|
||||
]);
|
||||
setDirty(true);
|
||||
setShowCategoryPicker(false);
|
||||
},
|
||||
[localSections]
|
||||
);
|
||||
|
||||
const addBuiltIn = useCallback(
|
||||
(type: 'popular' | 'new_releases') => {
|
||||
if (localSections.length >= MAX_SECTIONS) return;
|
||||
if (localSections.some((s) => s.sectionType === type)) return;
|
||||
|
||||
setLocalSections((prev) => [
|
||||
...prev,
|
||||
{ sectionType: type, categoryId: null, categoryName: null, sortOrder: prev.length },
|
||||
]);
|
||||
setDirty(true);
|
||||
},
|
||||
[localSections]
|
||||
);
|
||||
|
||||
const removeSection = useCallback((index: number) => {
|
||||
setLocalSections((prev) => prev.filter((_, i) => i !== index));
|
||||
setDirty(true);
|
||||
}, []);
|
||||
|
||||
const moveSection = useCallback((from: number, to: number) => {
|
||||
setLocalSections((prev) => {
|
||||
const next = [...prev];
|
||||
const [item] = next.splice(from, 1);
|
||||
next.splice(to, 0, item);
|
||||
return next;
|
||||
});
|
||||
setDirty(true);
|
||||
}, []);
|
||||
|
||||
// Drag handlers
|
||||
const handleDragStart = (index: number) => {
|
||||
setDragIndex(index);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent, index: number) => {
|
||||
e.preventDefault();
|
||||
if (dragIndex === null || dragIndex === index) return;
|
||||
moveSection(dragIndex, index);
|
||||
setDragIndex(index);
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setDragIndex(null);
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const hasPopular = localSections.some((s) => s.sectionType === 'popular');
|
||||
const hasNewReleases = localSections.some((s) => s.sectionType === 'new_releases');
|
||||
const existingCategoryIds = new Set(
|
||||
localSections.filter((s) => s.sectionType === 'category').map((s) => s.categoryId)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative bg-white dark:bg-gray-900 rounded-2xl shadow-2xl w-full max-w-lg mx-4 max-h-[85vh] flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
Customize Home
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{localSections.length}/{MAX_SECTIONS} sections
|
||||
{saving && (
|
||||
<span className="ml-2 text-blue-500 dark:text-blue-400">Saving...</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<XMarkIcon className="w-5 h-5 text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Section list */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-2">
|
||||
{localSections.length === 0 && (
|
||||
<div className="text-center text-gray-400 dark:text-gray-500 py-8">
|
||||
<p className="text-sm">No sections configured.</p>
|
||||
<p className="text-xs mt-1">Add sections below to customize your home page.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{localSections.map((section, index) => (
|
||||
<div
|
||||
key={`${section.sectionType}-${section.categoryId || index}`}
|
||||
draggable
|
||||
onDragStart={() => handleDragStart(index)}
|
||||
onDragOver={(e) => handleDragOver(e, index)}
|
||||
onDragEnd={handleDragEnd}
|
||||
className={`
|
||||
flex items-center gap-3 px-3 py-2.5 rounded-xl border transition-all duration-200
|
||||
${dragIndex === index
|
||||
? 'border-blue-400 dark:border-blue-500 bg-blue-50 dark:bg-blue-900/20 shadow-md scale-[1.02]'
|
||||
: 'border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50 hover:border-gray-300 dark:hover:border-gray-600'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<div className="cursor-grab active:cursor-grabbing text-gray-400 dark:text-gray-500 hidden sm:block">
|
||||
<Bars3Icon className="w-4 h-4" />
|
||||
</div>
|
||||
|
||||
{/* Color dot */}
|
||||
<div className={`w-2 h-2 rounded-full flex-shrink-0 ${SECTION_COLORS[index % SECTION_COLORS.length]}`} />
|
||||
|
||||
{/* Label */}
|
||||
<span className="flex-1 text-sm font-medium text-gray-800 dark:text-gray-200 truncate">
|
||||
{getSectionLabel(section)}
|
||||
</span>
|
||||
|
||||
{/* Mobile reorder arrows */}
|
||||
<div className="flex sm:hidden gap-0.5">
|
||||
<button
|
||||
onClick={() => index > 0 && moveSection(index, index - 1)}
|
||||
disabled={index === 0}
|
||||
className="p-1 rounded text-gray-400 hover:text-gray-600 disabled:opacity-25"
|
||||
aria-label="Move up"
|
||||
>
|
||||
<ChevronUpIcon className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => index < localSections.length - 1 && moveSection(index, index + 1)}
|
||||
disabled={index === localSections.length - 1}
|
||||
className="p-1 rounded text-gray-400 hover:text-gray-600 disabled:opacity-25"
|
||||
aria-label="Move down"
|
||||
>
|
||||
<ChevronDownIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Remove */}
|
||||
<button
|
||||
onClick={() => removeSection(index)}
|
||||
className="p-1 rounded-lg text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
|
||||
aria-label={`Remove ${getSectionLabel(section)}`}
|
||||
>
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Add section controls */}
|
||||
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 space-y-3">
|
||||
{/* Built-in section buttons */}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{!hasPopular && (
|
||||
<button
|
||||
onClick={() => addBuiltIn('popular')}
|
||||
disabled={localSections.length >= MAX_SECTIONS}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/20 rounded-lg hover:bg-blue-100 dark:hover:bg-blue-900/40 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<PlusIcon className="w-3.5 h-3.5" />
|
||||
Popular
|
||||
</button>
|
||||
)}
|
||||
{!hasNewReleases && (
|
||||
<button
|
||||
onClick={() => addBuiltIn('new_releases')}
|
||||
disabled={localSections.length >= MAX_SECTIONS}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-900/20 rounded-lg hover:bg-emerald-100 dark:hover:bg-emerald-900/40 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<PlusIcon className="w-3.5 h-3.5" />
|
||||
New Releases
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={loadCategories}
|
||||
disabled={localSections.length >= MAX_SECTIONS || loadingCategories}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-violet-600 dark:text-violet-400 bg-violet-50 dark:bg-violet-900/20 rounded-lg hover:bg-violet-100 dark:hover:bg-violet-900/40 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<PlusIcon className="w-3.5 h-3.5" />
|
||||
{loadingCategories ? 'Loading...' : 'Category'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Category picker */}
|
||||
{showCategoryPicker && (
|
||||
<div className="max-h-48 overflow-y-auto rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
|
||||
{categories.length === 0 ? (
|
||||
<div className="px-4 py-3 text-sm text-gray-500">No categories found.</div>
|
||||
) : (
|
||||
categories
|
||||
.filter((c) => !existingCategoryIds.has(c.id))
|
||||
.map((cat) => (
|
||||
<button
|
||||
key={cat.id}
|
||||
onClick={() => addCategory(cat)}
|
||||
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors border-b border-gray-100 dark:border-gray-700/50 last:border-0"
|
||||
>
|
||||
{cat.name}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowCategoryPicker(false)}
|
||||
className="w-full px-4 py-2 text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user