first commit

This commit is contained in:
Lars Behrends
2026-04-09 10:29:11 +02:00
commit dda118a2f7
36 changed files with 14470 additions and 0 deletions

235
src/App.tsx Normal file
View File

@@ -0,0 +1,235 @@
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useMemo, useEffect } from 'react';
import { LayoutGroup } from 'motion/react';
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 { MOCK_MEDIA, DETAIL_MEDIA } from './data';
import { Media, Staff, MediaCategory } from './types';
import { fetchMediaFromLocalJson, fetchMediaById } from './api';
export default function App() {
const [currentView, setCurrentView] = useState<'browse' | 'detail' | 'cast' | 'castDetail'>('browse');
const [activeCategory, setActiveCategory] = useState<MediaCategory>('Anime');
const [selectedMedia, setSelectedMedia] = useState<Media | null>(null);
const [selectedPerson, setSelectedPerson] = useState<Staff | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [enabledCategories, setEnabledCategories] = useState<MediaCategory[]>(['Anime', 'Movies', 'Music', 'Books', 'Consoles', 'Games', 'Adult']);
const [customMedia, setCustomMedia] = useState<Media[]>([]);
const [adultMedia, setAdultMedia] = useState<Media[]>([]);
// Load adult media on component mount
useEffect(() => {
const loadAdultMedia = async () => {
try {
const media = await fetchMediaFromLocalJson();
// Add category to adult media
const categorizedMedia = media.map(m => ({ ...m, category: 'Adult' as MediaCategory }));
setAdultMedia(categorizedMedia);
} catch (error) {
console.error('Failed to load adult media:', error);
}
};
loadAdultMedia();
}, []);
const toggleCategory = (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);
}
return newList;
});
};
const handleCategoryChange = (category: MediaCategory) => {
setActiveCategory(category);
setCurrentView('browse');
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const allMedia = useMemo(() => {
// Merge mock media, adult media, detail media and custom media
const list = [...MOCK_MEDIA, ...adultMedia, ...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, adultMedia]);
const handleAddMedia = (newMedia: Media) => {
setCustomMedia(prev => [...prev, newMedia]);
};
const allStaff = useMemo(() => {
const staff: Staff[] = [];
// Use all available media (mock + adult + custom + detail) but filter by enabled categories
const baseList = [...MOCK_MEDIA, ...adultMedia, ...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, adultMedia]);
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);
}
setCurrentView('detail');
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const handleBack = () => {
setCurrentView('browse');
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const handleCastClick = () => {
setCurrentView('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);
setCurrentView('castDetail');
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const handleSearch = (query: string) => {
setSearchQuery(query);
if (currentView !== 'browse' && currentView !== 'cast') {
setCurrentView('browse');
}
};
return (
<div className="min-h-screen bg-white font-sans selection:bg-[#6d28d9]/20 selection:text-[#6d28d9]">
<Header
onBrowse={handleBack}
onCast={handleCastClick}
onSearch={handleSearch}
activeCategory={activeCategory}
onCategoryChange={handleCategoryChange}
enabledCategories={enabledCategories}
onToggleCategory={toggleCategory}
transparent={currentView === 'detail' || currentView === 'castDetail'}
/>
<main>
<LayoutGroup>
{currentView === 'browse' ? (
<BrowseView
mediaList={filteredMedia}
onMediaClick={handleMediaClick}
onAddMedia={handleAddMedia}
activeCategory={activeCategory}
/>
) : currentView === 'cast' ? (
<CastView
staffList={allStaff}
onPersonClick={handlePersonClick}
/>
) : currentView === 'castDetail' ? (
selectedPerson && (
<CastDetailView
person={selectedPerson}
onBack={handleCastClick}
onMediaClick={(id) => {
const media = allMedia.find(m => m.id === id);
if (media) handleMediaClick(media);
}}
relatedMedia={allMedia.filter(m => m.staff?.some(s => s.id === selectedPerson.id))}
/>
)
) : (
selectedMedia && (
<DetailView
media={selectedMedia}
onBack={handleBack}
onPersonClick={handlePersonClick}
/>
)
)}
</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>
);
}