Compare commits

...

2 Commits

Author SHA1 Message Date
Lars Behrends
07c3270e12 backend env url 2026-04-10 14:32:54 +02:00
Lars Behrends
04156486e2 Add user settings UI and API integration
Introduce a full user settings feature: add a SettingsView component and UserSettings type, plus API helpers to fetch, create, and update settings (convertors between API and app shapes). App now loads settings on mount, persists category toggles to the API, exposes a /settings route, and passes itemsPerPage into BrowseView and CastView. Header gains a settings icon/link and BrowseView/CastView update pagination option defaults. This enables centralized library/display/content preferences and syncs them with the backend.
2026-04-10 14:14:27 +02:00
12 changed files with 583 additions and 33 deletions

View File

@@ -3,6 +3,9 @@
# Used for self-referential links, OAuth callbacks, and API endpoints.
APP_URL="MY_APP_URL"
# Backend API URL
VITE_API_URL="http://192.168.1.102:6400"
# Importer Configurations
# XBVR Importer
VITE_XBVR_URL=""

View File

@@ -13,9 +13,10 @@ import CastView from './components/CastView';
import CastDetailView from './components/CastDetailView';
import AddMediaView from './components/AddMediaView';
import ImporterView from './components/ImporterView';
import SettingsView from './components/SettingsView';
import { MOCK_MEDIA, DETAIL_MEDIA } from './data';
import { Media, Staff, MediaCategory } from './types';
import { fetchAllMedia, fetchMediaById, fetchCastById, convertApiCastToStaff } from './api';
import { Media, Staff, MediaCategory, UserSettings } from './types';
import { fetchAllMedia, fetchMediaById, fetchCastById, convertApiCastToStaff, fetchSettings, updateSettings } from './api';
function AppContent() {
const navigate = useNavigate();
@@ -28,12 +29,41 @@ function AppContent() {
const [selectedPerson, setSelectedPerson] = useState<Staff | null>(null);
const [searchQuery, setSearchQuery] = useState(searchParams.get('search') || '');
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 [adultMedia, setAdultMedia] = useState<Media[]>([]);
// Load media from API on component mount (only when not on cast routes)
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(() => {
const loadMediaFromApi = async () => {
try {
@@ -50,7 +80,7 @@ function AppContent() {
}
}, [location.pathname]);
const toggleCategory = (category: MediaCategory) => {
const toggleCategory = async (category: MediaCategory) => {
setEnabledCategories(prev => {
const isEnabling = !prev.includes(category);
const newList = isEnabling
@@ -62,6 +92,27 @@ function AppContent() {
const nextCategory = newList.find(c => c !== category) || 'Anime';
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;
});
};
@@ -237,6 +288,7 @@ function AppContent() {
mediaList={filteredMedia}
onMediaClick={handleMediaClick}
activeCategory={activeCategory}
itemsPerPage={settings?.itemsPerPage}
/>
} />
<Route path="/media/:id" element={
@@ -251,6 +303,7 @@ function AppContent() {
<CastView
onPersonClick={handlePersonClick}
enabledCategories={enabledCategories}
itemsPerPage={settings?.itemsPerPage}
/>
} />
<Route path="/cast/:id" element={
@@ -268,6 +321,9 @@ function AppContent() {
<Route path="/import" element={
<ImporterView />
} />
<Route path="/settings" element={
<SettingsView onSettingsSaved={reloadSettings} />
} />
</Routes>
</LayoutGroup>
</main>

View File

@@ -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 {
if (!url) return '';
@@ -625,3 +625,144 @@ export async function fetchMediaFromApi(apiUrl?: string): Promise<Media[]> {
export async function fetchMediaFromLocalJson(): Promise<Media[]> {
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;
}
}

View File

@@ -17,12 +17,13 @@ interface BrowseViewProps {
mediaList: Media[];
onMediaClick: (media: Media) => void;
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 [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(12);
const [itemsPerPage, setItemsPerPage] = useState(initialItemsPerPage);
const [sortBy, setSortBy] = useState<string>('default');
// 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"
>
{[8, 12, 16, 24, 48].map(size => (
{[12, 20, 36, 48, 60].map(size => (
<option key={size} value={size}>{size}</option>
))}
</select>

View File

@@ -12,9 +12,10 @@ import { fetchAllCast } from '@/api';
interface CastViewProps {
onPersonClick: (person: Staff) => void;
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 [staffList, setStaffList] = useState<Staff[]>([]);
const [loading, setLoading] = useState(true);
@@ -34,7 +35,7 @@ export default function CastView({ onPersonClick, enabledCategories }: CastViewP
return localStorage.getItem('castFilterMediaType') || '';
});
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(12);
const [itemsPerPage, setItemsPerPage] = useState(initialItemsPerPage);
const [showFilters, setShowFilters] = useState(false);
// 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"
>
{[8, 12, 16, 24, 48].map(size => (
{[12, 20, 36, 48, 60].map(size => (
<option key={size} value={size}>{size}</option>
))}
</select>

View File

@@ -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 React, { useState } from 'react';
import { Link, NavLink } from 'react-router-dom';
import { MediaCategory } from '@/types';
import LibrarySettings from './LibrarySettings';
interface HeaderProps {
onSearch: (query: string) => void;
@@ -113,10 +112,12 @@ export default function Header({
>
<Download size={20} />
</Link>
<LibrarySettings
enabledCategories={enabledCategories}
onToggleCategory={onToggleCategory}
/>
<Link
to="/settings"
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">
<img
src="https://picsum.photos/seed/user/100/100"

View File

@@ -7,9 +7,11 @@ import { importFromXBVR, XBVRConfig, ImportProgress } from '@/lib/xbvrImporter';
import { importFromStashAPP, StashAPPConfig, updateActorsFromStashAPP } from '@/lib/stashappImporter';
import { importFromPlaynite, PlayniteConfig } from '@/lib/playniteImporter';
const BASE_URL = import.meta.env.VITE_BASE_URL || 'http://localhost:3000';
export default function ImporterView() {
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>({
url: import.meta.env.VITE_STASHAPP_URL || '',
apiKey: import.meta.env.VITE_STASHAPP_API_KEY || ''

View 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>
);
}

View File

@@ -1,3 +1,5 @@
const BASE_URL = import.meta.env.VITE_API_URL;
export interface PlayniteConfig {
ip: string;
apiToken: string;
@@ -108,7 +110,7 @@ export async function importFromPlaynite(
// Step 0: Fetch existing media to check for duplicates and enable updates
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 existingMedia = new Map(
(existingMediaData.data?.items || []).map((m: any) => [m.title, m])
@@ -279,13 +281,13 @@ export async function importFromPlaynite(
let response;
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',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(mediaData)
});
} else {
response = await fetch('http://192.168.1.102:6400/api/media', {
response = await fetch(`${BASE_URL}/api/media`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(mediaData)

View File

@@ -1,3 +1,5 @@
const BASE_URL = import.meta.env.VITE_API_URL;
export interface StashAPPConfig {
url: string;
apiKey?: string;
@@ -155,7 +157,7 @@ export async function updateActorsFromStashAPP(
// Fetch 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 existingActors = new Map(
(existingCastData.data?.items || []).map((c: any) => [c.name, c])
@@ -274,7 +276,7 @@ export async function updateActorsFromStashAPP(
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',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updateData)
@@ -290,7 +292,7 @@ export async function updateActorsFromStashAPP(
}
} else {
// 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',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -377,7 +379,7 @@ export async function importFromStashAPP(
// Step 0: Fetch existing media and cast to check for duplicates
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 existingTitles = new Set(
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('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 existingActors = new Map(
(existingCastData.data?.items || []).map((c: any) => [c.name, c])
@@ -550,7 +552,7 @@ export async function importFromStashAPP(
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',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updateData)
@@ -566,7 +568,7 @@ export async function importFromStashAPP(
}
} else {
// 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',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -706,7 +708,7 @@ export async function importFromStashAPP(
staff: staff
};
const response = await fetch('http://192.168.1.102:6400/api/media', {
const response = await fetch(`${BASE_URL}/api/media`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(mediaData)

View File

@@ -1,3 +1,5 @@
const BASE_URL = import.meta.env.VITE_API_URL;
export interface XBVRConfig {
url: string;
apiKey?: string;
@@ -74,7 +76,7 @@ export async function importFromXBVR(
// Step 0: Fetch existing media and cast to check for duplicates
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 existingTitles = new Set(
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('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 existingActors = new Map(
(existingCastData.data?.items || []).map((c: any) => [c.name, c])
@@ -194,7 +196,7 @@ export async function importFromXBVR(
logCallback(`⊘ Actor already exists: ${actor.name}`);
} else {
// 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',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -312,7 +314,7 @@ export async function importFromXBVR(
staff: staff
};
const response = await fetch('http://192.168.1.102:6400/api/media', {
const response = await fetch(`${BASE_URL}/api/media`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(mediaData)

View File

@@ -95,3 +95,16 @@ export interface AdultSpecifics {
measurements?: string | 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;
}