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 { MOCK_MEDIA, DETAIL_MEDIA } from './data';
import { Media, Staff, MediaCategory } from './types';
import { fetchMediaFromLocalJson, fetchMediaById } from './api';
import { fetchAllMedia, fetchMediaById } from './api';
export default function App() {
const [currentView, setCurrentView] = useState<'browse' | 'detail' | 'cast' | 'castDetail'>('browse');
@@ -24,19 +24,19 @@ export default function App() {
const [customMedia, setCustomMedia] = 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(() => {
const loadAdultMedia = async () => {
const loadMediaFromApi = async () => {
try {
const media = await fetchMediaFromLocalJson();
// Add category to adult media
const categorizedMedia = media.map(m => ({ ...m, category: 'Adult' as MediaCategory }));
setAdultMedia(categorizedMedia);
const media = await fetchAllMedia();
setApiMedia(media);
} 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) => {
@@ -62,23 +62,52 @@ export default function App() {
};
const allMedia = useMemo(() => {
// Merge mock media, adult media, detail media and custom media
const list = [...MOCK_MEDIA, ...adultMedia, ...customMedia];
// 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, adultMedia]);
}, [activeCategory, enabledCategories, customMedia, apiMedia]);
const handleAddMedia = (newMedia: Media) => {
setCustomMedia(prev => [...prev, newMedia]);
const handleAddMedia = async (newMedia: Media) => {
// 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 all available media (mock + adult + custom + detail) but filter by enabled categories
const baseList = [...MOCK_MEDIA, ...adultMedia, ...customMedia];
// 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);
}
@@ -95,7 +124,7 @@ export default function App() {
});
});
return staff;
}, [enabledCategories, customMedia, adultMedia]);
}, [enabledCategories, customMedia, apiMedia]);
const filteredMedia = useMemo(() => {
if (!searchQuery.trim()) return allMedia;

View File

@@ -1,6 +1,6 @@
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 {
if (!url) return '';
@@ -12,137 +12,244 @@ function normalizeUrl(url: string | null): string {
return `${BASE_URL}/${cleanPath}`;
}
export interface ApiResponse {
// API Response Types
export interface ApiResponse<T> {
success: boolean;
data: {
items: ApiMediaItem[];
};
data: T;
}
export interface PaginatedResponse<T> {
items: T[];
total: number;
page: number;
limit: number;
totalPages?: number;
}
// Media Types
export interface ApiMediaItem {
id: number;
title: string;
overview: string;
poster_url: string;
poster_aspect_ratio: string | null;
backdrop_url: string | null;
backdrop_aspect_ratio: string | null;
rating: string;
runtime_minutes: number;
release_date: 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;
cast: string | null;
genre: string | null;
metadata: string;
actors?: Array<{
id: number;
name: string;
thumbnail_path: string | null;
metadata?: string;
created_at?: string;
updated_at?: string;
}>;
releaseDate: string | null;
createdAt: string;
updatedAt: string;
genres?: string[];
tags?: string[];
studios?: string[];
staff?: ApiStaff[];
}
export interface ApiMetadata {
xbvr_id: number;
xbvr_url: string | null;
cast: string[];
actors: Array<{
id: number;
name: string;
thumbnail_path: string | null;
}>;
tags: string[];
is_available: boolean;
is_watched: boolean;
watch_count: number;
video_length: number;
video_width: number | null;
video_height: number | null;
video_codec: string | null;
file_path: string | null;
cover_url: string;
[key: string]: any;
export interface ApiStaff {
id: number;
name: string;
photo: string | null;
bio: string | null;
birthDate: string | null;
birthPlace: string | null;
role: string;
characterName: string | null;
characterImage: string | null;
occupations?: string[];
}
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 {
let metadata: ApiMetadata;
try {
metadata = JSON.parse(apiItem.metadata);
} catch (e) {
metadata = {
xbvr_id: 0,
xbvr_url: null,
cast: [],
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`,
// Convert staff from API to Media staff format
const staff: Staff[] = (apiItem.staff || []).map((staffMember) => ({
id: staffMember.id.toString(),
name: staffMember.name,
role: staffMember.role,
photo: normalizeUrl(staffMember.photo) || `https://picsum.photos/seed/staff-${staffMember.id}/200/200`,
characterName: staffMember.characterName || staffMember.name,
characterImage: normalizeUrl(staffMember.characterImage) || normalizeUrl(staffMember.photo) || `https://picsum.photos/seed/staff-${staffMember.id}/200/200`,
}));
// Determine aspect ratio from poster_aspect_ratio or default to 2/3
// Determine aspect ratio from API format
let aspectRatio: '2/3' | '16/9' | '1/1' = '2/3';
if (apiItem.poster_aspect_ratio) {
const ratio = apiItem.poster_aspect_ratio.toLowerCase();
if (ratio.includes('16:9') || ratio.includes('1.78')) {
if (apiItem.aspectRatio) {
const ratio = apiItem.aspectRatio.toLowerCase();
if (ratio.includes('16:9') || ratio.includes('1.78') || ratio.includes('2.39')) {
aspectRatio = '16/9';
} else if (ratio.includes('1:1') || ratio.includes('1.00')) {
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 {
id: apiItem.id.toString() || undefined,
title: apiItem.title || undefined,
year: apiItem.release_date ? new Date(apiItem.release_date).getFullYear().toString() : 'Unknown',
poster: normalizeUrl(apiItem.poster_url) || `https://picsum.photos/seed/${apiItem.id}/400/600`,
banner: normalizeUrl(apiItem.backdrop_url) || undefined,
description: apiItem.overview || undefined,
rating: apiItem.rating ? parseFloat(apiItem.rating) : undefined,
genres: metadata.tags || [],
tags: metadata.tags || [],
studios: apiItem.director ? [apiItem.director] : undefined,
type: 'Movie',
status: 'completed',
id: apiItem.id.toString(),
title: apiItem.title,
year: apiItem.year?.toString() || 'Unknown',
poster: normalizeUrl(apiItem.poster) || `https://picsum.photos/seed/${apiItem.id}/400/600`,
category: mediaCategory,
banner: normalizeUrl(apiItem.banner) || undefined,
description: apiItem.description || undefined,
rating: apiItem.rating || undefined,
genres: apiItem.genres || [],
tags: apiItem.tags || [],
studios: apiItem.studios,
type: mediaType,
status: mediaStatus,
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
};
}
export async function fetchMediaFromApi(apiUrl: string = `${BASE_URL}/api/adult`): Promise<Media[]> {
console.error('Error fetching');
// Media API Functions
export async function fetchAllMedia(page: number = 1, limit: number = 50): Promise<Media[]> {
try {
const response = await fetch(apiUrl);
const response = await fetch(`${BASE_URL}/api/media?page=${page}&limit=${limit}`);
if (!response.ok) {
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) {
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 {
const response = await fetch('/adult.json');
const response = await fetch(`${BASE_URL}/api/media/${id}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: ApiResponse = 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> {
try {
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;
const data: ApiResponse<ApiMediaItem> = await response.json();
if (data.success && data.data) {
return convertApiToMedia(data.data);
}
return null;
} 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 {
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) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: ApiResponse = await response.json();
if (data.data.items) {
return data.data.items
.filter(item => item.actors?.some(actor => actor.name.toLowerCase().includes(actorName.toLowerCase())))
.map(convertApiToMedia);
const data: ApiResponse<ApiMediaItem> = await response.json();
if (data.success && data.data) {
return convertApiToMedia(data.data);
}
return null;
} catch (error) {
console.error('Error creating media:', error);
return null;
}
}
export async function updateMedia(id: number | string, media: UpdateMediaInput): Promise<Media | null> {
try {
const response = await fetch(`${BASE_URL}/api/media/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(media),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: ApiResponse<ApiMediaItem> = await response.json();
if (data.success && data.data) {
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 {
const response = await fetch(`${BASE_URL}/api/media/${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 media:', error);
return false;
}
}
// 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 [];
} catch (error) {
console.error('Error fetching media by actor:', error);
console.error('Error fetching cast from API:', error);
return [];
}
}
export async function fetchMediaByTag(tag: string): Promise<Media[]> {
export async function fetchCastById(id: number | string): Promise<ApiCastItem | null> {
try {
const response = await fetch('/adult.json');
const response = await fetch(`${BASE_URL}/api/cast/${id}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: ApiResponse = await response.json();
if (data.data.items) {
return data.data.items
.filter(item => {
try {
const metadata = JSON.parse(item.metadata);
return metadata.tags?.some(t => t.toLowerCase().includes(tag.toLowerCase()));
} catch {
return false;
}
})
.map(convertApiToMedia);
const data: ApiResponse<ApiCastItem> = await response.json();
if (data.success && data.data) {
return data.data;
}
return null;
} catch (error) {
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 [];
} catch (error) {
console.error('Error fetching media by tag:', error);
console.error('Error fetching cast media:', error);
return [];
}
}
export async function fetchAllActors(): Promise<Array<{id: number, name: string, thumbnail_path: string | null}>> {
export async function createCast(cast: CreateCastInput): Promise<ApiCastItem | null> {
try {
const response = await fetch('/adult.json');
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 = await response.json();
if (data.data.items) {
const actorMap = new Map();
data.data.items.forEach(item => {
item.actors?.forEach(actor => {
if (!actorMap.has(actor.id)) {
actorMap.set(actor.id, {
id: actor.id,
name: actor.name,
thumbnail_path: actor.thumbnail_path
});
}
});
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());
}
return [];
});
return Array.from(actorMap.values());
} catch (error) {
console.error('Error fetching all actors:', error);
return [];
}
}
// Legacy function for compatibility - fetches all unique tags from media
export async function fetchAllTags(): Promise<string[]> {
try {
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 tagSet = new Set<string>();
data.data.items.forEach(item => {
try {
const metadata = JSON.parse(item.metadata);
metadata.tags?.forEach((tag: string) => tagSet.add(tag));
} catch {
// Ignore metadata parsing errors
}
});
return Array.from(tagSet).sort();
}
return [];
const media = await fetchAllMedia(1, 1000);
const tagSet = new Set<string>();
media.forEach(item => {
item.tags?.forEach(tag => tagSet.add(tag));
item.genres?.forEach(genre => tagSet.add(genre));
});
return Array.from(tagSet).sort();
} catch (error) {
console.error('Error fetching all tags:', error);
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 MediaCard from './MediaCard';
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 React, { useState, useMemo, useEffect } from 'react';
import { createMedia, type CreateMediaInput } from '@/api';
import {
DropdownMenu,
DropdownMenuContent,
@@ -43,65 +44,130 @@ export default function BrowseView({ mediaList, onMediaClick, onAddMedia, active
title: '',
year: '',
poster: '',
banner: '',
description: '',
rating: '',
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(() => {
let defaultAspect: '2/3' | '16/9' | '1/1' = '2/3';
if (activeCategory === 'Music') defaultAspect = '1/1';
if (activeCategory === 'Games' || activeCategory === 'Adult') defaultAspect = '16/9';
let defaultType = 'Movie';
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 => ({
...prev,
category: activeCategory,
aspectRatio: defaultAspect
aspectRatio: defaultAspect,
type: defaultType
}));
}, [activeCategory]);
const handleAddSubmit = (e: React.FormEvent) => {
const handleAddSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!newMedia.title || !newMedia.poster) return;
onAddMedia({
id: Math.random().toString(36).substr(2, 9),
// Convert category from plural to singular to match API format
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,
year: newMedia.year || new Date().getFullYear().toString(),
year: parseInt(newMedia.year) || new Date().getFullYear(),
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,
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({
title: '',
year: '',
poster: '',
banner: '',
description: '',
rating: '',
category: activeCategory,
aspectRatio: '2/3'
type: 'Movie',
status: 'Released',
aspectRatio: '2/3',
runtime: '',
director: '',
writer: '',
releaseDate: '',
genres: '',
tags: '',
studios: ''
});
setIsAddDialogOpen(false);
};
// Filter states
const [selectedType, setSelectedType] = useState<string | null>(null);
const [selectedGenre, setSelectedGenre] = useState<string | null>(null);
const [selectedStudio, setSelectedStudio] = useState<string | null>(null);
// 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 allStudios = useMemo(() => Array.from(new Set(mediaList.flatMap(m => m.studios || []))), [mediaList]);
const filteredMedia = useMemo(() => {
return mediaList.filter(media => {
if (selectedType && media.type !== selectedType) return false;
if (selectedGenre && !media.genres?.includes(selectedGenre)) return false;
if (selectedStudio && !media.studios?.includes(selectedStudio)) return false;
return true;
});
}, [mediaList, selectedType, selectedGenre, selectedStudio]);
}, [mediaList, selectedGenre, selectedStudio]);
// Reset to first page when mediaList or filters change
useEffect(() => {
@@ -141,29 +207,13 @@ export default function BrowseView({ mediaList, onMediaClick, onAddMedia, active
{/* Filters Bar */}
<div className="flex flex-wrap items-center justify-between gap-4 mb-8">
<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 */}
<DropdownMenu>
<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} />
{selectedGenre || 'Genres'}
</Button>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="max-h-[300px] overflow-y-auto">
<DropdownMenuItem onClick={() => setSelectedGenre(null)}>All Genres</DropdownMenuItem>
@@ -176,9 +226,9 @@ export default function BrowseView({ mediaList, onMediaClick, onAddMedia, active
{/* Studio Filter */}
<DropdownMenu>
<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
</Button>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="max-h-[300px] overflow-y-auto">
<DropdownMenuItem onClick={() => setSelectedStudio(null)}>All Studios</DropdownMenuItem>
@@ -188,13 +238,12 @@ export default function BrowseView({ mediaList, onMediaClick, onAddMedia, active
</DropdownMenuContent>
</DropdownMenu>
{(selectedType || selectedGenre || selectedStudio) && (
{(selectedGenre || selectedStudio) && (
<Button
variant="link"
size="sm"
className="text-zinc-400 font-bold"
onClick={() => {
setSelectedType(null);
setSelectedGenre(null);
setSelectedStudio(null);
}}
@@ -207,10 +256,10 @@ export default function BrowseView({ mediaList, onMediaClick, onAddMedia, active
<div className="flex items-center gap-4">
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
<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} />
ADD NEW
</Button>
</button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px] bg-white rounded-3xl">
<form onSubmit={handleAddSubmit}>
@@ -220,7 +269,7 @@ export default function BrowseView({ mediaList, onMediaClick, onAddMedia, active
Manually add a new item to your {activeCategory} library.
</DialogDescription>
</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">
<Label htmlFor="title" className="text-sm font-black text-zinc-700">Title</Label>
<Input
@@ -257,6 +306,64 @@ export default function BrowseView({ mediaList, onMediaClick, onAddMedia, active
</select>
</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">
<Label htmlFor="aspectRatio" className="text-sm font-black text-zinc-700">Aspect Ratio (Format)</Label>
<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' }))}
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="16/9">16:9 (Wide Thumbnail - Games/Adult)</option>
<option value="1/1">1:1 (Square - Music)</option>
<option value="2/3">2:3 (Standard Poster)</option>
<option value="16/9">16:9 (Wide Thumbnail)</option>
<option value="1/1">1:1 (Square)</option>
</select>
</div>
<div className="grid gap-2">
@@ -281,6 +388,117 @@ export default function BrowseView({ mediaList, onMediaClick, onAddMedia, active
required
/>
</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>
<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">
@@ -293,10 +511,10 @@ export default function BrowseView({ mediaList, onMediaClick, onAddMedia, active
<DropdownMenu>
<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} />
{sortBy === 'default' ? 'Sort' : sortBy === 'title-asc' ? 'Title (A-Z)' : 'Title (Z-A)'}
</Button>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setSortBy('default')}>Default</DropdownMenuItem>
@@ -336,7 +554,7 @@ export default function BrowseView({ mediaList, onMediaClick, onAddMedia, active
{mediaList.length === 0 ? (
<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">
<Filter size={32} />
<Search size={32} />
</div>
<p className="text-lg font-bold">No results found</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 (
<Dialog>
<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} />
</Button>
</button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px] bg-white rounded-3xl">
<DialogHeader>

View File

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