Add grid item size setting and UI
Introduce a persistent gridItemSize user setting (1-10) across the app. Updates include: types (UserSettings.gridItemSize), API mappings (grid_item_size in ApiSettingsItem, CreateSettingsInput, convertApiToSettings, convertSettingsToApi), default setting values, and the App handler (handleGridItemSizeChange) to save changes. UI additions: slider control in SettingsView, slider and value in BrowseView (with syncing to incoming API settings), passing the prop and change callback from App, and a mapping from slider values to responsive Tailwind grid column classes so the grid layout adapts to the chosen size. Also added syncing of itemsPerPage in BrowseView and CastView with API-loaded settings.
This commit is contained in:
25
src/App.tsx
25
src/App.tsx
@@ -103,6 +103,7 @@ function AppContent() {
|
|||||||
const baseSettings = settings || {
|
const baseSettings = settings || {
|
||||||
enabledCategories: prev,
|
enabledCategories: prev,
|
||||||
itemsPerPage: 20,
|
itemsPerPage: 20,
|
||||||
|
gridItemSize: 5,
|
||||||
defaultView: 'grid',
|
defaultView: 'grid',
|
||||||
showAdultContent: false,
|
showAdultContent: false,
|
||||||
autoPlayTrailers: false,
|
autoPlayTrailers: false,
|
||||||
@@ -172,6 +173,28 @@ function AppContent() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleGridItemSizeChange = async (size: number) => {
|
||||||
|
const baseSettings = settings || {
|
||||||
|
enabledCategories: enabledCategories,
|
||||||
|
itemsPerPage: 20,
|
||||||
|
gridItemSize: 5,
|
||||||
|
defaultView: 'grid',
|
||||||
|
showAdultContent: false,
|
||||||
|
autoPlayTrailers: false,
|
||||||
|
language: 'en',
|
||||||
|
theme: 'system',
|
||||||
|
};
|
||||||
|
const updatedSettings: UserSettings = {
|
||||||
|
...baseSettings,
|
||||||
|
gridItemSize: size,
|
||||||
|
};
|
||||||
|
updateSettings(updatedSettings).then(saved => {
|
||||||
|
if (saved) {
|
||||||
|
setSettings(saved);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const allStaff = useMemo(() => {
|
const allStaff = useMemo(() => {
|
||||||
const staff: Staff[] = [];
|
const staff: Staff[] = [];
|
||||||
// Use API data if available, otherwise fall back to mock data
|
// Use API data if available, otherwise fall back to mock data
|
||||||
@@ -295,6 +318,8 @@ function AppContent() {
|
|||||||
onMediaClick={handleMediaClick}
|
onMediaClick={handleMediaClick}
|
||||||
activeCategory={activeCategory}
|
activeCategory={activeCategory}
|
||||||
itemsPerPage={settings?.itemsPerPage}
|
itemsPerPage={settings?.itemsPerPage}
|
||||||
|
gridItemSize={settings?.gridItemSize}
|
||||||
|
onGridItemSizeChange={handleGridItemSizeChange}
|
||||||
/>
|
/>
|
||||||
} />
|
} />
|
||||||
<Route path="/media/:id" element={
|
<Route path="/media/:id" element={
|
||||||
|
|||||||
11
src/api.ts
11
src/api.ts
@@ -631,6 +631,7 @@ export interface ApiSettingsItem {
|
|||||||
id?: number;
|
id?: number;
|
||||||
enabled_categories: string[];
|
enabled_categories: string[];
|
||||||
items_per_page: number;
|
items_per_page: number;
|
||||||
|
grid_item_size?: number;
|
||||||
default_view: string;
|
default_view: string;
|
||||||
show_adult_content: boolean;
|
show_adult_content: boolean;
|
||||||
auto_play_trailers: boolean;
|
auto_play_trailers: boolean;
|
||||||
@@ -643,6 +644,7 @@ export interface ApiSettingsItem {
|
|||||||
export interface CreateSettingsInput {
|
export interface CreateSettingsInput {
|
||||||
enabled_categories: string[];
|
enabled_categories: string[];
|
||||||
items_per_page?: number;
|
items_per_page?: number;
|
||||||
|
grid_item_size?: number;
|
||||||
default_view?: string;
|
default_view?: string;
|
||||||
show_adult_content?: boolean;
|
show_adult_content?: boolean;
|
||||||
auto_play_trailers?: boolean;
|
auto_play_trailers?: boolean;
|
||||||
@@ -657,6 +659,7 @@ export function convertApiToSettings(apiItem: ApiSettingsItem): UserSettings {
|
|||||||
id: apiItem.id,
|
id: apiItem.id,
|
||||||
enabledCategories: apiItem.enabled_categories as MediaCategory[],
|
enabledCategories: apiItem.enabled_categories as MediaCategory[],
|
||||||
itemsPerPage: apiItem.items_per_page || 20,
|
itemsPerPage: apiItem.items_per_page || 20,
|
||||||
|
gridItemSize: apiItem.grid_item_size || 5,
|
||||||
defaultView: (apiItem.default_view as 'grid' | 'list') || 'grid',
|
defaultView: (apiItem.default_view as 'grid' | 'list') || 'grid',
|
||||||
showAdultContent: apiItem.show_adult_content || false,
|
showAdultContent: apiItem.show_adult_content || false,
|
||||||
autoPlayTrailers: apiItem.auto_play_trailers || false,
|
autoPlayTrailers: apiItem.auto_play_trailers || false,
|
||||||
@@ -671,6 +674,7 @@ export function convertSettingsToApi(settings: UserSettings): CreateSettingsInpu
|
|||||||
return {
|
return {
|
||||||
enabled_categories: settings.enabledCategories,
|
enabled_categories: settings.enabledCategories,
|
||||||
items_per_page: settings.itemsPerPage,
|
items_per_page: settings.itemsPerPage,
|
||||||
|
grid_item_size: settings.gridItemSize,
|
||||||
default_view: settings.defaultView,
|
default_view: settings.defaultView,
|
||||||
show_adult_content: settings.showAdultContent,
|
show_adult_content: settings.showAdultContent,
|
||||||
auto_play_trailers: settings.autoPlayTrailers,
|
auto_play_trailers: settings.autoPlayTrailers,
|
||||||
@@ -705,7 +709,6 @@ export async function fetchSettings(): Promise<UserSettings | null> {
|
|||||||
export async function createSettings(settings: UserSettings): Promise<UserSettings | null> {
|
export async function createSettings(settings: UserSettings): Promise<UserSettings | null> {
|
||||||
try {
|
try {
|
||||||
const apiSettings = convertSettingsToApi(settings);
|
const apiSettings = convertSettingsToApi(settings);
|
||||||
console.log('Creating settings:', apiSettings);
|
|
||||||
const response = await fetch(`${BASE_URL}/api/settings`, {
|
const response = await fetch(`${BASE_URL}/api/settings`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -713,14 +716,12 @@ export async function createSettings(settings: UserSettings): Promise<UserSettin
|
|||||||
},
|
},
|
||||||
body: JSON.stringify(apiSettings),
|
body: JSON.stringify(apiSettings),
|
||||||
});
|
});
|
||||||
console.log('Create settings response status:', response.status);
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorText = await response.text();
|
const errorText = await response.text();
|
||||||
console.error('Create settings error response:', errorText);
|
console.error('Create settings error response:', errorText);
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
}
|
}
|
||||||
const data: ApiResponse<ApiSettingsItem> = await response.json();
|
const data: ApiResponse<ApiSettingsItem> = await response.json();
|
||||||
console.log('Create settings response:', data);
|
|
||||||
|
|
||||||
if (data.success && data.data) {
|
if (data.success && data.data) {
|
||||||
return convertApiToSettings(data.data);
|
return convertApiToSettings(data.data);
|
||||||
@@ -735,7 +736,6 @@ export async function createSettings(settings: UserSettings): Promise<UserSettin
|
|||||||
export async function updateSettings(settings: UserSettings): Promise<UserSettings | null> {
|
export async function updateSettings(settings: UserSettings): Promise<UserSettings | null> {
|
||||||
try {
|
try {
|
||||||
const apiSettings = convertSettingsToApi(settings);
|
const apiSettings = convertSettingsToApi(settings);
|
||||||
console.log('Updating settings:', apiSettings);
|
|
||||||
const response = await fetch(`${BASE_URL}/api/settings`, {
|
const response = await fetch(`${BASE_URL}/api/settings`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -743,11 +743,9 @@ export async function updateSettings(settings: UserSettings): Promise<UserSettin
|
|||||||
},
|
},
|
||||||
body: JSON.stringify(apiSettings),
|
body: JSON.stringify(apiSettings),
|
||||||
});
|
});
|
||||||
console.log('Update settings response status:', response.status);
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
// If settings don't exist (404), try creating them instead
|
// If settings don't exist (404), try creating them instead
|
||||||
if (response.status === 404) {
|
if (response.status === 404) {
|
||||||
console.log('Settings not found, attempting to create...');
|
|
||||||
return createSettings(settings);
|
return createSettings(settings);
|
||||||
}
|
}
|
||||||
const errorText = await response.text();
|
const errorText = await response.text();
|
||||||
@@ -755,7 +753,6 @@ export async function updateSettings(settings: UserSettings): Promise<UserSettin
|
|||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
}
|
}
|
||||||
const data: ApiResponse<ApiSettingsItem> = await response.json();
|
const data: ApiResponse<ApiSettingsItem> = await response.json();
|
||||||
console.log('Update settings response:', data);
|
|
||||||
|
|
||||||
if (data.success && data.data) {
|
if (data.success && data.data) {
|
||||||
return convertApiToSettings(data.data);
|
return convertApiToSettings(data.data);
|
||||||
|
|||||||
@@ -18,13 +18,30 @@ interface BrowseViewProps {
|
|||||||
onMediaClick: (media: Media) => void;
|
onMediaClick: (media: Media) => void;
|
||||||
activeCategory: MediaCategory;
|
activeCategory: MediaCategory;
|
||||||
itemsPerPage?: number;
|
itemsPerPage?: number;
|
||||||
|
gridItemSize?: number;
|
||||||
|
onGridItemSizeChange?: (size: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function BrowseView({ mediaList, onMediaClick, activeCategory, itemsPerPage: initialItemsPerPage = 12 }: BrowseViewProps) {
|
export default function BrowseView({ mediaList, onMediaClick, activeCategory, itemsPerPage: initialItemsPerPage = 12, gridItemSize: initialGridItemSize = 5, onGridItemSizeChange }: BrowseViewProps) {
|
||||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [itemsPerPage, setItemsPerPage] = useState(initialItemsPerPage);
|
const [itemsPerPage, setItemsPerPage] = useState(initialItemsPerPage);
|
||||||
const [sortBy, setSortBy] = useState<string>('default');
|
const [sortBy, setSortBy] = useState<string>('default');
|
||||||
|
const [gridItemSize, setGridItemSize] = useState<number>(initialGridItemSize);
|
||||||
|
|
||||||
|
// Sync itemsPerPage with prop when API settings are loaded
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialItemsPerPage) {
|
||||||
|
setItemsPerPage(initialItemsPerPage);
|
||||||
|
}
|
||||||
|
}, [initialItemsPerPage]);
|
||||||
|
|
||||||
|
// Sync gridItemSize with prop when API settings are loaded
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialGridItemSize !== undefined) {
|
||||||
|
setGridItemSize(initialGridItemSize);
|
||||||
|
}
|
||||||
|
}, [initialGridItemSize]);
|
||||||
|
|
||||||
// Filter states
|
// Filter states
|
||||||
const [selectedGenre, setSelectedGenre] = useState<string | null>(null);
|
const [selectedGenre, setSelectedGenre] = useState<string | null>(null);
|
||||||
@@ -67,6 +84,23 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
|
|||||||
return list;
|
return list;
|
||||||
}, [filteredMedia, sortBy]);
|
}, [filteredMedia, sortBy]);
|
||||||
|
|
||||||
|
const gridColsClass = useMemo(() => {
|
||||||
|
// Map slider value (1-10) to grid columns
|
||||||
|
const colsMap: Record<number, string> = {
|
||||||
|
1: 'grid-cols-1 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4',
|
||||||
|
2: 'grid-cols-1 sm:grid-cols-2 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4',
|
||||||
|
3: 'grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5',
|
||||||
|
4: 'grid-cols-2 sm:grid-cols-3 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6',
|
||||||
|
5: 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8',
|
||||||
|
6: 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8',
|
||||||
|
7: 'grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-9',
|
||||||
|
8: 'grid-cols-3 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8 xl:grid-cols-10',
|
||||||
|
9: 'grid-cols-4 sm:grid-cols-5 md:grid-cols-6 lg:grid-cols-8 xl:grid-cols-10',
|
||||||
|
10: 'grid-cols-4 sm:grid-cols-5 md:grid-cols-6 lg:grid-cols-8 xl:grid-cols-12',
|
||||||
|
};
|
||||||
|
return `grid ${colsMap[gridItemSize] || colsMap[5]}`;
|
||||||
|
}, [gridItemSize]);
|
||||||
|
|
||||||
const totalPages = Math.ceil(sortedMedia.length / itemsPerPage);
|
const totalPages = Math.ceil(sortedMedia.length / itemsPerPage);
|
||||||
|
|
||||||
const paginatedMedia = useMemo(() => {
|
const paginatedMedia = useMemo(() => {
|
||||||
@@ -193,6 +227,24 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
|
{/* Grid item size slider */}
|
||||||
|
<div className="flex items-center gap-3 bg-muted rounded-md px-3 py-2">
|
||||||
|
<span className="text-xs font-bold text-muted-foreground">Size</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="1"
|
||||||
|
max="10"
|
||||||
|
value={gridItemSize}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newSize = Number(e.target.value);
|
||||||
|
setGridItemSize(newSize);
|
||||||
|
onGridItemSizeChange?.(newSize);
|
||||||
|
}}
|
||||||
|
className="w-24 h-2 bg-background rounded-lg appearance-none cursor-pointer accent-[#6d28d9]"
|
||||||
|
/>
|
||||||
|
<span className="text-xs font-bold text-[#6d28d9] w-5 text-center">{gridItemSize}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<button type="button" className="group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5 text-muted-foreground font-bold gap-2">
|
<button type="button" className="group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5 text-muted-foreground font-bold gap-2">
|
||||||
@@ -246,7 +298,7 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory, it
|
|||||||
) : (
|
) : (
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
viewMode === 'grid'
|
viewMode === 'grid'
|
||||||
? "grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-x-4 gap-y-8"
|
? cn(gridColsClass, "gap-x-4 gap-y-8")
|
||||||
: "flex flex-col gap-2"
|
: "flex flex-col gap-2"
|
||||||
)}>
|
)}>
|
||||||
<AnimatePresence mode="popLayout">
|
<AnimatePresence mode="popLayout">
|
||||||
|
|||||||
@@ -38,6 +38,13 @@ export default function CastView({ onPersonClick, enabledCategories, itemsPerPag
|
|||||||
const [itemsPerPage, setItemsPerPage] = useState(initialItemsPerPage);
|
const [itemsPerPage, setItemsPerPage] = useState(initialItemsPerPage);
|
||||||
const [showFilters, setShowFilters] = useState(false);
|
const [showFilters, setShowFilters] = useState(false);
|
||||||
|
|
||||||
|
// Sync itemsPerPage with prop when API settings are loaded
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialItemsPerPage) {
|
||||||
|
setItemsPerPage(initialItemsPerPage);
|
||||||
|
}
|
||||||
|
}, [initialItemsPerPage]);
|
||||||
|
|
||||||
// Persist filters and sorts
|
// Persist filters and sorts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem('castSearchQuery', searchQuery);
|
localStorage.setItem('castSearchQuery', searchQuery);
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
|
|||||||
const [settings, setSettings] = useState<UserSettings>({
|
const [settings, setSettings] = useState<UserSettings>({
|
||||||
enabledCategories: ['Anime', 'Movies', 'TV Series', 'Music', 'Books', 'Consoles', 'Games', 'Adult'],
|
enabledCategories: ['Anime', 'Movies', 'TV Series', 'Music', 'Books', 'Consoles', 'Games', 'Adult'],
|
||||||
itemsPerPage: 20,
|
itemsPerPage: 20,
|
||||||
|
gridItemSize: 5,
|
||||||
defaultView: 'grid',
|
defaultView: 'grid',
|
||||||
showAdultContent: false,
|
showAdultContent: false,
|
||||||
autoPlayTrailers: false,
|
autoPlayTrailers: false,
|
||||||
@@ -233,6 +234,24 @@ export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Grid item size */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm font-black text-foreground mb-2 block">Grid item size</Label>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="text-xs font-bold text-muted-foreground">Small</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="1"
|
||||||
|
max="10"
|
||||||
|
value={settings.gridItemSize}
|
||||||
|
onChange={(e) => setSettings(prev => ({ ...prev, gridItemSize: Number(e.target.value) }))}
|
||||||
|
className="flex-1 h-2 bg-muted rounded-lg appearance-none cursor-pointer accent-[#6d28d9]"
|
||||||
|
/>
|
||||||
|
<span className="text-xs font-bold text-muted-foreground">Large</span>
|
||||||
|
<span className="text-sm font-bold text-[#6d28d9] w-8 text-center">{settings.gridItemSize}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Theme */}
|
{/* Theme */}
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-sm font-black text-foreground mb-2 block">Theme</Label>
|
<Label className="text-sm font-black text-foreground mb-2 block">Theme</Label>
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ export interface UserSettings {
|
|||||||
id?: number;
|
id?: number;
|
||||||
enabledCategories: MediaCategory[];
|
enabledCategories: MediaCategory[];
|
||||||
itemsPerPage: number;
|
itemsPerPage: number;
|
||||||
|
gridItemSize: number; // 1-10 scale
|
||||||
defaultView: 'grid' | 'list';
|
defaultView: 'grid' | 'list';
|
||||||
showAdultContent: boolean;
|
showAdultContent: boolean;
|
||||||
autoPlayTrailers: boolean;
|
autoPlayTrailers: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user