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.
435 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|