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.
|
||||
APP_URL="MY_APP_URL"
|
||||
|
||||
# Backend API URL
|
||||
VITE_API_URL="http://192.168.1.102:6400"
|
||||
|
||||
# Importer Configurations
|
||||
# XBVR Importer
|
||||
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 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>
|
||||
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 || ''
|
||||
|
||||
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 {
|
||||
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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
13
src/types.ts
13
src/types.ts
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user