Files
mystuff_frontend/src/App.tsx
Lars Behrends 04156486e2 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.
2026-04-10 14:14:27 +02:00

435 lines
14 KiB
TypeScript

/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useMemo, useEffect } from 'react';
import { LayoutGroup } from 'motion/react';
import { BrowserRouter, Routes, Route, useNavigate, useSearchParams, useParams, useLocation } from 'react-router-dom';
import Header from './components/Header';
import BrowseView from './components/BrowseView';
import DetailView from './components/DetailView';
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, UserSettings } from './types';
import { fetchAllMedia, fetchMediaById, fetchCastById, convertApiCastToStaff, fetchSettings, updateSettings } from './api';
function AppContent() {
const navigate = useNavigate();
const location = useLocation();
const [searchParams, setSearchParams] = useSearchParams();
const [activeCategory, setActiveCategory] = useState<MediaCategory>(
(searchParams.get('category') as MediaCategory) || 'Anime'
);
const [selectedMedia, setSelectedMedia] = useState<Media | null>(null);
const [selectedPerson, setSelectedPerson] = useState<Staff | null>(null);
const [searchQuery, setSearchQuery] = useState(searchParams.get('search') || '');
const [enabledCategories, setEnabledCategories] = useState<MediaCategory[]>(['Anime', 'Movies', 'TV Series', 'Music', 'Books', 'Consoles', 'Games', 'Adult']);
const [settings, setSettings] = useState<UserSettings | null>(null);
const [customMedia, setCustomMedia] = useState<Media[]>([]);
const [adultMedia, setAdultMedia] = useState<Media[]>([]);
// Load media from API on component mount (only when not on cast routes)
const [apiMedia, setApiMedia] = useState<Media[]>([]);
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 {
const media = await fetchAllMedia();
setApiMedia(media);
} catch (error) {
console.error('Failed to load media from API:', error);
}
};
// Only load media if not on cast routes
if (!location.pathname.startsWith('/cast')) {
loadMediaFromApi();
}
}, [location.pathname]);
const toggleCategory = async (category: MediaCategory) => {
setEnabledCategories(prev => {
const isEnabling = !prev.includes(category);
const newList = isEnabling
? [...prev, category]
: prev.filter(c => c !== category);
// If we disable the current active category, switch to another enabled one
if (!isEnabling && activeCategory === category) {
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;
});
};
const handleCategoryChange = (category: MediaCategory) => {
setActiveCategory(category);
setSearchParams({ category });
navigate('/');
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const handleAddMediaView = () => {
navigate('/add');
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const handleImporterView = () => {
navigate('/import');
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const allMedia = useMemo(() => {
// Use API data if available, otherwise fall back to mock data
let list: Media[] = [];
if (apiMedia.length > 0) {
// API has data, use it
list = [...apiMedia];
} else {
// API is empty, use mock data as fallback
list = [...MOCK_MEDIA];
}
// Add custom media and detail media
list = [...list, ...customMedia];
if (!list.find(m => m.id === DETAIL_MEDIA.id)) {
list.push(DETAIL_MEDIA);
}
// Filter by active category AND ensure it's enabled
return list.filter(m => m.category === activeCategory && enabledCategories.includes(m.category));
}, [activeCategory, enabledCategories, customMedia, apiMedia]);
const handleAddMedia = async () => {
// Reload all media from API to get the newly added item
try {
const media = await fetchAllMedia();
setApiMedia(media);
} catch (error) {
console.error('Failed to reload media from API:', error);
}
};
const allStaff = useMemo(() => {
const staff: Staff[] = [];
// Use API data if available, otherwise fall back to mock data
let baseList: Media[] = [];
if (apiMedia.length > 0) {
// API has data, use it
baseList = [...apiMedia];
} else {
// API is empty, use mock data as fallback
baseList = [...MOCK_MEDIA];
}
// Add custom media and detail media
baseList = [...baseList, ...customMedia];
if (!baseList.find(m => m.id === DETAIL_MEDIA.id)) {
baseList.push(DETAIL_MEDIA);
}
const enabledMedia = baseList.filter(m => enabledCategories.includes(m.category));
enabledMedia.forEach(media => {
media.staff?.forEach(s => {
staff.push({
...s,
mediaId: media.id,
mediaTitle: media.title
});
});
});
return staff;
}, [enabledCategories, customMedia, apiMedia]);
const filteredMedia = useMemo(() => {
if (!searchQuery.trim()) return allMedia;
const query = searchQuery.toLowerCase();
return allMedia.filter(media =>
media.title.toLowerCase().includes(query) ||
media.year.toLowerCase().includes(query) ||
media.genres?.some(g => g.toLowerCase().includes(query)) ||
media.studios?.some(s => s.toLowerCase().includes(query))
);
}, [allMedia, searchQuery]);
const handleMediaClick = async (media: Media) => {
// For adult media, try to fetch detailed data by ID
if (media.category === 'Adult') {
try {
const detailedMedia = await fetchMediaById(parseInt(media.id));
if (detailedMedia) {
setSelectedMedia(detailedMedia);
} else {
// Fallback to original media if detailed fetch fails
setSelectedMedia(media);
}
} catch (error) {
console.error('Failed to fetch detailed media:', error);
setSelectedMedia(media);
}
} else {
// For non-adult media, use the original media
setSelectedMedia(media);
}
navigate(`/media/${media.id}`);
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const handleBack = () => {
navigate('/');
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const handleCastClick = () => {
navigate('/cast');
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const handlePersonClick = (person: Staff) => {
// Enrich person with some mock data for the detail page
const enrichedPerson: Staff = {
...person,
bio: `${person.name} is a renowned ${person.role} with a career spanning over a decade. Known for their versatility and emotional depth, they have become a staple in the industry, particularly for their work in ${person.mediaTitle || 'major productions'}.`,
birthDate: 'October 14, 1985',
birthPlace: 'Tokyo, Japan',
occupations: ['Voice Actor', 'Singer', 'Narrator']
};
setSelectedPerson(enrichedPerson);
navigate(`/cast/${person.id}`);
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const handleSearch = (query: string) => {
setSearchQuery(query);
const params = new URLSearchParams(searchParams);
if (query) {
params.set('search', query);
} else {
params.delete('search');
}
setSearchParams(params);
navigate('/');
};
return (
<div className="min-h-screen bg-white font-sans selection:bg-[#6d28d9]/20 selection:text-[#6d28d9]">
<Header
onSearch={handleSearch}
activeCategory={activeCategory}
onCategoryChange={handleCategoryChange}
enabledCategories={enabledCategories}
onToggleCategory={toggleCategory}
transparent={location.pathname.startsWith('/media/') || location.pathname.startsWith('/cast/')}
/>
<main>
<LayoutGroup>
<Routes>
<Route path="/" element={
<BrowseView
mediaList={filteredMedia}
onMediaClick={handleMediaClick}
activeCategory={activeCategory}
itemsPerPage={settings?.itemsPerPage}
/>
} />
<Route path="/media/:id" element={
<MediaDetailRoute
selectedMedia={selectedMedia}
setSelectedMedia={setSelectedMedia}
allMedia={allMedia}
onPersonClick={handlePersonClick}
/>
} />
<Route path="/cast" element={
<CastView
onPersonClick={handlePersonClick}
enabledCategories={enabledCategories}
itemsPerPage={settings?.itemsPerPage}
/>
} />
<Route path="/cast/:id" element={
<CastDetailRoute
selectedPerson={selectedPerson}
setSelectedPerson={setSelectedPerson}
/>
} />
<Route path="/add" element={
<AddMediaView
activeCategory={activeCategory}
onAddComplete={handleAddMedia}
/>
} />
<Route path="/import" element={
<ImporterView />
} />
<Route path="/settings" element={
<SettingsView onSettingsSaved={reloadSettings} />
} />
</Routes>
</LayoutGroup>
</main>
{/* Footer */}
<footer className="py-12 px-6 border-t border-zinc-100 bg-zinc-50">
<div className="max-w-[1600px] mx-auto flex flex-col md:flex-row items-center justify-between gap-6">
<div className="flex items-center gap-2 text-xl font-black text-zinc-400">
<div className="w-5 h-5 bg-zinc-300 rounded-full" />
kyoo
</div>
<div className="flex items-center gap-8 text-sm font-bold text-zinc-400">
<a href="#" className="hover:text-[#6d28d9] transition-colors">Terms</a>
<a href="#" className="hover:text-[#6d28d9] transition-colors">Privacy</a>
<a href="#" className="hover:text-[#6d28d9] transition-colors">Contact</a>
</div>
<p className="text-xs font-medium text-zinc-400">
© 2026 Kyoo Media Discovery. All rights reserved.
</p>
</div>
</footer>
</div>
);
}
// Helper component for media detail route
function MediaDetailRoute({ selectedMedia, setSelectedMedia, allMedia, onPersonClick }: any) {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
useEffect(() => {
const loadMedia = async () => {
if (id) {
// First check if media is in allMedia
const media = allMedia.find(m => m.id === id);
if (media) {
setSelectedMedia(media);
} else {
// If not found, fetch from API
try {
const fetchedMedia = await fetchMediaById(id);
if (fetchedMedia) {
setSelectedMedia(fetchedMedia);
} else {
navigate('/');
}
} catch (error) {
console.error('Failed to fetch media:', error);
navigate('/');
}
}
}
};
loadMedia();
}, [id, allMedia]);
if (!selectedMedia) return null;
return (
<DetailView
media={selectedMedia}
onPersonClick={onPersonClick}
/>
);
}
// Helper component for cast detail route
function CastDetailRoute({ selectedPerson, setSelectedPerson }: any) {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
useEffect(() => {
const loadCast = async () => {
if (id) {
try {
const castData = await fetchCastById(id);
if (castData) {
const person = convertApiCastToStaff(castData);
setSelectedPerson(person);
} else {
navigate('/cast');
}
} catch (error) {
console.error('Failed to load cast:', error);
navigate('/cast');
}
}
};
loadCast();
}, [id]);
if (!selectedPerson) return null;
return (
<CastDetailView
person={selectedPerson}
relatedMedia={[]}
/>
);
}
export default function App() {
return (
<BrowserRouter>
<AppContent />
</BrowserRouter>
);
}