banbaa
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
23
api_examples/create_adult_cast.json
Normal file
23
api_examples/create_adult_cast.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
28
api_examples/create_album.json
Normal file
28
api_examples/create_album.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
8
api_examples/create_cast.json
Normal file
8
api_examples/create_cast.json
Normal 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"]
|
||||||
|
}
|
||||||
9
api_examples/create_episode.json
Normal file
9
api_examples/create_episode.json
Normal 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"
|
||||||
|
}
|
||||||
32
api_examples/create_movie.json
Normal file
32
api_examples/create_movie.json
Normal 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"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
6
api_examples/create_track.json
Normal file
6
api_examples/create_track.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"track_number": 3,
|
||||||
|
"title": "On the Run",
|
||||||
|
"duration": "3:35",
|
||||||
|
"artist": "Pink Floyd"
|
||||||
|
}
|
||||||
29
api_examples/create_tv.json
Normal file
29
api_examples/create_tv.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
30
api_examples/get_adult_cast.json
Normal file
30
api_examples/get_adult_cast.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
32
api_examples/get_adult_cast_single.json
Normal file
32
api_examples/get_adult_cast_single.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
api_examples/get_cast.json
Normal file
20
api_examples/get_cast.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
27
api_examples/get_cast_media.json
Normal file
27
api_examples/get_cast_media.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
36
api_examples/get_cast_single.json
Normal file
36
api_examples/get_cast_single.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
29
api_examples/get_episodes.json
Normal file
29
api_examples/get_episodes.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
30
api_examples/get_media.json
Normal file
30
api_examples/get_media.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
39
api_examples/get_media_single.json
Normal file
39
api_examples/get_media_single.json
Normal 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"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
23
api_examples/get_tracks.json
Normal file
23
api_examples/get_tracks.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
8
api_examples/update_adult_cast.json
Normal file
8
api_examples/update_adult_cast.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "Jane Smith (Updated)",
|
||||||
|
"bio": "Updated bio",
|
||||||
|
"adult_specifics": {
|
||||||
|
"hair_color": "Red",
|
||||||
|
"weight": "56"
|
||||||
|
}
|
||||||
|
}
|
||||||
4
api_examples/update_cast.json
Normal file
4
api_examples/update_cast.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"name": "Tom Hardy (Updated)",
|
||||||
|
"bio": "Updated bio description"
|
||||||
|
}
|
||||||
4
api_examples/update_episode.json
Normal file
4
api_examples/update_episode.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"title": "Updated Episode Title",
|
||||||
|
"description": "Updated description"
|
||||||
|
}
|
||||||
5
api_examples/update_media.json
Normal file
5
api_examples/update_media.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"title": "The Matrix (Updated)",
|
||||||
|
"rating": 8.8,
|
||||||
|
"status": "Released"
|
||||||
|
}
|
||||||
4
api_examples/update_track.json
Normal file
4
api_examples/update_track.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"title": "Updated Track Title",
|
||||||
|
"duration": "4:00"
|
||||||
|
}
|
||||||
3752
public/adult.json
3752
public/adult.json
File diff suppressed because one or more lines are too long
63
src/App.tsx
63
src/App.tsx
@@ -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;
|
||||||
|
|||||||
618
src/api.ts
618
src/api.ts
@@ -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;
|
id: number;
|
||||||
xbvr_url: string | null;
|
name: string;
|
||||||
cast: string[];
|
photo: string | null;
|
||||||
actors: Array<{
|
bio: string | null;
|
||||||
id: number;
|
birthDate: string | null;
|
||||||
name: string;
|
birthPlace: string | null;
|
||||||
thumbnail_path: string | null;
|
role: string;
|
||||||
}>;
|
characterName: string | null;
|
||||||
tags: string[];
|
characterImage: string | null;
|
||||||
is_available: boolean;
|
occupations?: string[];
|
||||||
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 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 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 [];
|
return [];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching media by actor:', error);
|
console.error('Error fetching cast from API:', error);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchMediaByTag(tag: string): Promise<Media[]> {
|
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) {
|
|
||||||
return data.data.items
|
if (data.success && data.data) {
|
||||||
.filter(item => {
|
return data.data;
|
||||||
try {
|
}
|
||||||
const metadata = JSON.parse(item.metadata);
|
return null;
|
||||||
return metadata.tags?.some(t => t.toLowerCase().includes(tag.toLowerCase()));
|
} catch (error) {
|
||||||
} catch {
|
console.error('Error fetching cast by ID:', error);
|
||||||
return false;
|
return null;
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
.map(convertApiToMedia);
|
|
||||||
|
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) {
|
} catch (error) {
|
||||||
console.error('Error fetching media by tag:', error);
|
console.error('Error fetching cast media:', error);
|
||||||
return [];
|
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 {
|
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) {
|
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)) {
|
return null;
|
||||||
actorMap.set(actor.id, {
|
} catch (error) {
|
||||||
id: actor.id,
|
console.error('Error creating cast:', error);
|
||||||
name: actor.name,
|
return null;
|
||||||
thumbnail_path: actor.thumbnail_path
|
}
|
||||||
});
|
}
|
||||||
}
|
|
||||||
});
|
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) {
|
} 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) {
|
const tagSet = new Set<string>();
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
|
||||||
}
|
media.forEach(item => {
|
||||||
const data: ApiResponse = await response.json();
|
item.tags?.forEach(tag => tagSet.add(tag));
|
||||||
if (data.data.items) {
|
item.genres?.forEach(genre => tagSet.add(genre));
|
||||||
const tagSet = new Set<string>();
|
});
|
||||||
data.data.items.forEach(item => {
|
|
||||||
try {
|
return Array.from(tagSet).sort();
|
||||||
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 [];
|
|
||||||
} 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();
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 = {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
*/
|
||||||
Reference in New Issue
Block a user