This commit is contained in:
Lars Behrends
2026-04-09 12:46:32 +02:00
parent dda118a2f7
commit d6a0aac5f7
29 changed files with 1182 additions and 4005 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,23 @@
{
"name": "Jane Smith",
"photo": "https://example.com/jane-smith.jpg",
"bio": "Adult film actress and model",
"birthDate": "1998-03-20",
"birthPlace": "Miami, Florida",
"occupations": ["Actress", "Model"],
"adult_specifics": {
"bust_size": "36",
"cup_size": "DD",
"waist_size": "26",
"hip_size": "38",
"height": "170",
"weight": "58",
"hair_color": "Brunette",
"eye_color": "Green",
"ethnicity": "Latina",
"tattoos": "Lower back",
"piercings": "None",
"measurements": "36-26-38",
"shoe_size": "8"
}
}

View File

@@ -0,0 +1,28 @@
{
"title": "Thriller",
"year": 1982,
"poster": "https://example.com/thriller-cover.jpg",
"description": "Sixth studio album by Michael Jackson",
"rating": 9.0,
"category": "Music",
"type": "Album",
"status": "Released",
"genres": ["Pop", "Funk", "Rock"],
"tags": ["Classic", "Best-selling"],
"studios": ["Epic Records"],
"staff": [],
"tracks": [
{
"track_number": 1,
"title": "Wanna Be Startin' Somethin'",
"duration": "6:03",
"artist": "Michael Jackson"
},
{
"track_number": 2,
"title": "Baby Be Mine",
"duration": "4:20",
"artist": "Michael Jackson"
}
]
}

View File

@@ -0,0 +1,8 @@
{
"name": "Tom Hardy",
"photo": "https://example.com/tom.jpg",
"bio": "English actor known for versatile roles",
"birthDate": "1977-09-15",
"birthPlace": "Hammersmith, London, England",
"occupations": ["Actor", "Producer", "Writer"]
}

View File

@@ -0,0 +1,9 @@
{
"season": 1,
"episode_number": 3,
"title": "...And the Bag's in the River",
"description": "Walter and Jesse deal with the aftermath.",
"air_date": "2008-02-03",
"duration": 47,
"thumbnail": "https://example.com/ep3.jpg"
}

View File

@@ -0,0 +1,32 @@
{
"title": "The Matrix",
"year": 1999,
"poster": "https://example.com/matrix-poster.jpg",
"banner": "https://example.com/matrix-banner.jpg",
"description": "A computer hacker learns about the true nature of reality.",
"rating": 8.7,
"category": "Movie",
"type": "Movie",
"status": "Released",
"aspectRatio": "2.39:1",
"runtime": 136,
"director": "The Wachowskis",
"writer": "The Wachowskis",
"releaseDate": "1999-03-31",
"genres": ["Sci-Fi", "Action"],
"tags": ["Cyberpunk", "AI", "Simulation"],
"studios": ["Warner Bros."],
"staff": [
{
"name": "Keanu Reeves",
"photo": "https://example.com/keanu.jpg",
"bio": "Canadian actor",
"birthDate": "1964-09-02",
"birthPlace": "Beirut, Lebanon",
"role": "Actor",
"characterName": "Neo",
"characterImage": null,
"occupations": ["Actor"]
}
]
}

View File

@@ -0,0 +1,6 @@
{
"track_number": 3,
"title": "On the Run",
"duration": "3:35",
"artist": "Pink Floyd"
}

View File

@@ -0,0 +1,29 @@
{
"title": "Stranger Things",
"year": 2016,
"poster": "https://example.com/st-poster.jpg",
"description": "When a young boy disappears, his mother uncovers a mystery.",
"rating": 8.7,
"category": "TV",
"type": "TV",
"status": "Ongoing",
"runtime": 50,
"director": "The Duffer Brothers",
"writer": "The Duffer Brothers",
"releaseDate": "2016-07-15",
"genres": ["Sci-Fi", "Horror", "Drama"],
"tags": ["80s", "Supernatural", "Government Conspiracy"],
"studios": ["Netflix"],
"staff": [],
"episodes": [
{
"season": 1,
"episode_number": 1,
"title": "Chapter One: The Vanishing of Will Byers",
"description": "On his way home from a friend's house, young Will sees something terrifying.",
"air_date": "2016-07-15",
"duration": 47,
"thumbnail": "https://example.com/st-ep1.jpg"
}
]
}

View File

@@ -0,0 +1,30 @@
{
"success": true,
"data": {
"items": [
{
"id": 10,
"name": "Jane Doe",
"photo": "https://example.com/jane.jpg",
"bio": "Adult film actress",
"birthDate": "1995-05-15",
"birthPlace": "Los Angeles, California",
"createdAt": "2024-01-15 10:30:00",
"updatedAt": "2024-01-15 10:30:00",
"occupations": ["Actress"],
"bust_size": "34",
"cup_size": "D",
"waist_size": "24",
"hip_size": "34",
"height": "165",
"weight": "52",
"hair_color": "Blonde",
"eye_color": "Blue",
"ethnicity": "Caucasian"
}
],
"total": 25,
"page": 1,
"limit": 10
}
}

View File

@@ -0,0 +1,32 @@
{
"success": true,
"data": {
"id": 10,
"name": "Jane Doe",
"photo": "https://example.com/jane.jpg",
"bio": "Adult film actress",
"birthDate": "1995-05-15",
"birthPlace": "Los Angeles, California",
"createdAt": "2024-01-15 10:30:00",
"updatedAt": "2024-01-15 10:30:00",
"occupations": ["Actress"],
"filmography": [],
"adult_specifics": {
"id": 5,
"cast_id": 10,
"bust_size": "34",
"cup_size": "D",
"waist_size": "24",
"hip_size": "34",
"height": "165",
"weight": "52",
"hair_color": "Blonde",
"eye_color": "Blue",
"ethnicity": "Caucasian",
"tattoos": "None",
"piercings": "Ears",
"measurements": "34-24-34",
"shoe_size": "7"
}
}
}

View File

@@ -0,0 +1,20 @@
{
"success": true,
"data": {
"items": [
{
"id": 1,
"name": "Leonardo DiCaprio",
"photo": "https://example.com/leo.jpg",
"bio": "American actor and film producer",
"birthDate": "1974-11-11",
"birthPlace": "Los Angeles, California",
"createdAt": "2024-01-15 10:30:00",
"updatedAt": "2024-01-15 10:30:00"
}
],
"total": 5,
"page": 1,
"limit": 10
}
}

View File

@@ -0,0 +1,27 @@
{
"success": true,
"data": {
"items": [
{
"id": 1,
"title": "Inception",
"year": 2010,
"poster": "https://example.com/poster.jpg",
"category": "Movie",
"type": "Movie",
"role": "Actor",
"characterName": "Dom Cobb"
},
{
"id": 2,
"title": "The Revenant",
"year": 2015,
"poster": "https://example.com/revenant.jpg",
"category": "Movie",
"type": "Movie",
"role": "Actor",
"characterName": "Hugh Glass"
}
]
}
}

View File

@@ -0,0 +1,36 @@
{
"success": true,
"data": {
"id": 1,
"name": "Leonardo DiCaprio",
"photo": "https://example.com/leo.jpg",
"bio": "American actor and film producer",
"birthDate": "1974-11-11",
"birthPlace": "Los Angeles, California",
"createdAt": "2024-01-15 10:30:00",
"updatedAt": "2024-01-15 10:30:00",
"occupations": ["Actor", "Producer"],
"filmography": [
{
"id": 1,
"title": "Inception",
"year": 2010,
"poster": "https://example.com/poster.jpg",
"category": "Movie",
"type": "Movie",
"role": "Actor",
"characterName": "Dom Cobb"
},
{
"id": 2,
"title": "The Revenant",
"year": 2015,
"poster": "https://example.com/revenant.jpg",
"category": "Movie",
"type": "Movie",
"role": "Actor",
"characterName": "Hugh Glass"
}
]
}
}

View File

@@ -0,0 +1,29 @@
{
"success": true,
"data": {
"items": [
{
"id": 1,
"media_id": 2,
"season": 1,
"episode_number": 1,
"title": "Pilot",
"description": "Walter White is diagnosed with lung cancer.",
"air_date": "2008-01-20",
"duration": 49,
"thumbnail": "https://example.com/ep1.jpg"
},
{
"id": 2,
"media_id": 2,
"season": 1,
"episode_number": 2,
"title": "Cat's in the Bag...",
"description": "Walter and Jesse attempt to dispose of the body.",
"air_date": "2008-01-27",
"duration": 48,
"thumbnail": "https://example.com/ep2.jpg"
}
]
}
}

View File

@@ -0,0 +1,30 @@
{
"success": true,
"data": {
"items": [
{
"id": 1,
"title": "Inception",
"year": 2010,
"poster": "https://example.com/poster.jpg",
"banner": null,
"description": "A thief who steals corporate secrets through dream-sharing technology.",
"rating": 8.8,
"category": "Movie",
"type": "Movie",
"status": "Released",
"aspectRatio": "2.39:1",
"runtime": 148,
"director": "Christopher Nolan",
"writer": "Christopher Nolan",
"releaseDate": "2010-07-16",
"createdAt": "2024-01-15 10:30:00",
"updatedAt": "2024-01-15 10:30:00"
}
],
"total": 150,
"page": 1,
"limit": 10,
"totalPages": 15
}
}

View File

@@ -0,0 +1,39 @@
{
"success": true,
"data": {
"id": 1,
"title": "Inception",
"year": 2010,
"poster": "https://example.com/poster.jpg",
"banner": null,
"description": "A thief who steals corporate secrets through dream-sharing technology.",
"rating": 8.8,
"category": "Movie",
"type": "Movie",
"status": "Released",
"aspectRatio": "2.39:1",
"runtime": 148,
"director": "Christopher Nolan",
"writer": "Christopher Nolan",
"releaseDate": "2010-07-16",
"createdAt": "2024-01-15 10:30:00",
"updatedAt": "2024-01-15 10:30:00",
"genres": ["Sci-Fi", "Action", "Thriller"],
"tags": ["Mind-bending", "Dream", "Heist"],
"studios": ["Warner Bros.", "Legendary Pictures"],
"staff": [
{
"id": 1,
"name": "Leonardo DiCaprio",
"photo": "https://example.com/leo.jpg",
"bio": "American actor and film producer",
"birthDate": "1974-11-11",
"birthPlace": "Los Angeles, California",
"role": "Actor",
"characterName": "Dom Cobb",
"characterImage": null,
"occupations": ["Actor", "Producer"]
}
]
}
}

View File

@@ -0,0 +1,23 @@
{
"success": true,
"data": {
"items": [
{
"id": 1,
"media_id": 3,
"track_number": 1,
"title": "Speak to Me",
"duration": "1:30",
"artist": "Pink Floyd"
},
{
"id": 2,
"media_id": 3,
"track_number": 2,
"title": "Breathe",
"duration": "2:43",
"artist": "Pink Floyd"
}
]
}
}

View File

@@ -0,0 +1,8 @@
{
"name": "Jane Smith (Updated)",
"bio": "Updated bio",
"adult_specifics": {
"hair_color": "Red",
"weight": "56"
}
}

View File

@@ -0,0 +1,4 @@
{
"name": "Tom Hardy (Updated)",
"bio": "Updated bio description"
}

View File

@@ -0,0 +1,4 @@
{
"title": "Updated Episode Title",
"description": "Updated description"
}

View File

@@ -0,0 +1,5 @@
{
"title": "The Matrix (Updated)",
"rating": 8.8,
"status": "Released"
}

View File

@@ -0,0 +1,4 @@
{
"title": "Updated Track Title",
"duration": "4:00"
}

File diff suppressed because one or more lines are too long

View File

@@ -12,7 +12,7 @@ import CastView from './components/CastView';
import CastDetailView from './components/CastDetailView'; import CastDetailView from './components/CastDetailView';
import { MOCK_MEDIA, DETAIL_MEDIA } from './data'; import { MOCK_MEDIA, DETAIL_MEDIA } from './data';
import { Media, Staff, MediaCategory } from './types'; import { Media, Staff, MediaCategory } from './types';
import { fetchMediaFromLocalJson, fetchMediaById } from './api'; import { fetchAllMedia, fetchMediaById } from './api';
export default function App() { export default function App() {
const [currentView, setCurrentView] = useState<'browse' | 'detail' | 'cast' | 'castDetail'>('browse'); const [currentView, setCurrentView] = useState<'browse' | 'detail' | 'cast' | 'castDetail'>('browse');
@@ -24,19 +24,19 @@ export default function App() {
const [customMedia, setCustomMedia] = useState<Media[]>([]); const [customMedia, setCustomMedia] = useState<Media[]>([]);
const [adultMedia, setAdultMedia] = useState<Media[]>([]); const [adultMedia, setAdultMedia] = useState<Media[]>([]);
// Load adult media on component mount // Load media from API on component mount
const [apiMedia, setApiMedia] = useState<Media[]>([]);
useEffect(() => { useEffect(() => {
const loadAdultMedia = async () => { const loadMediaFromApi = async () => {
try { try {
const media = await fetchMediaFromLocalJson(); const media = await fetchAllMedia();
// Add category to adult media setApiMedia(media);
const categorizedMedia = media.map(m => ({ ...m, category: 'Adult' as MediaCategory }));
setAdultMedia(categorizedMedia);
} catch (error) { } catch (error) {
console.error('Failed to load adult media:', error); console.error('Failed to load media from API:', error);
} }
}; };
loadAdultMedia(); loadMediaFromApi();
}, []); }, []);
const toggleCategory = (category: MediaCategory) => { const toggleCategory = (category: MediaCategory) => {
@@ -62,23 +62,52 @@ export default function App() {
}; };
const allMedia = useMemo(() => { const allMedia = useMemo(() => {
// Merge mock media, adult media, detail media and custom media // Use API data if available, otherwise fall back to mock data
const list = [...MOCK_MEDIA, ...adultMedia, ...customMedia]; 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)) { if (!list.find(m => m.id === DETAIL_MEDIA.id)) {
list.push(DETAIL_MEDIA); list.push(DETAIL_MEDIA);
} }
// Filter by active category AND ensure it's enabled // Filter by active category AND ensure it's enabled
return list.filter(m => m.category === activeCategory && enabledCategories.includes(m.category)); return list.filter(m => m.category === activeCategory && enabledCategories.includes(m.category));
}, [activeCategory, enabledCategories, customMedia, adultMedia]); }, [activeCategory, enabledCategories, customMedia, apiMedia]);
const handleAddMedia = (newMedia: Media) => { const handleAddMedia = async (newMedia: Media) => {
setCustomMedia(prev => [...prev, newMedia]); // 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 allStaff = useMemo(() => {
const staff: Staff[] = []; const staff: Staff[] = [];
// Use all available media (mock + adult + custom + detail) but filter by enabled categories // Use API data if available, otherwise fall back to mock data
const baseList = [...MOCK_MEDIA, ...adultMedia, ...customMedia]; 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)) { if (!baseList.find(m => m.id === DETAIL_MEDIA.id)) {
baseList.push(DETAIL_MEDIA); baseList.push(DETAIL_MEDIA);
} }
@@ -95,7 +124,7 @@ export default function App() {
}); });
}); });
return staff; return staff;
}, [enabledCategories, customMedia, adultMedia]); }, [enabledCategories, customMedia, apiMedia]);
const filteredMedia = useMemo(() => { const filteredMedia = useMemo(() => {
if (!searchQuery.trim()) return allMedia; if (!searchQuery.trim()) return allMedia;

View File

@@ -1,6 +1,6 @@
import { Media, Staff } from './types'; import { Media, Staff } from './types';
const BASE_URL = 'http://192.168.1.102:57000'; const BASE_URL = 'http://192.168.1.102:6400';
function normalizeUrl(url: string | null): string { function normalizeUrl(url: string | null): string {
if (!url) return ''; if (!url) return '';
@@ -12,137 +12,244 @@ function normalizeUrl(url: string | null): string {
return `${BASE_URL}/${cleanPath}`; return `${BASE_URL}/${cleanPath}`;
} }
export interface ApiResponse { // API Response Types
export interface ApiResponse<T> {
success: boolean; success: boolean;
data: { data: T;
items: ApiMediaItem[];
};
} }
export interface PaginatedResponse<T> {
items: T[];
total: number;
page: number;
limit: number;
totalPages?: number;
}
// Media Types
export interface ApiMediaItem { export interface ApiMediaItem {
id: number; id: number;
title: string; title: string;
overview: string; year: number;
poster_url: string; poster: string | null;
poster_aspect_ratio: string | null; banner: string | null;
backdrop_url: string | null; description: string | null;
backdrop_aspect_ratio: string | null; rating: number | null;
rating: string; category: string | null;
runtime_minutes: number; type: string;
release_date: string; status: string;
aspectRatio: string | null;
runtime: number | null;
director: string | null; director: string | null;
writer: string | null; writer: string | null;
cast: string | null; releaseDate: string | null;
genre: string | null; createdAt: string;
metadata: string; updatedAt: string;
actors?: Array<{ genres?: string[];
id: number; tags?: string[];
name: string; studios?: string[];
thumbnail_path: string | null; staff?: ApiStaff[];
metadata?: string;
created_at?: string;
updated_at?: string;
}>;
} }
export interface ApiMetadata { export interface ApiStaff {
xbvr_id: number;
xbvr_url: string | null;
cast: string[];
actors: Array<{
id: number; id: number;
name: string; name: string;
thumbnail_path: string | null; photo: string | null;
}>; bio: string | null;
tags: string[]; birthDate: string | null;
is_available: boolean; birthPlace: string | null;
is_watched: boolean; role: string;
watch_count: number; characterName: string | null;
video_length: number; characterImage: string | null;
video_width: number | null; occupations?: string[];
video_height: number | null;
video_codec: string | null;
file_path: string | null;
cover_url: string;
[key: string]: any;
} }
export interface CreateMediaInput {
title: string;
year: number;
poster?: string | null;
banner?: string | null;
description?: string | null;
rating?: number | null;
category?: string | null;
type?: string;
status?: string;
aspectRatio?: string | null;
runtime?: number | null;
director?: string | null;
writer?: string | null;
releaseDate?: string | null;
genres?: string[];
tags?: string[];
studios?: string[];
staff?: CreateStaffInput[];
}
export interface UpdateMediaInput extends Partial<CreateMediaInput> {}
export interface CreateStaffInput {
name: string;
photo?: string | null;
bio?: string | null;
birthDate?: string | null;
birthPlace?: string | null;
role: string;
characterName?: string | null;
characterImage?: string | null;
occupations?: string[];
}
// Cast Types
export interface ApiCastItem {
id: number;
name: string;
photo: string | null;
bio: string | null;
birthDate: string | null;
birthPlace: string | null;
createdAt: string;
updatedAt: string;
occupations?: string[];
filmography?: ApiCastMediaItem[];
}
export interface ApiCastMediaItem {
id: number;
title: string;
year: number;
poster: string | null;
category: string | null;
type: string;
role: string;
characterName: string | null;
}
export interface CreateCastInput {
name: string;
photo?: string | null;
bio?: string | null;
birthDate?: string | null;
birthPlace?: string | null;
occupations?: string[];
}
export interface UpdateCastInput extends Partial<CreateCastInput> {}
export function convertApiToMedia(apiItem: ApiMediaItem): Media { export function convertApiToMedia(apiItem: ApiMediaItem): Media {
let metadata: ApiMetadata; // Convert staff from API to Media staff format
try { const staff: Staff[] = (apiItem.staff || []).map((staffMember) => ({
metadata = JSON.parse(apiItem.metadata); id: staffMember.id.toString(),
} catch (e) { name: staffMember.name,
metadata = { role: staffMember.role,
xbvr_id: 0, photo: normalizeUrl(staffMember.photo) || `https://picsum.photos/seed/staff-${staffMember.id}/200/200`,
xbvr_url: null, characterName: staffMember.characterName || staffMember.name,
cast: [], characterImage: normalizeUrl(staffMember.characterImage) || normalizeUrl(staffMember.photo) || `https://picsum.photos/seed/staff-${staffMember.id}/200/200`,
actors: [],
tags: [],
is_available: false,
is_watched: false,
watch_count: 0,
video_length: 0,
video_width: null,
video_height: null,
video_codec: null,
file_path: null,
cover_url: apiItem.poster_url,
};
}
// Use actors from the main item if available, otherwise from metadata
const actors = apiItem.actors || metadata.actors || [];
const staff: Staff[] = actors.map((actor, index) => ({
id: `actor-${actor.id}`,
name: actor.name,
role: 'Actor',
photo: normalizeUrl(actor.thumbnail_path) || `https://picsum.photos/seed/actor-${actor.id}/200/200`,
characterName: actor.name,
characterImage: normalizeUrl(actor.thumbnail_path) || `https://picsum.photos/seed/actor-${actor.id}/200/200`,
})); }));
// Determine aspect ratio from API format
// Determine aspect ratio from poster_aspect_ratio or default to 2/3
let aspectRatio: '2/3' | '16/9' | '1/1' = '2/3'; let aspectRatio: '2/3' | '16/9' | '1/1' = '2/3';
if (apiItem.poster_aspect_ratio) { if (apiItem.aspectRatio) {
const ratio = apiItem.poster_aspect_ratio.toLowerCase(); const ratio = apiItem.aspectRatio.toLowerCase();
if (ratio.includes('16:9') || ratio.includes('1.78')) { if (ratio.includes('16:9') || ratio.includes('1.78') || ratio.includes('2.39')) {
aspectRatio = '16/9'; aspectRatio = '16/9';
} else if (ratio.includes('1:1') || ratio.includes('1.00')) { } else if (ratio.includes('1:1') || ratio.includes('1.00')) {
aspectRatio = '1/1'; aspectRatio = '1/1';
} }
} }
// Map API type to Media type allowed values
let mediaType: 'TV' | 'Movie' | 'OVA' | 'ONA' | 'Album' | 'Single' | 'Hardcover' | 'E-book' | 'Console' | 'Game' = 'Movie';
const apiType = apiItem.type?.toLowerCase();
if (apiType === 'tv' || apiType === 'episode') {
mediaType = 'TV';
} else if (apiType === 'album' || apiType === 'single') {
mediaType = apiType === 'album' ? 'Album' : 'Single';
} else if (apiType === 'game' || apiType === 'console') {
mediaType = apiType === 'game' ? 'Game' : 'Console';
} else if (apiType === 'ova') {
mediaType = 'OVA';
} else if (apiType === 'ona') {
mediaType = 'ONA';
} else if (apiType === 'hardcover' || apiType === 'e-book') {
mediaType = apiType === 'hardcover' ? 'Hardcover' : 'E-book';
}
// Map API category to MediaCategory
let mediaCategory: 'Anime' | 'Movies' | 'Music' | 'Books' | 'Adult' | 'Consoles' | 'Games' = 'Movies';
const apiCategory = apiItem.category?.toLowerCase();
console.log('API Category:', apiItem.category, 'Lowercased:', apiCategory, 'Type:', apiType);
if (apiCategory === 'anime') {
mediaCategory = 'Anime';
} else if (apiCategory === 'movie' || apiCategory === 'movies') {
mediaCategory = 'Movies';
} else if (apiCategory === 'music' || apiType === 'album' || apiType === 'single') {
mediaCategory = 'Music';
} else if (apiCategory === 'book' || apiCategory === 'books' || apiType === 'hardcover' || apiType === 'e-book') {
mediaCategory = 'Books';
} else if (apiCategory === 'adult') {
mediaCategory = 'Adult';
} else if (apiCategory === 'console' || apiCategory === 'consoles' || apiType === 'console') {
mediaCategory = 'Consoles';
} else if (apiCategory === 'game' || apiCategory === 'games' || apiType === 'game') {
mediaCategory = 'Games';
} else {
// If category doesn't match any known category, use the original value capitalized
// This handles cases where the API returns unexpected category values
console.warn('Unknown category:', apiItem.category, 'defaulting to Movies');
mediaCategory = 'Movies';
}
console.log('Mapped to:', mediaCategory);
// Map API status to Media status allowed values
let mediaStatus: 'watching' | 'completed' | 'planned' | 'dropped' | 'reading' | 'listening' | 'playing' | 'on-hold' = 'completed';
const apiStatus = apiItem.status?.toLowerCase();
if (apiStatus === 'ongoing' || apiStatus === 'watching') {
mediaStatus = 'watching';
} else if (apiStatus === 'upcoming' || apiStatus === 'planned') {
mediaStatus = 'planned';
} else if (apiStatus === 'dropped') {
mediaStatus = 'dropped';
} else if (apiStatus === 'reading') {
mediaStatus = 'reading';
} else if (apiStatus === 'listening') {
mediaStatus = 'listening';
} else if (apiStatus === 'playing') {
mediaStatus = 'playing';
} else if (apiStatus === 'on-hold') {
mediaStatus = 'on-hold';
}
return { return {
id: apiItem.id.toString() || undefined, id: apiItem.id.toString(),
title: apiItem.title || undefined, title: apiItem.title,
year: apiItem.release_date ? new Date(apiItem.release_date).getFullYear().toString() : 'Unknown', year: apiItem.year?.toString() || 'Unknown',
poster: normalizeUrl(apiItem.poster_url) || `https://picsum.photos/seed/${apiItem.id}/400/600`, poster: normalizeUrl(apiItem.poster) || `https://picsum.photos/seed/${apiItem.id}/400/600`,
banner: normalizeUrl(apiItem.backdrop_url) || undefined, category: mediaCategory,
description: apiItem.overview || undefined, banner: normalizeUrl(apiItem.banner) || undefined,
rating: apiItem.rating ? parseFloat(apiItem.rating) : undefined, description: apiItem.description || undefined,
genres: metadata.tags || [], rating: apiItem.rating || undefined,
tags: metadata.tags || [], genres: apiItem.genres || [],
studios: apiItem.director ? [apiItem.director] : undefined, tags: apiItem.tags || [],
type: 'Movie', studios: apiItem.studios,
status: 'completed', type: mediaType,
status: mediaStatus,
staff: staff.length > 0 ? staff : undefined, staff: staff.length > 0 ? staff : undefined,
runtime: apiItem.runtime_minutes,
director: apiItem.director || undefined,
writer: apiItem.writer || undefined,
releaseDate: apiItem.release_date || undefined,
aspectRatio: aspectRatio aspectRatio: aspectRatio
}; };
} }
export async function fetchMediaFromApi(apiUrl: string = `${BASE_URL}/api/adult`): Promise<Media[]> { // Media API Functions
console.error('Error fetching'); export async function fetchAllMedia(page: number = 1, limit: number = 50): Promise<Media[]> {
try { try {
const response = await fetch(apiUrl); const response = await fetch(`${BASE_URL}/api/media?page=${page}&limit=${limit}`);
if (!response.ok) { if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`); throw new Error(`HTTP error! status: ${response.status}`);
} }
const data: ApiResponse = await response.json(); const data: ApiResponse<PaginatedResponse<ApiMediaItem>> = await response.json();
if (data.success && data.data.items) { if (data.success && data.data.items) {
return data.data.items.map(convertApiToMedia); return data.data.items.map(convertApiToMedia);
@@ -154,33 +261,16 @@ export async function fetchMediaFromApi(apiUrl: string = `${BASE_URL}/api/adult`
} }
} }
export async function fetchMediaFromLocalJson(): Promise<Media[]> { export async function fetchMediaById(id: number | string): Promise<Media | null> {
try { try {
const response = await fetch('/adult.json'); const response = await fetch(`${BASE_URL}/api/media/${id}`);
if (!response.ok) { if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`); throw new Error(`HTTP error! status: ${response.status}`);
} }
const data: ApiResponse = await response.json(); const data: ApiResponse<ApiMediaItem> = await response.json();
if (data.data.items) {
return data.data.items.map(convertApiToMedia);
}
return [];
} catch (error) {
console.error('Error fetching media from local JSON:', error);
return [];
}
}
export async function fetchMediaById(id: number): Promise<Media | null> { if (data.success && data.data) {
try { return convertApiToMedia(data.data);
const response = await fetch('/adult.json');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: ApiResponse = await response.json();
if (data.data.items) {
const item = data.data.items.find(item => item.id === id);
return item ? convertApiToMedia(item) : null;
} }
return null; return null;
} catch (error) { } catch (error) {
@@ -189,102 +279,268 @@ export async function fetchMediaById(id: number): Promise<Media | null> {
} }
} }
export async function fetchMediaByActor(actorName: string): Promise<Media[]> { export async function createMedia(media: CreateMediaInput): Promise<Media | null> {
try { try {
const response = await fetch('/adult.json'); const response = await fetch(`${BASE_URL}/api/media`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(media),
});
if (!response.ok) { if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`); throw new Error(`HTTP error! status: ${response.status}`);
} }
const data: ApiResponse = await response.json(); const data: ApiResponse<ApiMediaItem> = await response.json();
if (data.data.items) {
return data.data.items if (data.success && data.data) {
.filter(item => item.actors?.some(actor => actor.name.toLowerCase().includes(actorName.toLowerCase()))) return convertApiToMedia(data.data);
.map(convertApiToMedia);
} }
return []; return null;
} catch (error) { } catch (error) {
console.error('Error fetching media by actor:', error); console.error('Error creating media:', error);
return []; return null;
} }
} }
export async function fetchMediaByTag(tag: string): Promise<Media[]> { export async function updateMedia(id: number | string, media: UpdateMediaInput): Promise<Media | null> {
try { try {
const response = await fetch('/adult.json'); const response = await fetch(`${BASE_URL}/api/media/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(media),
});
if (!response.ok) { if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`); throw new Error(`HTTP error! status: ${response.status}`);
} }
const data: ApiResponse = await response.json(); const data: ApiResponse<ApiMediaItem> = await response.json();
if (data.data.items) {
return data.data.items if (data.success && data.data) {
.filter(item => { return convertApiToMedia(data.data);
}
return null;
} catch (error) {
console.error('Error updating media:', error);
return null;
}
}
export async function deleteMedia(id: number | string): Promise<boolean> {
try { try {
const metadata = JSON.parse(item.metadata); const response = await fetch(`${BASE_URL}/api/media/${id}`, {
return metadata.tags?.some(t => t.toLowerCase().includes(tag.toLowerCase())); method: 'DELETE',
} catch { });
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: ApiResponse<{ message: string }> = await response.json();
return data.success;
} catch (error) {
console.error('Error deleting media:', error);
return false; return false;
} }
}) }
.map(convertApiToMedia);
// Cast API Functions
export async function fetchAllCast(page: number = 1, limit: number = 50): Promise<ApiCastItem[]> {
try {
const response = await fetch(`${BASE_URL}/api/cast?page=${page}&limit=${limit}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: ApiResponse<PaginatedResponse<ApiCastItem>> = await response.json();
if (data.success && data.data.items) {
return data.data.items;
} }
return []; return [];
} catch (error) { } catch (error) {
console.error('Error fetching media by tag:', error); console.error('Error fetching cast from API:', error);
return []; return [];
} }
} }
export async function fetchAllActors(): Promise<Array<{id: number, name: string, thumbnail_path: string | null}>> { export async function fetchCastById(id: number | string): Promise<ApiCastItem | null> {
try { try {
const response = await fetch('/adult.json'); const response = await fetch(`${BASE_URL}/api/cast/${id}`);
if (!response.ok) { if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`); throw new Error(`HTTP error! status: ${response.status}`);
} }
const data: ApiResponse = await response.json(); const data: ApiResponse<ApiCastItem> = await response.json();
if (data.data.items) {
const actorMap = new Map(); if (data.success && data.data) {
data.data.items.forEach(item => { return data.data;
item.actors?.forEach(actor => {
if (!actorMap.has(actor.id)) {
actorMap.set(actor.id, {
id: actor.id,
name: actor.name,
thumbnail_path: actor.thumbnail_path
});
} }
}); return null;
}); } catch (error) {
return Array.from(actorMap.values()); console.error('Error fetching cast by ID:', error);
return null;
}
}
export async function fetchCastMedia(castId: number | string): Promise<Media[]> {
try {
const response = await fetch(`${BASE_URL}/api/cast/${castId}/media`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: ApiResponse<PaginatedResponse<ApiMediaItem>> = await response.json();
if (data.success && data.data.items) {
return data.data.items.map(convertApiToMedia);
} }
return []; return [];
} catch (error) {
console.error('Error fetching cast media:', error);
return [];
}
}
export async function createCast(cast: CreateCastInput): Promise<ApiCastItem | null> {
try {
const response = await fetch(`${BASE_URL}/api/cast`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(cast),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: ApiResponse<ApiCastItem> = await response.json();
if (data.success && data.data) {
return data.data;
}
return null;
} catch (error) {
console.error('Error creating cast:', error);
return null;
}
}
export async function updateCast(id: number | string, cast: UpdateCastInput): Promise<ApiCastItem | null> {
try {
const response = await fetch(`${BASE_URL}/api/cast/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(cast),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: ApiResponse<ApiCastItem> = await response.json();
if (data.success && data.data) {
return data.data;
}
return null;
} catch (error) {
console.error('Error updating cast:', error);
return null;
}
}
export async function deleteCast(id: number | string): Promise<boolean> {
try {
const response = await fetch(`${BASE_URL}/api/cast/${id}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: ApiResponse<{ message: string }> = await response.json();
return data.success;
} catch (error) {
console.error('Error deleting cast:', error);
return false;
}
}
// Legacy function for compatibility - fetches all unique staff members from media
export async function fetchAllActors(): Promise<Array<{id: number, name: string, photo: string | null}>> {
try {
const media = await fetchAllMedia(1, 1000);
const actorMap = new Map<number, {id: number, name: string, photo: string | null}>();
media.forEach(item => {
item.staff?.forEach(staffMember => {
const id = parseInt(staffMember.id);
if (!actorMap.has(id)) {
actorMap.set(id, {
id: id,
name: staffMember.name,
photo: staffMember.photo
});
}
});
});
return Array.from(actorMap.values());
} catch (error) { } catch (error) {
console.error('Error fetching all actors:', error); console.error('Error fetching all actors:', error);
return []; return [];
} }
} }
// Legacy function for compatibility - fetches all unique tags from media
export async function fetchAllTags(): Promise<string[]> { export async function fetchAllTags(): Promise<string[]> {
try { try {
const response = await fetch('/adult.json'); const media = await fetchAllMedia(1, 1000);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: ApiResponse = await response.json();
if (data.data.items) {
const tagSet = new Set<string>(); const tagSet = new Set<string>();
data.data.items.forEach(item => {
try { media.forEach(item => {
const metadata = JSON.parse(item.metadata); item.tags?.forEach(tag => tagSet.add(tag));
metadata.tags?.forEach((tag: string) => tagSet.add(tag)); item.genres?.forEach(genre => tagSet.add(genre));
} catch {
// Ignore metadata parsing errors
}
}); });
return Array.from(tagSet).sort(); return Array.from(tagSet).sort();
}
return [];
} catch (error) { } catch (error) {
console.error('Error fetching all tags:', error); console.error('Error fetching all tags:', error);
return []; return [];
} }
} }
// Legacy function for compatibility - fetches media by actor name
export async function fetchMediaByActor(actorName: string): Promise<Media[]> {
try {
const media = await fetchAllMedia(1, 1000);
return media.filter(item =>
item.staff?.some(staffMember =>
staffMember.name.toLowerCase().includes(actorName.toLowerCase())
)
);
} catch (error) {
console.error('Error fetching media by actor:', error);
return [];
}
}
// Legacy function for compatibility - fetches media by tag
export async function fetchMediaByTag(tag: string): Promise<Media[]> {
try {
const media = await fetchAllMedia(1, 1000);
return media.filter(item =>
item.tags?.some(t => t.toLowerCase().includes(tag.toLowerCase())) ||
item.genres?.some(g => g.toLowerCase().includes(tag.toLowerCase()))
);
} catch (error) {
console.error('Error fetching media by tag:', error);
return [];
}
}
// Convenience function - fetch media from API (legacy compatibility)
export async function fetchMediaFromApi(apiUrl?: string): Promise<Media[]> {
return fetchAllMedia();
}
// Convenience function - fetch media from local JSON (legacy compatibility)
export async function fetchMediaFromLocalJson(): Promise<Media[]> {
return fetchAllMedia();
}

View File

@@ -1,9 +1,10 @@
import { Media, MediaCategory } from '@/types'; import { Media, MediaCategory } from '@/types';
import MediaCard from './MediaCard'; import MediaCard from './MediaCard';
import MediaListItem from './MediaListItem'; import MediaListItem from './MediaListItem';
import { Filter, LayoutGrid, List, Star, ChevronLeft, ChevronRight, ArrowUpDown, Plus } from 'lucide-react'; import { LayoutGrid, List, Star, ChevronLeft, ChevronRight, ArrowUpDown, Plus, Search } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import React, { useState, useMemo, useEffect } from 'react'; import React, { useState, useMemo, useEffect } from 'react';
import { createMedia, type CreateMediaInput } from '@/api';
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -43,65 +44,130 @@ export default function BrowseView({ mediaList, onMediaClick, onAddMedia, active
title: '', title: '',
year: '', year: '',
poster: '', poster: '',
banner: '',
description: '',
rating: '',
category: activeCategory as MediaCategory, category: activeCategory as MediaCategory,
aspectRatio: '2/3' as '2/3' | '16/9' | '1/1' type: 'Movie' as string,
status: 'Released' as string,
aspectRatio: '2/3' as '2/3' | '16/9' | '1/1',
runtime: '',
director: '',
writer: '',
releaseDate: '',
genres: '' as string,
tags: '' as string,
studios: '' as string
}); });
// Update category and default aspect ratio when activeCategory changes // Update category, default aspect ratio, and default type when activeCategory changes
useEffect(() => { useEffect(() => {
let defaultAspect: '2/3' | '16/9' | '1/1' = '2/3'; let defaultAspect: '2/3' | '16/9' | '1/1' = '2/3';
if (activeCategory === 'Music') defaultAspect = '1/1'; let defaultType = 'Movie';
if (activeCategory === 'Games' || activeCategory === 'Adult') defaultAspect = '16/9';
if (activeCategory === 'Music') {
defaultAspect = '1/1';
defaultType = 'Album';
} else if (activeCategory === 'Games') {
defaultAspect = '16/9';
defaultType = 'Game';
} else if (activeCategory === 'Adult') {
defaultAspect = '16/9';
defaultType = 'Movie';
} else if (activeCategory === 'Anime') {
defaultType = 'TV';
} else if (activeCategory === 'Books') {
defaultType = 'Hardcover';
} else if (activeCategory === 'Consoles') {
defaultType = 'Console';
}
setNewMedia(prev => ({ setNewMedia(prev => ({
...prev, ...prev,
category: activeCategory, category: activeCategory,
aspectRatio: defaultAspect aspectRatio: defaultAspect,
type: defaultType
})); }));
}, [activeCategory]); }, [activeCategory]);
const handleAddSubmit = (e: React.FormEvent) => { const handleAddSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!newMedia.title || !newMedia.poster) return; if (!newMedia.title || !newMedia.poster) return;
onAddMedia({ // Convert category from plural to singular to match API format
id: Math.random().toString(36).substr(2, 9), const categoryMap: Record<string, string> = {
'Anime': 'Anime',
'Movies': 'Movie',
'Music': 'Music',
'Books': 'Book',
'Consoles': 'Console',
'Games': 'Game',
'Adult': 'Adult'
};
const mediaInput: CreateMediaInput = {
title: newMedia.title, title: newMedia.title,
year: newMedia.year || new Date().getFullYear().toString(), year: parseInt(newMedia.year) || new Date().getFullYear(),
poster: newMedia.poster, poster: newMedia.poster,
category: newMedia.category, banner: newMedia.banner || null,
description: newMedia.description || null,
rating: newMedia.rating ? parseFloat(newMedia.rating) : null,
category: categoryMap[newMedia.category] || newMedia.category,
type: newMedia.type,
status: newMedia.status,
aspectRatio: newMedia.aspectRatio, aspectRatio: newMedia.aspectRatio,
status: 'planned' runtime: newMedia.runtime ? parseInt(newMedia.runtime) : null,
}); director: newMedia.director || null,
writer: newMedia.writer || null,
releaseDate: newMedia.releaseDate || null,
genres: newMedia.genres ? newMedia.genres.split(',').map(g => g.trim()) : [],
tags: newMedia.tags ? newMedia.tags.split(',').map(t => t.trim()) : [],
studios: newMedia.studios ? newMedia.studios.split(',').map(s => s.trim()) : []
};
const createdMedia = await createMedia(mediaInput);
if (createdMedia) {
onAddMedia(createdMedia);
}
setNewMedia({ setNewMedia({
title: '', title: '',
year: '', year: '',
poster: '', poster: '',
banner: '',
description: '',
rating: '',
category: activeCategory, category: activeCategory,
aspectRatio: '2/3' type: 'Movie',
status: 'Released',
aspectRatio: '2/3',
runtime: '',
director: '',
writer: '',
releaseDate: '',
genres: '',
tags: '',
studios: ''
}); });
setIsAddDialogOpen(false); setIsAddDialogOpen(false);
}; };
// Filter states // Filter states
const [selectedType, setSelectedType] = useState<string | null>(null);
const [selectedGenre, setSelectedGenre] = useState<string | null>(null); const [selectedGenre, setSelectedGenre] = useState<string | null>(null);
const [selectedStudio, setSelectedStudio] = useState<string | null>(null); const [selectedStudio, setSelectedStudio] = useState<string | null>(null);
// Extract unique values for filters // Extract unique values for filters
const allTypes = useMemo(() => Array.from(new Set(mediaList.map(m => m.type).filter(Boolean))), [mediaList]);
const allGenres = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.genres || []))), [mediaList]); const allGenres = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.genres || []))), [mediaList]);
const allStudios = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.studios || []))), [mediaList]); const allStudios = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.studios || []))), [mediaList]);
const filteredMedia = useMemo(() => { const filteredMedia = useMemo(() => {
return mediaList.filter(media => { return mediaList.filter(media => {
if (selectedType && media.type !== selectedType) return false;
if (selectedGenre && !media.genres?.includes(selectedGenre)) return false; if (selectedGenre && !media.genres?.includes(selectedGenre)) return false;
if (selectedStudio && !media.studios?.includes(selectedStudio)) return false; if (selectedStudio && !media.studios?.includes(selectedStudio)) return false;
return true; return true;
}); });
}, [mediaList, selectedType, selectedGenre, selectedStudio]); }, [mediaList, selectedGenre, selectedStudio]);
// Reset to first page when mediaList or filters change // Reset to first page when mediaList or filters change
useEffect(() => { useEffect(() => {
@@ -141,29 +207,13 @@ export default function BrowseView({ mediaList, onMediaClick, onAddMedia, active
{/* Filters Bar */} {/* Filters Bar */}
<div className="flex flex-wrap items-center justify-between gap-4 mb-8"> <div className="flex flex-wrap items-center justify-between gap-4 mb-8">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{/* Type Filter */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className={cn("font-bold gap-2", selectedType ? "text-[#6d28d9] bg-[#6d28d9]/10" : "text-zinc-600")}>
<Filter size={16} />
{selectedType || 'Media Type'}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => setSelectedType(null)}>All Types</DropdownMenuItem>
{allTypes.map(type => (
<DropdownMenuItem key={type} onClick={() => setSelectedType(type!)}>{type}</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* Genre Filter */} {/* Genre Filter */}
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className={cn("font-bold gap-2", selectedGenre ? "text-[#6d28d9] bg-[#6d28d9]/10" : "text-zinc-600")}> <button type="button" className={cn("group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5 font-bold gap-2", selectedGenre ? "text-[#6d28d9] bg-[#6d28d9]/10" : "text-zinc-600")}>
<Star size={16} /> <Star size={16} />
{selectedGenre || 'Genres'} {selectedGenre || 'Genres'}
</Button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="start" className="max-h-[300px] overflow-y-auto"> <DropdownMenuContent align="start" className="max-h-[300px] overflow-y-auto">
<DropdownMenuItem onClick={() => setSelectedGenre(null)}>All Genres</DropdownMenuItem> <DropdownMenuItem onClick={() => setSelectedGenre(null)}>All Genres</DropdownMenuItem>
@@ -176,9 +226,9 @@ export default function BrowseView({ mediaList, onMediaClick, onAddMedia, active
{/* Studio Filter */} {/* Studio Filter */}
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className={cn("font-bold gap-2", selectedStudio ? "text-[#6d28d9] bg-[#6d28d9]/10" : "text-zinc-600")}> <button type="button" className={cn("group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5 font-bold gap-2", selectedStudio ? "text-[#6d28d9] bg-[#6d28d9]/10" : "text-zinc-600")}>
Studios Studios
</Button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="start" className="max-h-[300px] overflow-y-auto"> <DropdownMenuContent align="start" className="max-h-[300px] overflow-y-auto">
<DropdownMenuItem onClick={() => setSelectedStudio(null)}>All Studios</DropdownMenuItem> <DropdownMenuItem onClick={() => setSelectedStudio(null)}>All Studios</DropdownMenuItem>
@@ -188,13 +238,12 @@ export default function BrowseView({ mediaList, onMediaClick, onAddMedia, active
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
{(selectedType || selectedGenre || selectedStudio) && ( {(selectedGenre || selectedStudio) && (
<Button <Button
variant="link" variant="link"
size="sm" size="sm"
className="text-zinc-400 font-bold" className="text-zinc-400 font-bold"
onClick={() => { onClick={() => {
setSelectedType(null);
setSelectedGenre(null); setSelectedGenre(null);
setSelectedStudio(null); setSelectedStudio(null);
}} }}
@@ -207,10 +256,10 @@ export default function BrowseView({ mediaList, onMediaClick, onAddMedia, active
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}> <Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button className="bg-[#6d28d9] hover:bg-[#5b21b6] text-white font-black rounded-full px-6 h-11 shadow-lg shadow-[#6d28d9]/20 gap-2"> <button type="button" className="group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 bg-[#6d28d9] hover:bg-[#5b21b6] text-white font-black rounded-full px-6 h-11 shadow-lg shadow-[#6d28d9]/20 gap-2">
<Plus size={20} /> <Plus size={20} />
ADD NEW ADD NEW
</Button> </button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="sm:max-w-[425px] bg-white rounded-3xl"> <DialogContent className="sm:max-w-[425px] bg-white rounded-3xl">
<form onSubmit={handleAddSubmit}> <form onSubmit={handleAddSubmit}>
@@ -220,7 +269,7 @@ export default function BrowseView({ mediaList, onMediaClick, onAddMedia, active
Manually add a new item to your {activeCategory} library. Manually add a new item to your {activeCategory} library.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="grid gap-6 py-6"> <div className="grid gap-4 py-6 max-h-[60vh] overflow-y-auto">
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="title" className="text-sm font-black text-zinc-700">Title</Label> <Label htmlFor="title" className="text-sm font-black text-zinc-700">Title</Label>
<Input <Input
@@ -257,6 +306,64 @@ export default function BrowseView({ mediaList, onMediaClick, onAddMedia, active
</select> </select>
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="type" className="text-sm font-black text-zinc-700">Type</Label>
<select
id="type"
value={newMedia.type}
onChange={e => setNewMedia(prev => ({ ...prev, type: e.target.value }))}
className="bg-zinc-50 border border-zinc-100 rounded-xl h-11 px-3 text-sm font-bold text-zinc-700 focus:ring-2 focus:ring-[#6d28d9] outline-none"
>
{newMedia.category === 'Music' ? (
<>
<option value="Album">Album</option>
<option value="Single">Single</option>
</>
) : newMedia.category === 'Books' ? (
<>
<option value="Hardcover">Hardcover</option>
<option value="E-book">E-book</option>
</>
) : newMedia.category === 'Games' ? (
<>
<option value="Game">Game</option>
</>
) : newMedia.category === 'Consoles' ? (
<>
<option value="Console">Console</option>
</>
) : (
<>
<option value="TV">TV</option>
<option value="Movie">Movie</option>
<option value="OVA">OVA</option>
<option value="ONA">ONA</option>
</>
)}
</select>
</div>
<div className="grid gap-2">
<Label htmlFor="status" className="text-sm font-black text-zinc-700">Status</Label>
<select
id="status"
value={newMedia.status}
onChange={e => setNewMedia(prev => ({ ...prev, status: e.target.value }))}
className="bg-zinc-50 border border-zinc-100 rounded-xl h-11 px-3 text-sm font-bold text-zinc-700 focus:ring-2 focus:ring-[#6d28d9] outline-none"
>
<option value="Released">Released</option>
<option value="Ongoing">Ongoing</option>
<option value="Upcoming">Upcoming</option>
<option value="Completed">Completed</option>
<option value="Watching">Watching</option>
<option value="Reading">Reading</option>
<option value="Listening">Listening</option>
<option value="Playing">Playing</option>
<option value="Dropped">Dropped</option>
<option value="On Hold">On Hold</option>
</select>
</div>
</div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="aspectRatio" className="text-sm font-black text-zinc-700">Aspect Ratio (Format)</Label> <Label htmlFor="aspectRatio" className="text-sm font-black text-zinc-700">Aspect Ratio (Format)</Label>
<select <select
@@ -265,9 +372,9 @@ export default function BrowseView({ mediaList, onMediaClick, onAddMedia, active
onChange={e => setNewMedia(prev => ({ ...prev, aspectRatio: e.target.value as '2/3' | '16/9' | '1/1' }))} onChange={e => setNewMedia(prev => ({ ...prev, aspectRatio: e.target.value as '2/3' | '16/9' | '1/1' }))}
className="bg-zinc-50 border border-zinc-100 rounded-xl h-11 px-3 text-sm font-bold text-zinc-700 focus:ring-2 focus:ring-[#6d28d9] outline-none" className="bg-zinc-50 border border-zinc-100 rounded-xl h-11 px-3 text-sm font-bold text-zinc-700 focus:ring-2 focus:ring-[#6d28d9] outline-none"
> >
<option value="2/3">2:3 (Standard Poster - Anime/Movies)</option> <option value="2/3">2:3 (Standard Poster)</option>
<option value="16/9">16:9 (Wide Thumbnail - Games/Adult)</option> <option value="16/9">16:9 (Wide Thumbnail)</option>
<option value="1/1">1:1 (Square - Music)</option> <option value="1/1">1:1 (Square)</option>
</select> </select>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
@@ -281,6 +388,117 @@ export default function BrowseView({ mediaList, onMediaClick, onAddMedia, active
required required
/> />
</div> </div>
<div className="grid gap-2">
<Label htmlFor="banner" className="text-sm font-black text-zinc-700">Banner URL (Optional)</Label>
<Input
id="banner"
value={newMedia.banner}
onChange={e => setNewMedia(prev => ({ ...prev, banner: e.target.value }))}
placeholder="https://example.com/banner.jpg"
className="bg-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="description" className="text-sm font-black text-zinc-700">Description (Optional)</Label>
<textarea
id="description"
value={newMedia.description}
onChange={e => setNewMedia(prev => ({ ...prev, description: e.target.value }))}
placeholder="Brief description..."
className="bg-zinc-50 border-zinc-100 rounded-xl p-3 h-20 text-sm font-bold text-zinc-700 focus:ring-2 focus:ring-[#6d28d9] outline-none resize-none"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="rating" className="text-sm font-black text-zinc-700">Rating (Optional)</Label>
<Input
id="rating"
type="number"
step="0.1"
min="0"
max="10"
value={newMedia.rating}
onChange={e => setNewMedia(prev => ({ ...prev, rating: e.target.value }))}
placeholder="8.5"
className="bg-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
/>
</div>
{(newMedia.category === 'Anime' || newMedia.category === 'Movies' || newMedia.category === 'Adult') && (
<>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="runtime" className="text-sm font-black text-zinc-700">Runtime (min)</Label>
<Input
id="runtime"
type="number"
value={newMedia.runtime}
onChange={e => setNewMedia(prev => ({ ...prev, runtime: e.target.value }))}
placeholder="120"
className="bg-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="releaseDate" className="text-sm font-black text-zinc-700">Release Date</Label>
<Input
id="releaseDate"
type="date"
value={newMedia.releaseDate}
onChange={e => setNewMedia(prev => ({ ...prev, releaseDate: e.target.value }))}
className="bg-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
/>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="director" className="text-sm font-black text-zinc-700">Director</Label>
<Input
id="director"
value={newMedia.director}
onChange={e => setNewMedia(prev => ({ ...prev, director: e.target.value }))}
placeholder="Director name"
className="bg-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="writer" className="text-sm font-black text-zinc-700">Writer</Label>
<Input
id="writer"
value={newMedia.writer}
onChange={e => setNewMedia(prev => ({ ...prev, writer: e.target.value }))}
placeholder="Writer name"
className="bg-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
/>
</div>
</>
)}
<div className="grid gap-2">
<Label htmlFor="genres" className="text-sm font-black text-zinc-700">Genres (comma-separated)</Label>
<Input
id="genres"
value={newMedia.genres}
onChange={e => setNewMedia(prev => ({ ...prev, genres: e.target.value }))}
placeholder="Action, Drama, Sci-Fi"
className="bg-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="tags" className="text-sm font-black text-zinc-700">Tags (comma-separated)</Label>
<Input
id="tags"
value={newMedia.tags}
onChange={e => setNewMedia(prev => ({ ...prev, tags: e.target.value }))}
placeholder="Classic, Best-selling"
className="bg-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="studios" className="text-sm font-black text-zinc-700">Studios (comma-separated)</Label>
<Input
id="studios"
value={newMedia.studios}
onChange={e => setNewMedia(prev => ({ ...prev, studios: e.target.value }))}
placeholder="Studio A, Studio B"
className="bg-zinc-50 border-zinc-100 rounded-xl h-11 focus:ring-[#6d28d9]"
/>
</div>
</div> </div>
<DialogFooter> <DialogFooter>
<Button type="submit" className="w-full bg-[#6d28d9] hover:bg-[#5b21b6] text-white font-black h-12 rounded-xl shadow-lg shadow-[#6d28d9]/20"> <Button type="submit" className="w-full bg-[#6d28d9] hover:bg-[#5b21b6] text-white font-black h-12 rounded-xl shadow-lg shadow-[#6d28d9]/20">
@@ -293,10 +511,10 @@ export default function BrowseView({ mediaList, onMediaClick, onAddMedia, active
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="text-zinc-600 font-bold gap-2"> <button type="button" className="group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5 text-zinc-600 font-bold gap-2">
<ArrowUpDown size={16} /> <ArrowUpDown size={16} />
{sortBy === 'default' ? 'Sort' : sortBy === 'title-asc' ? 'Title (A-Z)' : 'Title (Z-A)'} {sortBy === 'default' ? 'Sort' : sortBy === 'title-asc' ? 'Title (A-Z)' : 'Title (Z-A)'}
</Button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setSortBy('default')}>Default</DropdownMenuItem> <DropdownMenuItem onClick={() => setSortBy('default')}>Default</DropdownMenuItem>
@@ -336,7 +554,7 @@ export default function BrowseView({ mediaList, onMediaClick, onAddMedia, active
{mediaList.length === 0 ? ( {mediaList.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-zinc-400"> <div className="flex flex-col items-center justify-center py-20 text-zinc-400">
<div className="w-16 h-16 bg-zinc-100 rounded-full flex items-center justify-center mb-4"> <div className="w-16 h-16 bg-zinc-100 rounded-full flex items-center justify-center mb-4">
<Filter size={32} /> <Search size={32} />
</div> </div>
<p className="text-lg font-bold">No results found</p> <p className="text-lg font-bold">No results found</p>
<p className="text-sm">Try adjusting your search or filters</p> <p className="text-sm">Try adjusting your search or filters</p>

View File

@@ -34,9 +34,9 @@ export default function LibrarySettings({ enabledCategories, onToggleCategory }:
return ( return (
<Dialog> <Dialog>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="ghost" size="icon" className="text-white/90 hover:text-white transition-colors"> <button type="button" className="group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 size-8 text-white/90 hover:text-white transition-colors">
<Settings size={20} /> <Settings size={20} />
</Button> </button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="sm:max-w-[425px] bg-white rounded-3xl"> <DialogContent className="sm:max-w-[425px] bg-white rounded-3xl">
<DialogHeader> <DialogHeader>

View File

@@ -127,7 +127,8 @@ export const MOCK_MEDIA: Media[] = [
studios: ['Example Studio'], studios: ['Example Studio'],
} }
]; ];
export const DETAIL_MEDIA: Media = {}
/*
export const DETAIL_MEDIA: Media = { export const DETAIL_MEDIA: Media = {
id: 'mob-psycho', id: 'mob-psycho',
title: 'Mob Psycho 100', title: 'Mob Psycho 100',
@@ -220,3 +221,4 @@ export const DETAIL_MEDIA: Media = {
}, },
] ]
}; };
*/