first commit
This commit is contained in:
235
src/App.tsx
Normal file
235
src/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user