commit 4e91c2acdf7300f4342b83ea290487c4ef664df4 Author: Koha9 Date: Fri Nov 28 01:31:35 2025 +0900 Squashed 'sample-front-end/' content from commit 0881bf1 git-subtree-dir: sample-front-end git-subtree-split: 0881bf1c045118585100360b2c47594cd94b89f1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/App.tsx b/App.tsx new file mode 100644 index 0000000..d05aef7 --- /dev/null +++ b/App.tsx @@ -0,0 +1,285 @@ + +import React, { useEffect, useState, useCallback, useRef } from 'react'; +import { Playlist, ServerType, SyncStrategy, PlexServerConnection, RegexReplacement } from './types'; +import { apiService } from './services/api'; +import ServerPanel from './components/ServerPanel'; +import StrategySelector from './components/StrategySelector'; +import ConnectionModal from './components/ConnectionModal'; +import { ArrowLeftRight, ShieldCheck, X, Server, ServerOff } from 'lucide-react'; + +interface Toast { + id: number; + message: string; + exiting: boolean; + entering: boolean; +} + +const App: React.FC = () => { + const [localPlaylists, setLocalPlaylists] = useState([]); + const [cloudPlaylists, setCloudPlaylists] = useState([]); + const [cloudServerInfo, setCloudServerInfo] = useState(undefined); + + const [loadingLocal, setLoadingLocal] = useState(false); + const [loadingCloud, setLoadingCloud] = useState(false); + + // Connection Modal State + const [isConnectionModalOpen, setIsConnectionModalOpen] = useState(false); + + // Strategy State + const [currentStrategy, setCurrentStrategy] = useState(SyncStrategy.LOCAL_OVERWRITE); + + // Regex State + const [regexReplacements, setRegexReplacements] = useState([]); + + // Toast Notification System + const [toasts, setToasts] = useState([]); + const timeoutsRef = useRef<{[key: number]: ReturnType}>({}); + + const removeToast = (id: number) => { + setToasts(prev => prev.filter(t => t.id !== id)); + if (timeoutsRef.current[id]) { + clearTimeout(timeoutsRef.current[id]); + delete timeoutsRef.current[id]; + } + }; + + const addToast = (message: string) => { + const id = Date.now(); + // Start with entering: true to position it above + const newToast: Toast = { id, message, exiting: false, entering: true }; + + setToasts(prev => { + // Mark all existing toasts as exiting immediately so they slide up + const exitingToasts = prev.map(t => ({ ...t, exiting: true, entering: false })); + return [...exitingToasts, newToast]; + }); + + // Auto dismiss the new toast after 3 seconds + const dismissTimer = setTimeout(() => { + setToasts(prev => prev.map(t => t.id === id ? { ...t, exiting: true } : t)); + }, 3000); + + timeoutsRef.current[id] = dismissTimer; + }; + + // Effect to trigger the "slide down" animation + useEffect(() => { + const enteringIds = toasts.filter(t => t.entering).map(t => t.id); + + if (enteringIds.length > 0) { + let raf1: number; + let raf2: number; + + raf1 = requestAnimationFrame(() => { + raf2 = requestAnimationFrame(() => { + setToasts(prev => prev.map(t => + enteringIds.includes(t.id) ? { ...t, entering: false } : t + )); + }); + }); + + return () => { + cancelAnimationFrame(raf1); + cancelAnimationFrame(raf2); + }; + } + }, [toasts]); + + // Cleanup effect for exiting toasts + useEffect(() => { + const exitingToasts = toasts.filter(t => t.exiting); + exitingToasts.forEach(t => { + if (!timeoutsRef.current[`remove-${t.id}`]) { + timeoutsRef.current[`remove-${t.id}`] = setTimeout(() => { + removeToast(t.id); + delete timeoutsRef.current[`remove-${t.id}`]; + }, 300); + } + }); + }, [toasts]); + + // Fetch Local Playlists + const refreshLocal = useCallback(async () => { + setLoadingLocal(true); + const result = await apiService.getPlaylists(ServerType.LOCAL); + if (result.status === 'success') { + setLocalPlaylists(result.data); + } + setLoadingLocal(false); + }, []); + + // Fetch Cloud Playlists and Info + const refreshCloud = useCallback(async () => { + setLoadingCloud(true); + // Fetch playlists + const playlistResult = await apiService.getPlaylists(ServerType.CLOUD); + if (playlistResult.status === 'success') { + setCloudPlaylists(playlistResult.data); + } + + // Fetch server info + const infoResult = await apiService.getServerStatus(); + if (infoResult.status === 'success') { + setCloudServerInfo(infoResult.data); + } + + setLoadingCloud(false); + }, []); + + // Initial Load + useEffect(() => { + refreshLocal(); + refreshCloud(); + }, [refreshLocal, refreshCloud]); + + // Handle Strategy Change + const handleStrategyChange = (strategy: SyncStrategy, label: string) => { + setCurrentStrategy(strategy); + addToast(`Selected strategy "${label}" has been saved.`); + }; + + // Handle Regex Save + const handleSaveRegex = (replacements: RegexReplacement[]) => { + setRegexReplacements(replacements); + addToast('Regex preprocessing rules have been saved.'); + }; + + const handleConnectSuccess = (serverInfo: PlexServerConnection) => { + setCloudServerInfo(serverInfo); + // Removed implicit toast here to allow the caller (ConnectionModal) to handle specific messaging + refreshCloud(); // Refresh playlists after new connection + }; + + const getToastStyles = (toast: Toast): React.CSSProperties => { + if (toast.exiting || toast.entering) { + return { + opacity: 0, + transform: 'translateY(-40px) scale(0.95)', + }; + } + return { + opacity: 1, + transform: 'translateY(0) scale(1)', + }; + }; + + const getToastClasses = () => { + return "absolute top-2 flex items-center space-x-2 px-4 py-2 rounded-full shadow-lg border text-sm font-medium pointer-events-auto bg-gray-800 text-plex-orange border-plex-orange/30 transition-all duration-300 ease-out origin-top z-50 backdrop-blur-md"; + }; + + const isConnected = cloudServerInfo?.isConnected; + + return ( +
+ + {/* App Header */} +
+
+
+
+ +
+

+ PlexSync +

+
+ + {/* Connection Status Button */} + +
+
+ + {/* Notification Toasts Container */} +
+ {toasts.map((toast) => ( +
+ + {toast.message} + +
+ ))} +
+ + {/* Main Content Area */} +
+
+ + {/* Left Column - Local */} +
+ +
+ + {/* Strategy Selector - Positioned specifically between headers */} + {/* Desktop: Centered Horizontally, Top aligned with Headers (Padding Top 24px + Header Half Height ~40px = ~64px) */} + {/* Mobile: Centered Vertically, Right aligned with Headers (Padding Right 16px + Header Half Width ~36px = ~52px) */} +
+ +
+ + {/* Right Column - Cloud */} +
+ +
+ +
+
+ + {/* Footer */} +
+

© {new Date().getFullYear()} PlexSync Manager. Connected to Docker backend.

+
+ + {/* Modals */} + setIsConnectionModalOpen(false)} + onConnectSuccess={handleConnectSuccess} + onShowMessage={addToast} + /> +
+ ); +}; + +export default App; diff --git a/README.md b/README.md new file mode 100644 index 0000000..6674576 --- /dev/null +++ b/README.md @@ -0,0 +1,20 @@ +
+GHBanner +
+ +# Run and deploy your AI Studio app + +This contains everything you need to run your app locally. + +View your app in AI Studio: https://ai.studio/apps/drive/1HGbFKaSambWckOUfemMSKy_Vm-94xh4D + +## Run Locally + +**Prerequisites:** Node.js + + +1. Install dependencies: + `npm install` +2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key +3. Run the app: + `npm run dev` diff --git a/components/ConnectionModal.tsx b/components/ConnectionModal.tsx new file mode 100644 index 0000000..f2bb243 --- /dev/null +++ b/components/ConnectionModal.tsx @@ -0,0 +1,326 @@ + +import React, { useState, useEffect } from 'react'; +import { PlexConnectionSettings, PlexServerConnection, PlexLibrary } from '../types'; +import { apiService } from '../services/api'; +import { X, Server, Lock, User, Key, Globe, Eye, EyeOff, CheckCircle, Library, ChevronDown } from 'lucide-react'; + +interface ConnectionModalProps { + isOpen: boolean; + onClose: () => void; + onConnectSuccess: (serverInfo: PlexServerConnection) => void; + onShowMessage: (message: string) => void; +} + +const ConnectionModal: React.FC = ({ isOpen, onClose, onConnectSuccess, onShowMessage }) => { + const [formData, setFormData] = useState({ + protocol: 'http', + address: '', + port: '32400', + token: '', + username: '', + password: '' + }); + + const [isConnecting, setIsConnecting] = useState(false); + const [error, setError] = useState(null); + const [showPassword, setShowPassword] = useState(false); + + // Post-connection state + const [connectedServerInfo, setConnectedServerInfo] = useState(null); + const [libraries, setLibraries] = useState([]); + const [selectedLibraryId, setSelectedLibraryId] = useState(''); + + // Reset state when opening + useEffect(() => { + if (isOpen) { + setError(null); + setConnectedServerInfo(null); + setLibraries([]); + setSelectedLibraryId(''); + } + }, [isOpen]); + + if (!isOpen) return null; + + const handleChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setFormData(prev => ({ ...prev, [name]: value })); + }; + + const handleLibraryChange = (e: React.ChangeEvent) => { + const newId = e.target.value; + setSelectedLibraryId(newId); + + const lib = libraries.find(l => l.id === newId); + if (lib && connectedServerInfo) { + const updatedInfo = { ...connectedServerInfo, libraryName: lib.title }; + setConnectedServerInfo(updatedInfo); + // Notify parent of update + onConnectSuccess(updatedInfo); + // Show toast + onShowMessage(`Library switched to ${lib.title}`); + } + }; + + const isTokenProvided = formData.token.trim().length > 0; + + // Dynamic styles for disabled fields + const disabledInputClass = isTokenProvided + ? "bg-gray-700/50 text-gray-500 line-through decoration-gray-500 cursor-not-allowed border-gray-700" + : "bg-gray-800 text-gray-100 border-gray-600 focus:border-plex-orange focus:ring-1 focus:ring-plex-orange"; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setIsConnecting(true); + + const result = await apiService.connectToPlex(formData); + + setIsConnecting(false); + + if (result.status === 'success' && result.data) { + // Update Token field and clear user/pass + setFormData(prev => ({ + ...prev, + token: result.data.token, + username: '', + password: '' + })); + + const info = result.data.serverInfo; + setConnectedServerInfo(info); + + // Explicitly show connection message here + onShowMessage(`Successfully connected to ${info.name || 'Plex Server'}`); + + // Handle libraries + const libs = info.libraries || []; + setLibraries(libs); + if (libs.length > 0) { + const defaultLib = libs[0]; + setSelectedLibraryId(defaultLib.id); + // Pass connection info back with default library name explicitly set (though mock already does it) + onConnectSuccess({ + ...info, + libraryName: defaultLib.title + }); + } else { + onConnectSuccess(info); + } + } else { + setError(result.message || "Connection failed"); + } + }; + + const isConnected = !!connectedServerInfo; + + return ( +
+
+ + {/* Header */} +
+

+ + {isConnected ? 'Server Connected' : 'Connect Plex Server'} +

+ +
+ + {/* Body */} +
+
+ + {error && ( +
+ {error} +
+ )} + + {/* Server Connection */} +
+ +
+
+ +
+
+
+
+ +
+ +
+
+
+ +
+ +
+
+ +
+ + {/* Authentication */} +
+ + + {/* Token */} +
+
+ +
+ +
+ + {!isConnected && ( + <> +
+ — OR — +
+ + {/* Username */} +
+
+ +
+ +
+ + {/* Password */} +
+
+ +
+ + +
+ {isTokenProvided && ( +

+ Credential login disabled when token is present. +

+ )} + + )} +
+ + {!isConnected ? ( + + ) : ( +
+

+ + Connected Successfully +

+
+ )} + + + {/* Library Selection - Appears after connection */} + {isConnected && libraries.length > 0 && ( +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+
+ )} +
+
+
+ ); +}; + +export default ConnectionModal; diff --git a/components/PlaylistCard.tsx b/components/PlaylistCard.tsx new file mode 100644 index 0000000..40e9229 --- /dev/null +++ b/components/PlaylistCard.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { Playlist } from '../types'; +import { Disc3, Clock } from 'lucide-react'; + +interface PlaylistCardProps { + playlist: Playlist; +} + +const PlaylistCard: React.FC = ({ playlist }) => { + return ( +
+
+

+ {playlist.title} +

+
+ +
+ + + {playlist.trackCount} + + + + {new Date(playlist.lastUpdated).toLocaleDateString()} + +
+
+ ); +}; + +export default PlaylistCard; \ No newline at end of file diff --git a/components/ServerPanel.tsx b/components/ServerPanel.tsx new file mode 100644 index 0000000..1c6e08e --- /dev/null +++ b/components/ServerPanel.tsx @@ -0,0 +1,140 @@ + +import React from 'react'; +import { Playlist, ServerType, PlexServerConnection } from '../types'; +import PlaylistCard from './PlaylistCard'; +import { RefreshCw, Server, Cloud, WifiOff } from 'lucide-react'; + +interface ServerPanelProps { + type: ServerType; + playlists: Playlist[]; + isLoading: boolean; + onRefresh: () => void; + serverInfo?: PlexServerConnection; +} + +const ServerPanel: React.FC = ({ type, playlists, isLoading, onRefresh, serverInfo }) => { + const isLocal = type === ServerType.LOCAL; + + let Icon = isLocal ? Server : Cloud; + let headerColor = isLocal ? 'text-blue-400' : 'text-green-400'; + const borderColor = isLocal ? 'border-blue-500/30' : 'border-green-500/30'; + const bgGradient = isLocal + ? 'bg-gradient-to-br from-gray-800/80 to-gray-900/80' + : 'bg-gradient-to-bl from-gray-800/80 to-gray-900/80'; + + // Resolve Title and Subtitle Logic + let displayTitle = ''; + let displaySubtitle: React.ReactNode = null; + + if (isLocal) { + displayTitle = 'Local Server'; + displaySubtitle = ( +

+ {playlists.length} Playlists +

+ ); + } else { + // Cloud Logic + if (serverInfo) { + if (serverInfo.isConnected) { + displayTitle = serverInfo.name || 'Cloud Server'; + displaySubtitle = ( +
+ {serverInfo.libraryName} + + {serverInfo.ip}:{serverInfo.port} +
+ ); + } else { + displayTitle = 'Not Connected'; + Icon = WifiOff; + headerColor = 'text-red-400'; + displaySubtitle = ( +

+ Connection failed +

+ ); + } + } else { + displayTitle = 'Cloud Server'; + displaySubtitle = ( +

+ {isLoading ? 'Connecting...' : 'Waiting...'} +

+ ); + } + } + + return ( +
+ + {/* Header */} + {/* Mobile: Order Last (Right side), Vertical Text */} + {/* Desktop: Order First (Top side), Horizontal Text */} +
+ + {/* Title Group */} +
+ + {/* Icon Box */} +
+ +
+ + {/* Text Container - Vertical on Mobile, Horizontal on Desktop */} +
+ {/* Use vertical-rl for vertical text flow, rotate-180 to flip it bottom-up */} +
+

+ {displayTitle} +

+
+ {displaySubtitle} +
+
+
+
+ + {/* Refresh Button */} + +
+ + {/* Content List */} +
+ {isLoading && playlists.length === 0 ? ( +
+ +

Syncing...

+
+ ) : playlists.length === 0 ? ( +
+

No playlists found.

+
+ ) : ( +
+ {playlists.map((playlist) => ( + + ))} +
+ )} +
+
+ ); +}; + +export default ServerPanel; diff --git a/components/StrategySelector.tsx b/components/StrategySelector.tsx new file mode 100644 index 0000000..5830e3c --- /dev/null +++ b/components/StrategySelector.tsx @@ -0,0 +1,297 @@ + +import React, { useState, useRef, useEffect } from 'react'; +import { SyncStrategy, RegexReplacement } from '../types'; +import { + ArrowRightCircle, + ArrowLeftCircle, + GitMerge, + ChevronDown, + Check, + HelpCircle, + Plus, + Trash2, + Save, + RotateCcw +} from 'lucide-react'; + +interface StrategyOption { + value: SyncStrategy; + label: string; + description: string; + icon: React.ElementType; + color: string; +} + +const STRATEGIES: StrategyOption[] = [ + { + value: SyncStrategy.LOCAL_OVERWRITE, + label: 'Local Overwrite', + description: 'Local playlist completely overwrites Cloud. (No Diff)', + icon: ArrowRightCircle, + color: 'text-blue-400' + }, + { + value: SyncStrategy.CLOUD_OVERWRITE, + label: 'Cloud Overwrite', + description: 'Cloud playlist completely overwrites Local. (No Diff)', + icon: ArrowLeftCircle, + color: 'text-green-400' + }, + { + value: SyncStrategy.MERGE_LOCAL, + label: 'Two-way Merge (Local Priority)', + description: 'Merge both. Conflicts resolve to Local version.', + icon: GitMerge, + color: 'text-blue-300' + }, + { + value: SyncStrategy.MERGE_CLOUD, + label: 'Two-way Merge (Cloud Priority)', + description: 'Merge both. Conflicts resolve to Cloud version.', + icon: GitMerge, + color: 'text-green-300' + } +]; + +interface StrategySelectorProps { + currentStrategy: SyncStrategy; + onSelect: (strategy: SyncStrategy, label: string) => void; + savedRegexReplacements: RegexReplacement[]; + onSaveRegex: (replacements: RegexReplacement[]) => void; +} + +const StrategySelector: React.FC = ({ + currentStrategy, + onSelect, + savedRegexReplacements, + onSaveRegex +}) => { + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); + + // Local state for regex editing + const [localReplacements, setLocalReplacements] = useState([]); + const [isDirty, setIsDirty] = useState(false); + + // Initialize local state when prop updates (only if not dirty, or initially) + useEffect(() => { + setLocalReplacements(JSON.parse(JSON.stringify(savedRegexReplacements))); + setIsDirty(false); + }, [savedRegexReplacements]); + + // Check dirty state whenever local changes + useEffect(() => { + const isDifferent = JSON.stringify(localReplacements) !== JSON.stringify(savedRegexReplacements); + setIsDirty(isDifferent); + }, [localReplacements, savedRegexReplacements]); + + const selectedOption = STRATEGIES.find(s => s.value === currentStrategy) || STRATEGIES[0]; + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const handleSelect = (strategy: StrategyOption) => { + onSelect(strategy.value, strategy.label); + }; + + // Regex Handlers + const handleAddRegex = () => { + const newId = Date.now().toString(); + setLocalReplacements(prev => [...prev, { id: newId, pattern: '', replacement: '' }]); + }; + + const handleDeleteRegex = (id: string) => { + setLocalReplacements(prev => prev.filter(r => r.id !== id)); + }; + + const handleUpdateRegex = (id: string, field: 'pattern' | 'replacement', value: string) => { + setLocalReplacements(prev => prev.map(r => + r.id === id ? { ...r, [field]: value } : r + )); + }; + + const handleReset = () => { + setLocalReplacements(JSON.parse(JSON.stringify(savedRegexReplacements))); + }; + + const handleSave = () => { + const validReplacements = localReplacements.filter(r => r.pattern.trim() !== ''); + setLocalReplacements(validReplacements); + onSaveRegex(validReplacements); + }; + + return ( +
+ {/* Trigger Button */} + + + {/* Dropdown Menu - Persistent Mount for State Preservation */} +
+ {/* Section 1: Sync Strategy */} +
+

Sync Strategy

+
+ {STRATEGIES.map((strategy) => ( +
handleSelect(strategy)} + className={`group flex items-center justify-between p-2 rounded-lg cursor-pointer transition-all border ${ + currentStrategy === strategy.value + ? 'bg-white/10 border-white/10 shadow-sm' + : 'hover:bg-white/5 border-transparent' + }`} + > +
+ + + {strategy.label} + +
+ +
+
+ +
+ {strategy.description} +
+
+ + {currentStrategy === strategy.value && ( + + )} +
+
+ ))} +
+
+ + {/* Section 2: Regex Preprocessing */} +
+
+

Regex Rules

+ {localReplacements.length === 0 && ( + + )} +
+ +
+ {localReplacements.length === 0 ? ( +
+ No regex replacements configured. +
+ ) : ( + localReplacements.map((regex) => ( +
+
+ handleUpdateRegex(regex.id, 'pattern', e.target.value)} + className={`w-full bg-gray-900/80 border rounded-md px-2.5 py-1.5 text-xs text-gray-200 focus:outline-none focus:ring-1 transition-all placeholder-gray-600 + ${!regex.pattern && isDirty ? 'border-red-500/30 focus:border-red-500' : 'border-gray-700 focus:border-plex-orange'}`} + /> +
+
+ +
+
+ handleUpdateRegex(regex.id, 'replacement', e.target.value)} + className="w-full bg-gray-900/80 border border-gray-700 rounded-md px-2.5 py-1.5 text-xs text-gray-200 focus:outline-none focus:border-plex-orange focus:ring-1 focus:ring-plex-orange transition-all placeholder-gray-600" + /> +
+ +
+ )) + )} +
+ + {/* Actions */} +
+ {localReplacements.length > 0 && ( +
+ +
+ )} + +
+ + +
+
+
+
+
+ ); +}; + +export default StrategySelector; diff --git a/index.html b/index.html new file mode 100644 index 0000000..313a4b5 --- /dev/null +++ b/index.html @@ -0,0 +1,54 @@ + + + + + + PlexSync Manager + + + + + + +
+ + \ No newline at end of file diff --git a/index.tsx b/index.tsx new file mode 100644 index 0000000..6ca5361 --- /dev/null +++ b/index.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; + +const rootElement = document.getElementById('root'); +if (!rootElement) { + throw new Error("Could not find root element to mount to"); +} + +const root = ReactDOM.createRoot(rootElement); +root.render( + + + +); \ No newline at end of file diff --git a/metadata.json b/metadata.json new file mode 100644 index 0000000..1e51b26 --- /dev/null +++ b/metadata.json @@ -0,0 +1,5 @@ +{ + "name": "PlexSync Manager", + "description": "A modern dashboard to synchronize and manage playlists between Local and Cloud Plex servers.", + "requestFramePermissions": [] +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..87c0017 --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "plexsync-manager", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "lucide-react": "^0.555.0", + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "@types/node": "^22.14.0", + "@vitejs/plugin-react": "^5.0.0", + "typescript": "~5.8.2", + "vite": "^6.2.0" + } +} diff --git a/services/api.ts b/services/api.ts new file mode 100644 index 0000000..0684670 --- /dev/null +++ b/services/api.ts @@ -0,0 +1,127 @@ + +import { Playlist, ServerType, ApiResponse, PlexServerConnection, PlexConnectionSettings, PlexLibrary } from '../types'; +import { MOCK_LOCAL_PLAYLISTS, MOCK_CLOUD_PLAYLISTS } from './mockData'; + +const SIMULATE_DELAY_MS = 800; + +// Mock available libraries on a server +const MOCK_LIBRARIES: PlexLibrary[] = [ + { id: 'lib1', title: 'Music (Flac)', type: 'artist' }, + { id: 'lib2', title: 'MP3 Collection', type: 'artist' }, + { id: 'lib3', title: 'Soundtracks', type: 'artist' }, + { id: 'lib4', title: 'Audiobooks', type: 'artist' } +]; + +// Helper to simulate network request or call actual API +const fetchPlaylists = async (type: ServerType): Promise => { + // In a real Docker environment with FastAPI, you would do: + // const response = await fetch(`/api/playlists/${type.toLowerCase()}`); + // const data = await response.json(); + // return data; + + // Mocking for UI demonstration + return new Promise((resolve) => { + setTimeout(() => { + if (type === ServerType.LOCAL) { + resolve([...MOCK_LOCAL_PLAYLISTS]); + } else { + resolve([...MOCK_CLOUD_PLAYLISTS]); + } + }, SIMULATE_DELAY_MS); + }); +}; + +const fetchServerStatus = async (): Promise => { + // Mocking server status + return new Promise((resolve) => { + setTimeout(() => { + // 90% chance of success for demo + const isSuccess = Math.random() > 0.1; + if (isSuccess) { + resolve({ + isConnected: true, + name: 'Home Media Server', + ip: '192.168.1.105', + port: 32400, + libraryName: 'Music (Flac)' + }); + } else { + resolve({ + isConnected: false + }); + } + }, SIMULATE_DELAY_MS); + }); +}; + +const authenticatePlex = async (settings: PlexConnectionSettings): Promise<{ token: string, serverInfo: PlexServerConnection }> => { + return new Promise((resolve, reject) => { + setTimeout(() => { + // Simulate validation + if (!settings.address) { + reject(new Error("Server address is required")); + return; + } + + // If user provided username/password, mock a token generation + let token = settings.token; + if (!token && settings.username && settings.password) { + token = "MOCK_TOKEN_XYZ_999"; + } else if (!token) { + reject(new Error("Token or Username/Password required")); + return; + } + + // Success response with libraries + resolve({ + token: token, + serverInfo: { + isConnected: true, + name: 'My Plex Server', + ip: settings.address, + port: parseInt(settings.port) || 32400, + libraryName: MOCK_LIBRARIES[0].title, // Default to first library + libraries: MOCK_LIBRARIES + } + }); + }, 1500); + }); +} + +export const apiService = { + getPlaylists: async (serverType: ServerType): Promise> => { + try { + const data = await fetchPlaylists(serverType); + return { data, status: 'success' }; + } catch (error) { + console.error(`Error fetching ${serverType} playlists:`, error); + return { data: [], status: 'error', message: 'Failed to fetch playlists' }; + } + }, + + getServerStatus: async (): Promise> => { + try { + const data = await fetchServerStatus(); + return { data, status: 'success' }; + } catch (error) { + return { + data: { isConnected: false }, + status: 'error', + message: 'Failed to connect to server' + }; + } + }, + + connectToPlex: async (settings: PlexConnectionSettings): Promise> => { + try { + const data = await authenticatePlex(settings); + return { data, status: 'success', message: 'Connected successfully' }; + } catch (error: any) { + return { + data: { token: '', serverInfo: { isConnected: false } }, + status: 'error', + message: error.message || 'Connection failed' + }; + } + } +}; diff --git a/services/mockData.ts b/services/mockData.ts new file mode 100644 index 0000000..41f7e5f --- /dev/null +++ b/services/mockData.ts @@ -0,0 +1,14 @@ +import { Playlist } from '../types'; + +export const MOCK_LOCAL_PLAYLISTS: Playlist[] = [ + { id: 'l1', title: 'Road Trip 2024', trackCount: 45, lastUpdated: '2023-10-25T10:00:00Z', thumbnail: 'https://picsum.photos/200/200?random=1' }, + { id: 'l2', title: 'Coding Focus', trackCount: 120, lastUpdated: '2023-10-24T14:30:00Z', thumbnail: 'https://picsum.photos/200/200?random=2' }, + { id: 'l3', title: '90s Rock', trackCount: 32, lastUpdated: '2023-10-20T09:15:00Z', thumbnail: 'https://picsum.photos/200/200?random=3' }, + { id: 'l4', title: 'Gym Pump', trackCount: 50, lastUpdated: '2023-10-22T18:45:00Z', thumbnail: 'https://picsum.photos/200/200?random=4' }, +]; + +export const MOCK_CLOUD_PLAYLISTS: Playlist[] = [ + { id: 'c1', title: 'Road Trip 2024', trackCount: 42, lastUpdated: '2023-10-24T10:00:00Z', thumbnail: 'https://picsum.photos/200/200?random=1' }, // Slightly out of sync + { id: 'c2', title: 'Coding Focus', trackCount: 120, lastUpdated: '2023-10-24T14:30:00Z', thumbnail: 'https://picsum.photos/200/200?random=2' }, + { id: 'c5', title: 'Chill Vibes', trackCount: 88, lastUpdated: '2023-10-19T20:20:00Z', thumbnail: 'https://picsum.photos/200/200?random=5' }, +]; \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2c6eed5 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "target": "ES2022", + "experimentalDecorators": true, + "useDefineForClassFields": false, + "module": "ESNext", + "lib": [ + "ES2022", + "DOM", + "DOM.Iterable" + ], + "skipLibCheck": true, + "types": [ + "node" + ], + "moduleResolution": "bundler", + "isolatedModules": true, + "moduleDetection": "force", + "allowJs": true, + "jsx": "react-jsx", + "paths": { + "@/*": [ + "./*" + ] + }, + "allowImportingTsExtensions": true, + "noEmit": true + } +} \ No newline at end of file diff --git a/types.ts b/types.ts new file mode 100644 index 0000000..923ba8a --- /dev/null +++ b/types.ts @@ -0,0 +1,64 @@ + +export interface Track { + id: string; + title: string; + artist: string; + duration: number; // in seconds +} + +export interface Playlist { + id: string; + title: string; + trackCount: number; + thumbnail?: string; + lastUpdated: string; + tracks?: Track[]; // Optional detailed track list +} + +export enum ServerType { + LOCAL = 'LOCAL', + CLOUD = 'CLOUD' +} + +export enum SyncStrategy { + LOCAL_OVERWRITE = 'LOCAL_OVERWRITE', + CLOUD_OVERWRITE = 'CLOUD_OVERWRITE', + MERGE_LOCAL = 'MERGE_LOCAL', + MERGE_CLOUD = 'MERGE_CLOUD' +} + +export interface RegexReplacement { + id: string; + pattern: string; + replacement: string; +} + +export interface PlexLibrary { + id: string; + title: string; + type: string; +} + +export interface PlexServerConnection { + isConnected: boolean; + name?: string; + ip?: string; + port?: number; + libraryName?: string; + libraries?: PlexLibrary[]; +} + +export interface PlexConnectionSettings { + protocol: 'http' | 'https'; + address: string; + port: string; + token: string; + username?: string; + password?: string; +} + +export interface ApiResponse { + data: T; + status: 'success' | 'error'; + message?: string; +} \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..ee5fb8d --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,23 @@ +import path from 'path'; +import { defineConfig, loadEnv } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, '.', ''); + return { + server: { + port: 3000, + host: '0.0.0.0', + }, + plugins: [react()], + define: { + 'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY), + 'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY) + }, + resolve: { + alias: { + '@': path.resolve(__dirname, '.'), + } + } + }; +});