Compare commits
2 Commits
f5c3e96823
...
07c3270e12
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07c3270e12 | ||
|
|
04156486e2 |
@@ -3,6 +3,9 @@
|
|||||||
# Used for self-referential links, OAuth callbacks, and API endpoints.
|
# Used for self-referential links, OAuth callbacks, and API endpoints.
|
||||||
APP_URL="MY_APP_URL"
|
APP_URL="MY_APP_URL"
|
||||||
|
|
||||||
|
# Backend API URL
|
||||||
|
VITE_API_URL="http://192.168.1.102:6400"
|
||||||
|
|
||||||
# Importer Configurations
|
# Importer Configurations
|
||||||
# XBVR Importer
|
# XBVR Importer
|
||||||
VITE_XBVR_URL=""
|
VITE_XBVR_URL=""
|
||||||
|
|||||||
62
src/App.tsx
62
src/App.tsx
@@ -13,9 +13,10 @@ import CastView from './components/CastView';
|
|||||||
import CastDetailView from './components/CastDetailView';
|
import CastDetailView from './components/CastDetailView';
|
||||||
import AddMediaView from './components/AddMediaView';
|
import AddMediaView from './components/AddMediaView';
|
||||||
import ImporterView from './components/ImporterView';
|
import ImporterView from './components/ImporterView';
|
||||||
|
import SettingsView from './components/SettingsView';
|
||||||
import { MOCK_MEDIA, DETAIL_MEDIA } from './data';
|
import { MOCK_MEDIA, DETAIL_MEDIA } from './data';
|
||||||
import { Media, Staff, MediaCategory } from './types';
|
import { Media, Staff, MediaCategory, UserSettings } from './types';
|
||||||
import { fetchAllMedia, fetchMediaById, fetchCastById, convertApiCastToStaff } from './api';
|
import { fetchAllMedia, fetchMediaById, fetchCastById, convertApiCastToStaff, fetchSettings, updateSettings } from './api';
|
||||||
|
|
||||||
function AppContent() {
|
function AppContent() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -28,12 +29,41 @@ function AppContent() {
|
|||||||
const [selectedPerson, setSelectedPerson] = useState<Staff | null>(null);
|
const [selectedPerson, setSelectedPerson] = useState<Staff | null>(null);
|
||||||
const [searchQuery, setSearchQuery] = useState(searchParams.get('search') || '');
|
const [searchQuery, setSearchQuery] = useState(searchParams.get('search') || '');
|
||||||
const [enabledCategories, setEnabledCategories] = useState<MediaCategory[]>(['Anime', 'Movies', 'TV Series', 'Music', 'Books', 'Consoles', 'Games', 'Adult']);
|
const [enabledCategories, setEnabledCategories] = useState<MediaCategory[]>(['Anime', 'Movies', 'TV Series', 'Music', 'Books', 'Consoles', 'Games', 'Adult']);
|
||||||
|
const [settings, setSettings] = useState<UserSettings | null>(null);
|
||||||
const [customMedia, setCustomMedia] = useState<Media[]>([]);
|
const [customMedia, setCustomMedia] = useState<Media[]>([]);
|
||||||
const [adultMedia, setAdultMedia] = useState<Media[]>([]);
|
const [adultMedia, setAdultMedia] = useState<Media[]>([]);
|
||||||
|
|
||||||
// Load media from API on component mount (only when not on cast routes)
|
// Load media from API on component mount (only when not on cast routes)
|
||||||
const [apiMedia, setApiMedia] = useState<Media[]>([]);
|
const [apiMedia, setApiMedia] = useState<Media[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadSettingsFromApi = async () => {
|
||||||
|
try {
|
||||||
|
const loadedSettings = await fetchSettings();
|
||||||
|
if (loadedSettings) {
|
||||||
|
setSettings(loadedSettings);
|
||||||
|
setEnabledCategories(loadedSettings.enabledCategories);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load settings from API:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadSettingsFromApi();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const reloadSettings = async () => {
|
||||||
|
try {
|
||||||
|
const loadedSettings = await fetchSettings();
|
||||||
|
if (loadedSettings) {
|
||||||
|
setSettings(loadedSettings);
|
||||||
|
setEnabledCategories(loadedSettings.enabledCategories);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to reload settings from API:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadMediaFromApi = async () => {
|
const loadMediaFromApi = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -50,7 +80,7 @@ function AppContent() {
|
|||||||
}
|
}
|
||||||
}, [location.pathname]);
|
}, [location.pathname]);
|
||||||
|
|
||||||
const toggleCategory = (category: MediaCategory) => {
|
const toggleCategory = async (category: MediaCategory) => {
|
||||||
setEnabledCategories(prev => {
|
setEnabledCategories(prev => {
|
||||||
const isEnabling = !prev.includes(category);
|
const isEnabling = !prev.includes(category);
|
||||||
const newList = isEnabling
|
const newList = isEnabling
|
||||||
@@ -62,6 +92,27 @@ function AppContent() {
|
|||||||
const nextCategory = newList.find(c => c !== category) || 'Anime';
|
const nextCategory = newList.find(c => c !== category) || 'Anime';
|
||||||
setActiveCategory(nextCategory as MediaCategory);
|
setActiveCategory(nextCategory as MediaCategory);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Save to API
|
||||||
|
const baseSettings = settings || {
|
||||||
|
enabledCategories: prev,
|
||||||
|
itemsPerPage: 20,
|
||||||
|
defaultView: 'grid',
|
||||||
|
showAdultContent: false,
|
||||||
|
autoPlayTrailers: false,
|
||||||
|
language: 'en',
|
||||||
|
theme: 'system',
|
||||||
|
};
|
||||||
|
const updatedSettings: UserSettings = {
|
||||||
|
...baseSettings,
|
||||||
|
enabledCategories: newList,
|
||||||
|
};
|
||||||
|
updateSettings(updatedSettings).then(saved => {
|
||||||
|
if (saved) {
|
||||||
|
setSettings(saved);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return newList;
|
return newList;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -237,6 +288,7 @@ function AppContent() {
|
|||||||
mediaList={filteredMedia}
|
mediaList={filteredMedia}
|
||||||
onMediaClick={handleMediaClick}
|
onMediaClick={handleMediaClick}
|
||||||
activeCategory={activeCategory}
|
activeCategory={activeCategory}
|
||||||
|
itemsPerPage={settings?.itemsPerPage}
|
||||||
/>
|
/>
|
||||||
} />
|
} />
|
||||||
<Route path="/media/:id" element={
|
<Route path="/media/:id" element={
|
||||||
@@ -251,6 +303,7 @@ function AppContent() {
|
|||||||
<CastView
|
<CastView
|
||||||
onPersonClick={handlePersonClick}
|
onPersonClick={handlePersonClick}
|
||||||
enabledCategories={enabledCategories}
|
enabledCategories={enabledCategories}
|
||||||
|
itemsPerPage={settings?.itemsPerPage}
|
||||||
/>
|
/>
|
||||||
} />
|
} />
|
||||||
<Route path="/cast/:id" element={
|
<Route path="/cast/:id" element={
|
||||||
@@ -268,6 +321,9 @@ function AppContent() {
|
|||||||
<Route path="/import" element={
|
<Route path="/import" element={
|
||||||
<ImporterView />
|
<ImporterView />
|
||||||
} />
|
} />
|
||||||
|
<Route path="/settings" element={
|
||||||
|
<SettingsView onSettingsSaved={reloadSettings} />
|
||||||
|
} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</LayoutGroup>
|
</LayoutGroup>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
145
src/api.ts
145
src/api.ts
@@ -1,6 +1,6 @@
|
|||||||
import { Media, Staff } from './types';
|
import { Media, Staff, UserSettings, MediaCategory } from './types';
|
||||||
|
|
||||||
const BASE_URL = 'http://192.168.1.102:6400';
|
const BASE_URL = import.meta.env.VITE_API_URL;
|
||||||
|
|
||||||
function normalizeUrl(url: string | null): string {
|
function normalizeUrl(url: string | null): string {
|
||||||
if (!url) return '';
|
if (!url) return '';
|
||||||
@@ -625,3 +625,144 @@ export async function fetchMediaFromApi(apiUrl?: string): Promise<Media[]> {
|
|||||||
export async function fetchMediaFromLocalJson(): Promise<Media[]> {
|
export async function fetchMediaFromLocalJson(): Promise<Media[]> {
|
||||||
return fetchAllMedia();
|
return fetchAllMedia();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Settings API Types
|
||||||
|
export interface ApiSettingsItem {
|
||||||
|
id?: number;
|
||||||
|
enabled_categories: string[];
|
||||||
|
items_per_page: number;
|
||||||
|
default_view: string;
|
||||||
|
show_adult_content: boolean;
|
||||||
|
auto_play_trailers: boolean;
|
||||||
|
language: string;
|
||||||
|
theme: string;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateSettingsInput {
|
||||||
|
enabled_categories: string[];
|
||||||
|
items_per_page?: number;
|
||||||
|
default_view?: string;
|
||||||
|
show_adult_content?: boolean;
|
||||||
|
auto_play_trailers?: boolean;
|
||||||
|
language?: string;
|
||||||
|
theme?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateSettingsInput extends Partial<CreateSettingsInput> {}
|
||||||
|
|
||||||
|
export function convertApiToSettings(apiItem: ApiSettingsItem): UserSettings {
|
||||||
|
return {
|
||||||
|
id: apiItem.id,
|
||||||
|
enabledCategories: apiItem.enabled_categories as MediaCategory[],
|
||||||
|
itemsPerPage: apiItem.items_per_page || 20,
|
||||||
|
defaultView: (apiItem.default_view as 'grid' | 'list') || 'grid',
|
||||||
|
showAdultContent: apiItem.show_adult_content || false,
|
||||||
|
autoPlayTrailers: apiItem.auto_play_trailers || false,
|
||||||
|
language: apiItem.language || 'en',
|
||||||
|
theme: (apiItem.theme as 'light' | 'dark' | 'system') || 'system',
|
||||||
|
createdAt: apiItem.created_at,
|
||||||
|
updatedAt: apiItem.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function convertSettingsToApi(settings: UserSettings): CreateSettingsInput {
|
||||||
|
return {
|
||||||
|
enabled_categories: settings.enabledCategories,
|
||||||
|
items_per_page: settings.itemsPerPage,
|
||||||
|
default_view: settings.defaultView,
|
||||||
|
show_adult_content: settings.showAdultContent,
|
||||||
|
auto_play_trailers: settings.autoPlayTrailers,
|
||||||
|
language: settings.language,
|
||||||
|
theme: settings.theme,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings API Functions
|
||||||
|
export async function fetchSettings(): Promise<UserSettings | null> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${BASE_URL}/api/settings`);
|
||||||
|
if (!response.ok) {
|
||||||
|
// If settings don't exist (404), return null to use defaults
|
||||||
|
if (response.status === 404) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
const data: ApiResponse<ApiSettingsItem> = await response.json();
|
||||||
|
|
||||||
|
if (data.success && data.data) {
|
||||||
|
return convertApiToSettings(data.data);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching settings:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSettings(settings: UserSettings): Promise<UserSettings | null> {
|
||||||
|
try {
|
||||||
|
const apiSettings = convertSettingsToApi(settings);
|
||||||
|
console.log('Creating settings:', apiSettings);
|
||||||
|
const response = await fetch(`${BASE_URL}/api/settings`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(apiSettings),
|
||||||
|
});
|
||||||
|
console.log('Create settings response status:', response.status);
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
console.error('Create settings error response:', errorText);
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
const data: ApiResponse<ApiSettingsItem> = await response.json();
|
||||||
|
console.log('Create settings response:', data);
|
||||||
|
|
||||||
|
if (data.success && data.data) {
|
||||||
|
return convertApiToSettings(data.data);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating settings:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSettings(settings: UserSettings): Promise<UserSettings | null> {
|
||||||
|
try {
|
||||||
|
const apiSettings = convertSettingsToApi(settings);
|
||||||
|
console.log('Updating settings:', apiSettings);
|
||||||
|
const response = await fetch(`${BASE_URL}/api/settings`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(apiSettings),
|
||||||
|
});
|
||||||
|
console.log('Update settings response status:', response.status);
|
||||||
|
if (!response.ok) {
|
||||||
|
// If settings don't exist (404), try creating them instead
|
||||||
|
if (response.status === 404) {
|
||||||
|
console.log('Settings not found, attempting to create...');
|
||||||
|
return createSettings(settings);
|
||||||
|
}
|
||||||
|
const errorText = await response.text();
|
||||||
|
console.error('Update settings error response:', errorText);
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
const data: ApiResponse<ApiSettingsItem> = await response.json();
|
||||||
|
console.log('Update settings response:', data);
|
||||||
|
|
||||||
|
if (data.success && data.data) {
|
||||||
|
return convertApiToSettings(data.data);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating settings:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,12 +17,13 @@ interface BrowseViewProps {
|
|||||||
mediaList: Media[];
|
mediaList: Media[];
|
||||||
onMediaClick: (media: Media) => void;
|
onMediaClick: (media: Media) => void;
|
||||||
activeCategory: MediaCategory;
|
activeCategory: MediaCategory;
|
||||||
|
itemsPerPage?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function BrowseView({ mediaList, onMediaClick, activeCategory }: BrowseViewProps) {
|
export default function BrowseView({ mediaList, onMediaClick, activeCategory, itemsPerPage: initialItemsPerPage = 12 }: BrowseViewProps) {
|
||||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [itemsPerPage, setItemsPerPage] = useState(12);
|
const [itemsPerPage, setItemsPerPage] = useState(initialItemsPerPage);
|
||||||
const [sortBy, setSortBy] = useState<string>('default');
|
const [sortBy, setSortBy] = useState<string>('default');
|
||||||
|
|
||||||
// Filter states
|
// Filter states
|
||||||
@@ -281,7 +282,7 @@ export default function BrowseView({ mediaList, onMediaClick, activeCategory }:
|
|||||||
}}
|
}}
|
||||||
className="bg-zinc-100 border-none rounded-md px-2 py-1 text-sm font-bold text-zinc-700 focus:ring-2 focus:ring-[#6d28d9] outline-none"
|
className="bg-zinc-100 border-none rounded-md px-2 py-1 text-sm font-bold text-zinc-700 focus:ring-2 focus:ring-[#6d28d9] outline-none"
|
||||||
>
|
>
|
||||||
{[8, 12, 16, 24, 48].map(size => (
|
{[12, 20, 36, 48, 60].map(size => (
|
||||||
<option key={size} value={size}>{size}</option>
|
<option key={size} value={size}>{size}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -12,9 +12,10 @@ import { fetchAllCast } from '@/api';
|
|||||||
interface CastViewProps {
|
interface CastViewProps {
|
||||||
onPersonClick: (person: Staff) => void;
|
onPersonClick: (person: Staff) => void;
|
||||||
enabledCategories: MediaCategory[];
|
enabledCategories: MediaCategory[];
|
||||||
|
itemsPerPage?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CastView({ onPersonClick, enabledCategories }: CastViewProps) {
|
export default function CastView({ onPersonClick, enabledCategories, itemsPerPage: initialItemsPerPage = 12 }: CastViewProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [staffList, setStaffList] = useState<Staff[]>([]);
|
const [staffList, setStaffList] = useState<Staff[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -34,7 +35,7 @@ export default function CastView({ onPersonClick, enabledCategories }: CastViewP
|
|||||||
return localStorage.getItem('castFilterMediaType') || '';
|
return localStorage.getItem('castFilterMediaType') || '';
|
||||||
});
|
});
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [itemsPerPage, setItemsPerPage] = useState(12);
|
const [itemsPerPage, setItemsPerPage] = useState(initialItemsPerPage);
|
||||||
const [showFilters, setShowFilters] = useState(false);
|
const [showFilters, setShowFilters] = useState(false);
|
||||||
|
|
||||||
// Persist filters and sorts
|
// Persist filters and sorts
|
||||||
@@ -382,7 +383,7 @@ export default function CastView({ onPersonClick, enabledCategories }: CastViewP
|
|||||||
}}
|
}}
|
||||||
className="bg-zinc-100 border-none rounded-md px-2 py-1 text-sm font-bold text-zinc-700 focus:ring-2 focus:ring-[#6d28d9] outline-none"
|
className="bg-zinc-100 border-none rounded-md px-2 py-1 text-sm font-bold text-zinc-700 focus:ring-2 focus:ring-[#6d28d9] outline-none"
|
||||||
>
|
>
|
||||||
{[8, 12, 16, 24, 48].map(size => (
|
{[12, 20, 36, 48, 60].map(size => (
|
||||||
<option key={size} value={size}>{size}</option>
|
<option key={size} value={size}>{size}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { Search, User, X, Plus, Download } from 'lucide-react';
|
import { Search, User, X, Plus, Download, Settings } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Link, NavLink } from 'react-router-dom';
|
import { Link, NavLink } from 'react-router-dom';
|
||||||
import { MediaCategory } from '@/types';
|
import { MediaCategory } from '@/types';
|
||||||
import LibrarySettings from './LibrarySettings';
|
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
onSearch: (query: string) => void;
|
onSearch: (query: string) => void;
|
||||||
@@ -113,10 +112,12 @@ export default function Header({
|
|||||||
>
|
>
|
||||||
<Download size={20} />
|
<Download size={20} />
|
||||||
</Link>
|
</Link>
|
||||||
<LibrarySettings
|
<Link
|
||||||
enabledCategories={enabledCategories}
|
to="/settings"
|
||||||
onToggleCategory={onToggleCategory}
|
className="p-2 text-white/90 hover:text-white transition-colors"
|
||||||
/>
|
>
|
||||||
|
<Settings size={20} />
|
||||||
|
</Link>
|
||||||
<button className="w-8 h-8 rounded-full overflow-hidden border-2 border-white/20">
|
<button className="w-8 h-8 rounded-full overflow-hidden border-2 border-white/20">
|
||||||
<img
|
<img
|
||||||
src="https://picsum.photos/seed/user/100/100"
|
src="https://picsum.photos/seed/user/100/100"
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ import { importFromXBVR, XBVRConfig, ImportProgress } from '@/lib/xbvrImporter';
|
|||||||
import { importFromStashAPP, StashAPPConfig, updateActorsFromStashAPP } from '@/lib/stashappImporter';
|
import { importFromStashAPP, StashAPPConfig, updateActorsFromStashAPP } from '@/lib/stashappImporter';
|
||||||
import { importFromPlaynite, PlayniteConfig } from '@/lib/playniteImporter';
|
import { importFromPlaynite, PlayniteConfig } from '@/lib/playniteImporter';
|
||||||
|
|
||||||
|
const BASE_URL = import.meta.env.VITE_BASE_URL || 'http://localhost:3000';
|
||||||
|
|
||||||
export default function ImporterView() {
|
export default function ImporterView() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [xbvrConfig, setXbvrConfig] = useState<XBVRConfig>({ url: import.meta.env.VITE_XBVR_URL || '' });
|
const [xbvrConfig, setXbvrConfig] = useState<XBVRConfig>({ url: import.meta.env.VITE_XBVR_URL || BASE_URL });
|
||||||
const [stashappConfig, setStashappConfig] = useState<StashAPPConfig>({
|
const [stashappConfig, setStashappConfig] = useState<StashAPPConfig>({
|
||||||
url: import.meta.env.VITE_STASHAPP_URL || '',
|
url: import.meta.env.VITE_STASHAPP_URL || '',
|
||||||
apiKey: import.meta.env.VITE_STASHAPP_API_KEY || ''
|
apiKey: import.meta.env.VITE_STASHAPP_API_KEY || ''
|
||||||
|
|||||||
326
src/components/SettingsView.tsx
Normal file
326
src/components/SettingsView.tsx
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { MediaCategory, UserSettings } from '@/types';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Film, Music, Book, Tv, Gamepad2, ShieldAlert, LayoutGrid, List, Globe, Monitor, Sun, Moon, Save, ArrowLeft } from 'lucide-react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { fetchSettings, updateSettings } from '@/api';
|
||||||
|
|
||||||
|
const CATEGORY_ICONS: Record<MediaCategory, React.ReactNode> = {
|
||||||
|
Anime: <Tv size={18} />,
|
||||||
|
Movies: <Film size={18} />,
|
||||||
|
'TV Series': <Tv size={18} />,
|
||||||
|
Music: <Music size={18} />,
|
||||||
|
Books: <Book size={18} />,
|
||||||
|
Consoles: <Gamepad2 size={18} />,
|
||||||
|
Games: <Gamepad2 size={18} />,
|
||||||
|
Adult: <ShieldAlert size={18} />,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ITEMS_PER_PAGE_OPTIONS = [12, 20, 36, 48, 60];
|
||||||
|
const LANGUAGE_OPTIONS = [
|
||||||
|
{ value: 'en', label: 'English' },
|
||||||
|
{ value: 'de', label: 'Deutsch' },
|
||||||
|
{ value: 'es', label: 'Español' },
|
||||||
|
{ value: 'fr', label: 'Français' },
|
||||||
|
{ value: 'ja', label: '日本語' },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface SettingsViewProps {
|
||||||
|
onSettingsSaved?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SettingsView({ onSettingsSaved }: SettingsViewProps) {
|
||||||
|
const [settings, setSettings] = useState<UserSettings>({
|
||||||
|
enabledCategories: ['Anime', 'Movies', 'TV Series', 'Music', 'Books', 'Consoles', 'Games', 'Adult'],
|
||||||
|
itemsPerPage: 20,
|
||||||
|
defaultView: 'grid',
|
||||||
|
showAdultContent: false,
|
||||||
|
autoPlayTrailers: false,
|
||||||
|
language: 'en',
|
||||||
|
theme: 'system',
|
||||||
|
});
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [saveStatus, setSaveStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadSettings();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadSettings = async () => {
|
||||||
|
try {
|
||||||
|
const loadedSettings = await fetchSettings();
|
||||||
|
if (loadedSettings) {
|
||||||
|
setSettings(loadedSettings);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load settings:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setIsSaving(true);
|
||||||
|
setSaveStatus('idle');
|
||||||
|
try {
|
||||||
|
const savedSettings = await updateSettings(settings);
|
||||||
|
if (savedSettings) {
|
||||||
|
setSettings(savedSettings);
|
||||||
|
setSaveStatus('success');
|
||||||
|
onSettingsSaved?.();
|
||||||
|
} else {
|
||||||
|
setSaveStatus('error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save settings:', error);
|
||||||
|
setSaveStatus('error');
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
setTimeout(() => setSaveStatus('idle'), 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleCategory = (category: MediaCategory) => {
|
||||||
|
setSettings(prev => ({
|
||||||
|
...prev,
|
||||||
|
enabledCategories: prev.enabledCategories.includes(category)
|
||||||
|
? prev.enabledCategories.filter(c => c !== category)
|
||||||
|
: [...prev.enabledCategories, category]
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-white flex items-center justify-center">
|
||||||
|
<div className="text-zinc-400 font-medium">Loading settings...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-white pt-20">
|
||||||
|
{/* Content */}
|
||||||
|
<div className="max-w-[1600px] mx-auto px-6 py-12">
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<div>
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="inline-flex items-center gap-2 text-sm font-bold text-zinc-400 hover:text-[#6d28d9] transition-colors mb-2"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={16} />
|
||||||
|
Back to home
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-3xl font-black text-zinc-900">Settings</h1>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="bg-[#6d28d9] text-white hover:bg-[#5b21b6] font-bold px-6 py-3 h-12 rounded-lg flex items-center gap-2 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isSaving ? (
|
||||||
|
'Saving...'
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Save size={16} />
|
||||||
|
Save Changes
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{saveStatus === 'success' && (
|
||||||
|
<div className="mb-6 p-4 bg-green-50 border border-green-200 rounded-xl text-green-700 font-medium">
|
||||||
|
Settings saved successfully!
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{saveStatus === 'error' && (
|
||||||
|
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-xl text-red-700 font-medium">
|
||||||
|
Failed to save settings. Please try again.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid gap-8">
|
||||||
|
{/* Library Settings */}
|
||||||
|
<section>
|
||||||
|
<h2 className="text-xl font-black text-zinc-900 mb-6">Library Settings</h2>
|
||||||
|
<div className="bg-zinc-50 rounded-2xl p-6 border border-zinc-100">
|
||||||
|
<p className="text-sm font-medium text-zinc-500 mb-4">
|
||||||
|
Toggle which media areas you want to see in your library.
|
||||||
|
</p>
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{(['Anime', 'Movies', 'Music', 'Books', 'Consoles', 'Games', 'Adult'] as MediaCategory[]).map((category) => (
|
||||||
|
<div key={category} className="flex items-center justify-between p-4 rounded-xl bg-white border border-zinc-100 transition-all hover:border-[#6d28d9]/20">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-zinc-50 flex items-center justify-center text-[#6d28d9]">
|
||||||
|
{CATEGORY_ICONS[category]}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor={category} className="text-sm font-black text-zinc-900 cursor-pointer">
|
||||||
|
{category}
|
||||||
|
</Label>
|
||||||
|
<p className="text-[10px] font-bold text-zinc-400 uppercase tracking-widest">
|
||||||
|
{settings.enabledCategories.includes(category) ? 'Enabled' : 'Disabled'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id={category}
|
||||||
|
checked={settings.enabledCategories.includes(category)}
|
||||||
|
onCheckedChange={() => toggleCategory(category)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Display Settings */}
|
||||||
|
<section>
|
||||||
|
<h2 className="text-xl font-black text-zinc-900 mb-6">Display Settings</h2>
|
||||||
|
<div className="bg-zinc-50 rounded-2xl p-6 border border-zinc-100 space-y-6">
|
||||||
|
{/* Items per page */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm font-black text-zinc-900 mb-2 block">Items per page</Label>
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{ITEMS_PER_PAGE_OPTIONS.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option}
|
||||||
|
onClick={() => setSettings(prev => ({ ...prev, itemsPerPage: option }))}
|
||||||
|
className={`px-4 py-2 rounded-lg text-sm font-bold transition-all ${
|
||||||
|
settings.itemsPerPage === option
|
||||||
|
? 'bg-[#6d28d9] text-white'
|
||||||
|
: 'bg-white text-zinc-600 hover:bg-zinc-100 border border-zinc-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{option}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Default view */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm font-black text-zinc-900 mb-2 block">Default view</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setSettings(prev => ({ ...prev, defaultView: 'grid' }))}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-lg text-sm font-bold transition-all ${
|
||||||
|
settings.defaultView === 'grid'
|
||||||
|
? 'bg-[#6d28d9] text-white'
|
||||||
|
: 'bg-white text-zinc-600 hover:bg-zinc-100 border border-zinc-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<LayoutGrid size={18} />
|
||||||
|
Grid
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setSettings(prev => ({ ...prev, defaultView: 'list' }))}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-lg text-sm font-bold transition-all ${
|
||||||
|
settings.defaultView === 'list'
|
||||||
|
? 'bg-[#6d28d9] text-white'
|
||||||
|
: 'bg-white text-zinc-600 hover:bg-zinc-100 border border-zinc-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<List size={18} />
|
||||||
|
List
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Theme */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm font-black text-zinc-900 mb-2 block">Theme</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{(['light', 'dark', 'system'] as const).map((theme) => (
|
||||||
|
<button
|
||||||
|
key={theme}
|
||||||
|
onClick={() => setSettings(prev => ({ ...prev, theme }))}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-lg text-sm font-bold transition-all ${
|
||||||
|
settings.theme === theme
|
||||||
|
? 'bg-[#6d28d9] text-white'
|
||||||
|
: 'bg-white text-zinc-600 hover:bg-zinc-100 border border-zinc-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{theme === 'light' && <Sun size={18} />}
|
||||||
|
{theme === 'dark' && <Moon size={18} />}
|
||||||
|
{theme === 'system' && <Monitor size={18} />}
|
||||||
|
{theme.charAt(0).toUpperCase() + theme.slice(1)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Content Settings */}
|
||||||
|
<section>
|
||||||
|
<h2 className="text-xl font-black text-zinc-900 mb-6">Content Settings</h2>
|
||||||
|
<div className="bg-zinc-50 rounded-2xl p-6 border border-zinc-100 space-y-4">
|
||||||
|
{/* Show adult content */}
|
||||||
|
<div className="flex items-center justify-between p-4 rounded-xl bg-white border border-zinc-100">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="showAdult" className="text-sm font-black text-zinc-900 cursor-pointer">
|
||||||
|
Show adult content
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs font-medium text-zinc-500 mt-1">
|
||||||
|
Display adult media in your library
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="showAdult"
|
||||||
|
checked={settings.showAdultContent}
|
||||||
|
onCheckedChange={(checked) => setSettings(prev => ({ ...prev, showAdultContent: checked }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Auto-play trailers */}
|
||||||
|
<div className="flex items-center justify-between p-4 rounded-xl bg-white border border-zinc-100">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="autoPlay" className="text-sm font-black text-zinc-900 cursor-pointer">
|
||||||
|
Auto-play trailers
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs font-medium text-zinc-500 mt-1">
|
||||||
|
Automatically play trailers when viewing media
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="autoPlay"
|
||||||
|
checked={settings.autoPlayTrailers}
|
||||||
|
onCheckedChange={(checked) => setSettings(prev => ({ ...prev, autoPlayTrailers: checked }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Language Settings */}
|
||||||
|
<section>
|
||||||
|
<h2 className="text-xl font-black text-zinc-900 mb-6">Language</h2>
|
||||||
|
<div className="bg-zinc-50 rounded-2xl p-6 border border-zinc-100">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<Globe size={18} className="text-[#6d28d9]" />
|
||||||
|
<Label className="text-sm font-black text-zinc-900">Interface language</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{LANGUAGE_OPTIONS.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
onClick={() => setSettings(prev => ({ ...prev, language: option.value }))}
|
||||||
|
className={`px-4 py-2 rounded-lg text-sm font-bold transition-all ${
|
||||||
|
settings.language === option.value
|
||||||
|
? 'bg-[#6d28d9] text-white'
|
||||||
|
: 'bg-white text-zinc-600 hover:bg-zinc-100 border border-zinc-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
const BASE_URL = import.meta.env.VITE_API_URL;
|
||||||
|
|
||||||
export interface PlayniteConfig {
|
export interface PlayniteConfig {
|
||||||
ip: string;
|
ip: string;
|
||||||
apiToken: string;
|
apiToken: string;
|
||||||
@@ -108,7 +110,7 @@ export async function importFromPlaynite(
|
|||||||
|
|
||||||
// Step 0: Fetch existing media to check for duplicates and enable updates
|
// Step 0: Fetch existing media to check for duplicates and enable updates
|
||||||
logCallback('Fetching existing media from Kyoo API...');
|
logCallback('Fetching existing media from Kyoo API...');
|
||||||
const existingMediaResponse = await fetch('http://192.168.1.102:6400/api/media?limit=1000');
|
const existingMediaResponse = await fetch(`${BASE_URL}/api/media?limit=1000`);
|
||||||
const existingMediaData = await existingMediaResponse.json();
|
const existingMediaData = await existingMediaResponse.json();
|
||||||
const existingMedia = new Map(
|
const existingMedia = new Map(
|
||||||
(existingMediaData.data?.items || []).map((m: any) => [m.title, m])
|
(existingMediaData.data?.items || []).map((m: any) => [m.title, m])
|
||||||
@@ -279,13 +281,13 @@ export async function importFromPlaynite(
|
|||||||
|
|
||||||
let response;
|
let response;
|
||||||
if (isUpdate) {
|
if (isUpdate) {
|
||||||
response = await fetch(`http://192.168.1.102:6400/api/media/${(existingGame as any).id}`, {
|
response = await fetch(`${BASE_URL}/api/media/${(existingGame as any).id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(mediaData)
|
body: JSON.stringify(mediaData)
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
response = await fetch('http://192.168.1.102:6400/api/media', {
|
response = await fetch(`${BASE_URL}/api/media`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(mediaData)
|
body: JSON.stringify(mediaData)
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
const BASE_URL = import.meta.env.VITE_API_URL;
|
||||||
|
|
||||||
export interface StashAPPConfig {
|
export interface StashAPPConfig {
|
||||||
url: string;
|
url: string;
|
||||||
apiKey?: string;
|
apiKey?: string;
|
||||||
@@ -155,7 +157,7 @@ export async function updateActorsFromStashAPP(
|
|||||||
|
|
||||||
// Fetch existing cast from Kyoo API
|
// Fetch existing cast from Kyoo API
|
||||||
logCallback('Fetching existing cast from Kyoo API...');
|
logCallback('Fetching existing cast from Kyoo API...');
|
||||||
const existingCastResponse = await fetch('http://192.168.1.102:6400/api/cast');
|
const existingCastResponse = await fetch(`${BASE_URL}/api/cast`);
|
||||||
const existingCastData = await existingCastResponse.json();
|
const existingCastData = await existingCastResponse.json();
|
||||||
const existingActors = new Map(
|
const existingActors = new Map(
|
||||||
(existingCastData.data?.items || []).map((c: any) => [c.name, c])
|
(existingCastData.data?.items || []).map((c: any) => [c.name, c])
|
||||||
@@ -274,7 +276,7 @@ export async function updateActorsFromStashAPP(
|
|||||||
updateData.birthPlace = performer.country;
|
updateData.birthPlace = performer.country;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`http://192.168.1.102:6400/api/cast/${existingActor.id}`, {
|
const response = await fetch(`${BASE_URL}/api/cast/${existingActor.id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(updateData)
|
body: JSON.stringify(updateData)
|
||||||
@@ -290,7 +292,7 @@ export async function updateActorsFromStashAPP(
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Create new actor
|
// Create new actor
|
||||||
const response = await fetch('http://192.168.1.102:6400/api/cast/adult', {
|
const response = await fetch(`${BASE_URL}/api/cast/adult`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -377,7 +379,7 @@ export async function importFromStashAPP(
|
|||||||
|
|
||||||
// Step 0: Fetch existing media and cast to check for duplicates
|
// Step 0: Fetch existing media and cast to check for duplicates
|
||||||
logCallback('Fetching existing media from Kyoo API...');
|
logCallback('Fetching existing media from Kyoo API...');
|
||||||
const existingMediaResponse = await fetch('http://192.168.1.102:6400/api/media');
|
const existingMediaResponse = await fetch(`${BASE_URL}/api/media`);
|
||||||
const existingMediaData = await existingMediaResponse.json();
|
const existingMediaData = await existingMediaResponse.json();
|
||||||
const existingTitles = new Set(
|
const existingTitles = new Set(
|
||||||
existingMediaData.data?.items?.map((m: any) => m.title) || []
|
existingMediaData.data?.items?.map((m: any) => m.title) || []
|
||||||
@@ -385,7 +387,7 @@ export async function importFromStashAPP(
|
|||||||
logCallback(`Found ${existingTitles.size} existing videos in database`);
|
logCallback(`Found ${existingTitles.size} existing videos in database`);
|
||||||
|
|
||||||
logCallback('Fetching existing cast from Kyoo API...');
|
logCallback('Fetching existing cast from Kyoo API...');
|
||||||
const existingCastResponse = await fetch('http://192.168.1.102:6400/api/cast');
|
const existingCastResponse = await fetch(`${BASE_URL}/api/cast`, {});
|
||||||
const existingCastData = await existingCastResponse.json();
|
const existingCastData = await existingCastResponse.json();
|
||||||
const existingActors = new Map(
|
const existingActors = new Map(
|
||||||
(existingCastData.data?.items || []).map((c: any) => [c.name, c])
|
(existingCastData.data?.items || []).map((c: any) => [c.name, c])
|
||||||
@@ -550,7 +552,7 @@ export async function importFromStashAPP(
|
|||||||
updateData.birthPlace = performer.country;
|
updateData.birthPlace = performer.country;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`http://192.168.1.102:6400/api/cast/${existingActor.id}`, {
|
const response = await fetch(`${BASE_URL}/api/cast/${existingActor.id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(updateData)
|
body: JSON.stringify(updateData)
|
||||||
@@ -566,7 +568,7 @@ export async function importFromStashAPP(
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Create new actor
|
// Create new actor
|
||||||
const response = await fetch('http://192.168.1.102:6400/api/cast/adult', {
|
const response = await fetch(`${BASE_URL}/api/cast/adult`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -706,7 +708,7 @@ export async function importFromStashAPP(
|
|||||||
staff: staff
|
staff: staff
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await fetch('http://192.168.1.102:6400/api/media', {
|
const response = await fetch(`${BASE_URL}/api/media`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(mediaData)
|
body: JSON.stringify(mediaData)
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
const BASE_URL = import.meta.env.VITE_API_URL;
|
||||||
|
|
||||||
export interface XBVRConfig {
|
export interface XBVRConfig {
|
||||||
url: string;
|
url: string;
|
||||||
apiKey?: string;
|
apiKey?: string;
|
||||||
@@ -74,7 +76,7 @@ export async function importFromXBVR(
|
|||||||
|
|
||||||
// Step 0: Fetch existing media and cast to check for duplicates
|
// Step 0: Fetch existing media and cast to check for duplicates
|
||||||
logCallback('Fetching existing media from Kyoo API...');
|
logCallback('Fetching existing media from Kyoo API...');
|
||||||
const existingMediaResponse = await fetch('http://192.168.1.102:6400/api/media?limit=1000');
|
const existingMediaResponse = await fetch(`${BASE_URL}/api/media?limit=1000`);
|
||||||
const existingMediaData = await existingMediaResponse.json();
|
const existingMediaData = await existingMediaResponse.json();
|
||||||
const existingTitles = new Set(
|
const existingTitles = new Set(
|
||||||
existingMediaData.data?.items?.map((m: any) => m.title) || []
|
existingMediaData.data?.items?.map((m: any) => m.title) || []
|
||||||
@@ -82,7 +84,7 @@ export async function importFromXBVR(
|
|||||||
logCallback(`Found ${existingTitles.size} existing videos in database`);
|
logCallback(`Found ${existingTitles.size} existing videos in database`);
|
||||||
|
|
||||||
logCallback('Fetching existing cast from Kyoo API...');
|
logCallback('Fetching existing cast from Kyoo API...');
|
||||||
const existingCastResponse = await fetch('http://192.168.1.102:6400/api/cast?limit=1000');
|
const existingCastResponse = await fetch(`${BASE_URL}/api/cast?limit=1000`);
|
||||||
const existingCastData = await existingCastResponse.json();
|
const existingCastData = await existingCastResponse.json();
|
||||||
const existingActors = new Map(
|
const existingActors = new Map(
|
||||||
(existingCastData.data?.items || []).map((c: any) => [c.name, c])
|
(existingCastData.data?.items || []).map((c: any) => [c.name, c])
|
||||||
@@ -194,7 +196,7 @@ export async function importFromXBVR(
|
|||||||
logCallback(`⊘ Actor already exists: ${actor.name}`);
|
logCallback(`⊘ Actor already exists: ${actor.name}`);
|
||||||
} else {
|
} else {
|
||||||
// Create new actor
|
// Create new actor
|
||||||
const response = await fetch('http://192.168.1.102:6400/api/cast', {
|
const response = await fetch(`${BASE_URL}/api/cast`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -312,7 +314,7 @@ export async function importFromXBVR(
|
|||||||
staff: staff
|
staff: staff
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await fetch('http://192.168.1.102:6400/api/media', {
|
const response = await fetch(`${BASE_URL}/api/media`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(mediaData)
|
body: JSON.stringify(mediaData)
|
||||||
|
|||||||
13
src/types.ts
13
src/types.ts
@@ -95,3 +95,16 @@ export interface AdultSpecifics {
|
|||||||
measurements?: string | null;
|
measurements?: string | null;
|
||||||
shoe_size?: number | null;
|
shoe_size?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UserSettings {
|
||||||
|
id?: number;
|
||||||
|
enabledCategories: MediaCategory[];
|
||||||
|
itemsPerPage: number;
|
||||||
|
defaultView: 'grid' | 'list';
|
||||||
|
showAdultContent: boolean;
|
||||||
|
autoPlayTrailers: boolean;
|
||||||
|
language: string;
|
||||||
|
theme: 'light' | 'dark' | 'system';
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user