From 04156486e207d76404cd16e95b9daf9832210a07 Mon Sep 17 00:00:00 2001 From: Lars Behrends Date: Fri, 10 Apr 2026 14:14:27 +0200 Subject: [PATCH] Add user settings UI and API integration Introduce a full user settings feature: add a SettingsView component and UserSettings type, plus API helpers to fetch, create, and update settings (convertors between API and app shapes). App now loads settings on mount, persists category toggles to the API, exposes a /settings route, and passes itemsPerPage into BrowseView and CastView. Header gains a settings icon/link and BrowseView/CastView update pagination option defaults. This enables centralized library/display/content preferences and syncs them with the backend. --- src/App.tsx | 62 +++++- src/api.ts | 143 +++++++++++++- src/components/BrowseView.tsx | 7 +- src/components/CastView.tsx | 7 +- src/components/Header.tsx | 13 +- src/components/SettingsView.tsx | 326 ++++++++++++++++++++++++++++++++ src/types.ts | 13 ++ 7 files changed, 555 insertions(+), 16 deletions(-) create mode 100644 src/components/SettingsView.tsx diff --git a/src/App.tsx b/src/App.tsx index f543d0f..52b65c8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,9 +13,10 @@ import CastView from './components/CastView'; import CastDetailView from './components/CastDetailView'; import AddMediaView from './components/AddMediaView'; import ImporterView from './components/ImporterView'; +import SettingsView from './components/SettingsView'; import { MOCK_MEDIA, DETAIL_MEDIA } from './data'; -import { Media, Staff, MediaCategory } from './types'; -import { fetchAllMedia, fetchMediaById, fetchCastById, convertApiCastToStaff } from './api'; +import { Media, Staff, MediaCategory, UserSettings } from './types'; +import { fetchAllMedia, fetchMediaById, fetchCastById, convertApiCastToStaff, fetchSettings, updateSettings } from './api'; function AppContent() { const navigate = useNavigate(); @@ -28,12 +29,41 @@ function AppContent() { const [selectedPerson, setSelectedPerson] = useState(null); const [searchQuery, setSearchQuery] = useState(searchParams.get('search') || ''); const [enabledCategories, setEnabledCategories] = useState(['Anime', 'Movies', 'TV Series', 'Music', 'Books', 'Consoles', 'Games', 'Adult']); + const [settings, setSettings] = useState(null); const [customMedia, setCustomMedia] = useState([]); const [adultMedia, setAdultMedia] = useState([]); // Load media from API on component mount (only when not on cast routes) const [apiMedia, setApiMedia] = useState([]); + useEffect(() => { + const loadSettingsFromApi = async () => { + try { + const loadedSettings = await fetchSettings(); + if (loadedSettings) { + setSettings(loadedSettings); + setEnabledCategories(loadedSettings.enabledCategories); + } + } catch (error) { + console.error('Failed to load settings from API:', error); + } + }; + + loadSettingsFromApi(); + }, []); + + const reloadSettings = async () => { + try { + const loadedSettings = await fetchSettings(); + if (loadedSettings) { + setSettings(loadedSettings); + setEnabledCategories(loadedSettings.enabledCategories); + } + } catch (error) { + console.error('Failed to reload settings from API:', error); + } + }; + useEffect(() => { const loadMediaFromApi = async () => { try { @@ -50,7 +80,7 @@ function AppContent() { } }, [location.pathname]); - const toggleCategory = (category: MediaCategory) => { + const toggleCategory = async (category: MediaCategory) => { setEnabledCategories(prev => { const isEnabling = !prev.includes(category); const newList = isEnabling @@ -62,6 +92,27 @@ function AppContent() { const nextCategory = newList.find(c => c !== category) || 'Anime'; setActiveCategory(nextCategory as MediaCategory); } + + // Save to API + const baseSettings = settings || { + enabledCategories: prev, + itemsPerPage: 20, + defaultView: 'grid', + showAdultContent: false, + autoPlayTrailers: false, + language: 'en', + theme: 'system', + }; + const updatedSettings: UserSettings = { + ...baseSettings, + enabledCategories: newList, + }; + updateSettings(updatedSettings).then(saved => { + if (saved) { + setSettings(saved); + } + }); + return newList; }); }; @@ -237,6 +288,7 @@ function AppContent() { mediaList={filteredMedia} onMediaClick={handleMediaClick} activeCategory={activeCategory} + itemsPerPage={settings?.itemsPerPage} /> } /> } /> } /> + + } /> diff --git a/src/api.ts b/src/api.ts index 296d2f9..6574171 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,4 +1,4 @@ -import { Media, Staff } from './types'; +import { Media, Staff, UserSettings, MediaCategory } from './types'; const BASE_URL = 'http://192.168.1.102:6400'; @@ -625,3 +625,144 @@ export async function fetchMediaFromApi(apiUrl?: string): Promise { export async function fetchMediaFromLocalJson(): Promise { return fetchAllMedia(); } + +// Settings API Types +export interface ApiSettingsItem { + id?: number; + enabled_categories: string[]; + items_per_page: number; + default_view: string; + show_adult_content: boolean; + auto_play_trailers: boolean; + language: string; + theme: string; + created_at?: string; + updated_at?: string; +} + +export interface CreateSettingsInput { + enabled_categories: string[]; + items_per_page?: number; + default_view?: string; + show_adult_content?: boolean; + auto_play_trailers?: boolean; + language?: string; + theme?: string; +} + +export interface UpdateSettingsInput extends Partial {} + +export function convertApiToSettings(apiItem: ApiSettingsItem): UserSettings { + return { + id: apiItem.id, + enabledCategories: apiItem.enabled_categories as MediaCategory[], + itemsPerPage: apiItem.items_per_page || 20, + defaultView: (apiItem.default_view as 'grid' | 'list') || 'grid', + showAdultContent: apiItem.show_adult_content || false, + autoPlayTrailers: apiItem.auto_play_trailers || false, + language: apiItem.language || 'en', + theme: (apiItem.theme as 'light' | 'dark' | 'system') || 'system', + createdAt: apiItem.created_at, + updatedAt: apiItem.updated_at, + }; +} + +export function convertSettingsToApi(settings: UserSettings): CreateSettingsInput { + return { + enabled_categories: settings.enabledCategories, + items_per_page: settings.itemsPerPage, + default_view: settings.defaultView, + show_adult_content: settings.showAdultContent, + auto_play_trailers: settings.autoPlayTrailers, + language: settings.language, + theme: settings.theme, + }; +} + +// Settings API Functions +export async function fetchSettings(): Promise { + try { + const response = await fetch(`${BASE_URL}/api/settings`); + if (!response.ok) { + // If settings don't exist (404), return null to use defaults + if (response.status === 404) { + return null; + } + throw new Error(`HTTP error! status: ${response.status}`); + } + const data: ApiResponse = await response.json(); + + if (data.success && data.data) { + return convertApiToSettings(data.data); + } + return null; + } catch (error) { + console.error('Error fetching settings:', error); + return null; + } +} + +export async function createSettings(settings: UserSettings): Promise { + try { + const apiSettings = convertSettingsToApi(settings); + console.log('Creating settings:', apiSettings); + const response = await fetch(`${BASE_URL}/api/settings`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(apiSettings), + }); + console.log('Create settings response status:', response.status); + if (!response.ok) { + const errorText = await response.text(); + console.error('Create settings error response:', errorText); + throw new Error(`HTTP error! status: ${response.status}`); + } + const data: ApiResponse = await response.json(); + console.log('Create settings response:', data); + + if (data.success && data.data) { + return convertApiToSettings(data.data); + } + return null; + } catch (error) { + console.error('Error creating settings:', error); + return null; + } +} + +export async function updateSettings(settings: UserSettings): Promise { + try { + const apiSettings = convertSettingsToApi(settings); + console.log('Updating settings:', apiSettings); + const response = await fetch(`${BASE_URL}/api/settings`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(apiSettings), + }); + console.log('Update settings response status:', response.status); + if (!response.ok) { + // If settings don't exist (404), try creating them instead + if (response.status === 404) { + console.log('Settings not found, attempting to create...'); + return createSettings(settings); + } + const errorText = await response.text(); + console.error('Update settings error response:', errorText); + throw new Error(`HTTP error! status: ${response.status}`); + } + const data: ApiResponse = await response.json(); + console.log('Update settings response:', data); + + if (data.success && data.data) { + return convertApiToSettings(data.data); + } + return null; + } catch (error) { + console.error('Error updating settings:', error); + return null; + } +} diff --git a/src/components/BrowseView.tsx b/src/components/BrowseView.tsx index a835fe3..78d1d11 100644 --- a/src/components/BrowseView.tsx +++ b/src/components/BrowseView.tsx @@ -17,12 +17,13 @@ interface BrowseViewProps { mediaList: Media[]; onMediaClick: (media: Media) => void; activeCategory: MediaCategory; + itemsPerPage?: number; } -export default function BrowseView({ mediaList, onMediaClick, activeCategory }: BrowseViewProps) { +export default function BrowseView({ mediaList, onMediaClick, activeCategory, itemsPerPage: initialItemsPerPage = 12 }: BrowseViewProps) { const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid'); const [currentPage, setCurrentPage] = useState(1); - const [itemsPerPage, setItemsPerPage] = useState(12); + const [itemsPerPage, setItemsPerPage] = useState(initialItemsPerPage); const [sortBy, setSortBy] = useState('default'); // Filter states @@ -281,7 +282,7 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory }: }} className="bg-zinc-100 border-none rounded-md px-2 py-1 text-sm font-bold text-zinc-700 focus:ring-2 focus:ring-[#6d28d9] outline-none" > - {[8, 12, 16, 24, 48].map(size => ( + {[12, 20, 36, 48, 60].map(size => ( ))} diff --git a/src/components/CastView.tsx b/src/components/CastView.tsx index 39a44cc..1662de6 100644 --- a/src/components/CastView.tsx +++ b/src/components/CastView.tsx @@ -12,9 +12,10 @@ import { fetchAllCast } from '@/api'; interface CastViewProps { onPersonClick: (person: Staff) => void; enabledCategories: MediaCategory[]; + itemsPerPage?: number; } -export default function CastView({ onPersonClick, enabledCategories }: CastViewProps) { +export default function CastView({ onPersonClick, enabledCategories, itemsPerPage: initialItemsPerPage = 12 }: CastViewProps) { const navigate = useNavigate(); const [staffList, setStaffList] = useState([]); const [loading, setLoading] = useState(true); @@ -34,7 +35,7 @@ export default function CastView({ onPersonClick, enabledCategories }: CastViewP return localStorage.getItem('castFilterMediaType') || ''; }); const [currentPage, setCurrentPage] = useState(1); - const [itemsPerPage, setItemsPerPage] = useState(12); + const [itemsPerPage, setItemsPerPage] = useState(initialItemsPerPage); const [showFilters, setShowFilters] = useState(false); // Persist filters and sorts @@ -382,7 +383,7 @@ export default function CastView({ onPersonClick, enabledCategories }: CastViewP }} className="bg-zinc-100 border-none rounded-md px-2 py-1 text-sm font-bold text-zinc-700 focus:ring-2 focus:ring-[#6d28d9] outline-none" > - {[8, 12, 16, 24, 48].map(size => ( + {[12, 20, 36, 48, 60].map(size => ( ))} diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 15c6bd2..bdca39b 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,9 +1,8 @@ -import { Search, User, X, Plus, Download } from 'lucide-react'; +import { Search, User, X, Plus, Download, Settings } from 'lucide-react'; import { cn } from '@/lib/utils'; import React, { useState } from 'react'; import { Link, NavLink } from 'react-router-dom'; import { MediaCategory } from '@/types'; -import LibrarySettings from './LibrarySettings'; interface HeaderProps { onSearch: (query: string) => void; @@ -113,10 +112,12 @@ export default function Header({ > - + + + + + + {saveStatus === 'success' && ( +
+ Settings saved successfully! +
+ )} + {saveStatus === 'error' && ( +
+ Failed to save settings. Please try again. +
+ )} + +
+ {/* Library Settings */} +
+

Library Settings

+
+

+ Toggle which media areas you want to see in your library. +

+
+ {(['Anime', 'Movies', 'Music', 'Books', 'Consoles', 'Games', 'Adult'] as MediaCategory[]).map((category) => ( +
+
+
+ {CATEGORY_ICONS[category]} +
+
+ +

+ {settings.enabledCategories.includes(category) ? 'Enabled' : 'Disabled'} +

+
+
+ toggleCategory(category)} + /> +
+ ))} +
+
+
+ + {/* Display Settings */} +
+

Display Settings

+
+ {/* Items per page */} +
+ +
+ {ITEMS_PER_PAGE_OPTIONS.map((option) => ( + + ))} +
+
+ + {/* Default view */} +
+ +
+ + +
+
+ + {/* Theme */} +
+ +
+ {(['light', 'dark', 'system'] as const).map((theme) => ( + + ))} +
+
+
+
+ + {/* Content Settings */} +
+

Content Settings

+
+ {/* Show adult content */} +
+
+ +

+ Display adult media in your library +

+
+ setSettings(prev => ({ ...prev, showAdultContent: checked }))} + /> +
+ + {/* Auto-play trailers */} +
+
+ +

+ Automatically play trailers when viewing media +

+
+ setSettings(prev => ({ ...prev, autoPlayTrailers: checked }))} + /> +
+
+
+ + {/* Language Settings */} +
+

Language

+
+
+ + +
+
+ {LANGUAGE_OPTIONS.map((option) => ( + + ))} +
+
+
+
+ + + ); +} diff --git a/src/types.ts b/src/types.ts index 305a44d..7a7b646 100644 --- a/src/types.ts +++ b/src/types.ts @@ -95,3 +95,16 @@ export interface AdultSpecifics { measurements?: string | null; shoe_size?: number | null; } + +export interface UserSettings { + id?: number; + enabledCategories: MediaCategory[]; + itemsPerPage: number; + defaultView: 'grid' | 'list'; + showAdultContent: boolean; + autoPlayTrailers: boolean; + language: string; + theme: 'light' | 'dark' | 'system'; + createdAt?: string; + updatedAt?: string; +}