Integrate React frontend with backend API
This commit is contained in:
@@ -18,9 +18,10 @@ const App: React.FC = () => {
|
||||
const [localPlaylists, setLocalPlaylists] = useState<Playlist[]>([]);
|
||||
const [cloudPlaylists, setCloudPlaylists] = useState<Playlist[]>([]);
|
||||
const [cloudServerInfo, setCloudServerInfo] = useState<PlexServerConnection | undefined>(undefined);
|
||||
|
||||
|
||||
const [loadingLocal, setLoadingLocal] = useState(false);
|
||||
const [loadingCloud, setLoadingCloud] = useState(false);
|
||||
const [statusIntervalMs, setStatusIntervalMs] = useState<number>(60000);
|
||||
|
||||
// Connection Modal State
|
||||
const [isConnectionModalOpen, setIsConnectionModalOpen] = useState(false);
|
||||
@@ -115,12 +116,16 @@ const App: React.FC = () => {
|
||||
const playlistResult = await apiService.getPlaylists(ServerType.CLOUD);
|
||||
if (playlistResult.status === 'success') {
|
||||
setCloudPlaylists(playlistResult.data);
|
||||
} else {
|
||||
setCloudPlaylists([]);
|
||||
}
|
||||
|
||||
|
||||
// Fetch server info
|
||||
const infoResult = await apiService.getServerStatus();
|
||||
if (infoResult.status === 'success') {
|
||||
setCloudServerInfo(infoResult.data);
|
||||
} else {
|
||||
setCloudServerInfo({ isConnected: false });
|
||||
}
|
||||
|
||||
setLoadingCloud(false);
|
||||
@@ -128,20 +133,68 @@ const App: React.FC = () => {
|
||||
|
||||
// Initial Load
|
||||
useEffect(() => {
|
||||
refreshLocal();
|
||||
refreshCloud();
|
||||
const loadSettings = async () => {
|
||||
const [settings, uiConfig] = await Promise.all([
|
||||
apiService.getSettings(),
|
||||
apiService.getUiConfig()
|
||||
]);
|
||||
|
||||
if (settings.status === 'success') {
|
||||
setCurrentStrategy(normalizeStrategy(settings.data.syncStrategy));
|
||||
if (settings.data.regexRules) {
|
||||
setRegexReplacements(settings.data.regexRules);
|
||||
}
|
||||
}
|
||||
|
||||
if (uiConfig.status === 'success') {
|
||||
const ms = Math.max(10000, (uiConfig.data.statusCheckIntervalSeconds || 60) * 1000);
|
||||
setStatusIntervalMs(ms);
|
||||
}
|
||||
|
||||
refreshLocal();
|
||||
refreshCloud();
|
||||
};
|
||||
|
||||
loadSettings();
|
||||
}, [refreshLocal, refreshCloud]);
|
||||
|
||||
// Periodically check cloud connection status to avoid stale UI loops
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => {
|
||||
refreshCloud();
|
||||
}, statusIntervalMs);
|
||||
|
||||
return () => window.clearInterval(timer);
|
||||
}, [refreshCloud, statusIntervalMs]);
|
||||
|
||||
const normalizeStrategy = (value?: string): SyncStrategy => {
|
||||
if (!value) return SyncStrategy.LOCAL_OVERWRITE;
|
||||
const values = Object.values(SyncStrategy);
|
||||
return values.includes(value as SyncStrategy)
|
||||
? (value as SyncStrategy)
|
||||
: SyncStrategy.LOCAL_OVERWRITE;
|
||||
};
|
||||
|
||||
// Handle Strategy Change
|
||||
const handleStrategyChange = (strategy: SyncStrategy, label: string) => {
|
||||
const handleStrategyChange = async (strategy: SyncStrategy, label: string) => {
|
||||
setCurrentStrategy(strategy);
|
||||
addToast(`Selected strategy "${label}" has been saved.`);
|
||||
const result = await apiService.saveStrategy(strategy);
|
||||
if (result.status === 'success') {
|
||||
addToast(`Selected strategy "${label}" has been saved.`);
|
||||
} else {
|
||||
addToast('Failed to save strategy to server.');
|
||||
}
|
||||
};
|
||||
|
||||
// Handle Regex Save
|
||||
const handleSaveRegex = (replacements: RegexReplacement[]) => {
|
||||
setRegexReplacements(replacements);
|
||||
addToast('Regex preprocessing rules have been saved.');
|
||||
const handleSaveRegex = async (replacements: RegexReplacement[]) => {
|
||||
const result = await apiService.saveRegexRules(replacements);
|
||||
if (result.status === 'success') {
|
||||
setRegexReplacements(result.data);
|
||||
addToast('Regex preprocessing rules have been saved.');
|
||||
} else {
|
||||
addToast(result.message || 'Failed to save regex rules.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleConnectSuccess = (serverInfo: PlexServerConnection) => {
|
||||
|
||||
@@ -47,10 +47,10 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleLibraryChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const handleLibraryChange = async (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const newId = e.target.value;
|
||||
setSelectedLibraryId(newId);
|
||||
|
||||
|
||||
const lib = libraries.find(l => l.id === newId);
|
||||
if (lib && connectedServerInfo) {
|
||||
const updatedInfo = { ...connectedServerInfo, libraryName: lib.title };
|
||||
@@ -59,6 +59,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
|
||||
onConnectSuccess(updatedInfo);
|
||||
// Show toast
|
||||
onShowMessage(`Library switched to ${lib.title}`);
|
||||
await apiService.selectLibrary(lib.title);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -100,6 +101,7 @@ const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onCo
|
||||
const defaultLib = libs[0];
|
||||
setSelectedLibraryId(defaultLib.id);
|
||||
// Pass connection info back with default library name explicitly set (though mock already does it)
|
||||
await apiService.selectLibrary(defaultLib.title);
|
||||
onConnectSuccess({
|
||||
...info,
|
||||
libraryName: defaultLib.title
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script type="module" src="/index.tsx"></script>
|
||||
</head>
|
||||
<body class="bg-gray-900 text-gray-100 antialiased min-h-screen">
|
||||
<div id="root"></div>
|
||||
|
||||
Generated
+1771
File diff suppressed because it is too large
Load Diff
@@ -1,98 +1,90 @@
|
||||
|
||||
import { Playlist, ServerType, ApiResponse, PlexServerConnection, PlexConnectionSettings, PlexLibrary } from '../types';
|
||||
import { MOCK_LOCAL_PLAYLISTS, MOCK_CLOUD_PLAYLISTS } from './mockData';
|
||||
import { Playlist, ServerType, ApiResponse, PlexServerConnection, PlexConnectionSettings, RegexReplacement, UiConfig, SyncSettings } from '../types';
|
||||
|
||||
const SIMULATE_DELAY_MS = 800;
|
||||
const API_PREFIX = '/api';
|
||||
|
||||
// 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<Playlist[]> => {
|
||||
// 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 parseJson = async <T>(response: Response): Promise<ApiResponse<T>> => {
|
||||
try {
|
||||
const data = await response.json();
|
||||
return data as ApiResponse<T>;
|
||||
} catch (error) {
|
||||
console.error('Failed to parse API response', error);
|
||||
return { data: {} as T, status: 'error', message: 'Invalid response from server' };
|
||||
}
|
||||
};
|
||||
|
||||
const fetchServerStatus = async (): Promise<PlexServerConnection> => {
|
||||
// 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);
|
||||
});
|
||||
}
|
||||
const mapServerType = (type: ServerType) => type === ServerType.LOCAL ? 'local' : 'cloud';
|
||||
|
||||
export const apiService = {
|
||||
getUiConfig: async (): Promise<ApiResponse<UiConfig>> => {
|
||||
try {
|
||||
const response = await fetch(`${API_PREFIX}/ui-config`);
|
||||
const result = await parseJson<UiConfig>(response);
|
||||
if (!response.ok || result.status !== 'success') {
|
||||
return { data: { statusCheckIntervalSeconds: 60 }, status: 'error', message: result.message || '无法获取前端配置' };
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
return { data: { statusCheckIntervalSeconds: 60 }, status: 'error', message: '无法获取前端配置' };
|
||||
}
|
||||
},
|
||||
|
||||
getSettings: async (): Promise<ApiResponse<SyncSettings>> => {
|
||||
try {
|
||||
const response = await fetch(`${API_PREFIX}/settings`);
|
||||
const result = await parseJson<SyncSettings>(response);
|
||||
if (!response.ok || result.status !== 'success') {
|
||||
return { data: { syncStrategy: 'LOCAL_OVERWRITE', regexRules: [] }, status: 'error', message: result.message || '无法获取设置' };
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
return { data: { syncStrategy: 'LOCAL_OVERWRITE', regexRules: [] }, status: 'error', message: '无法获取设置' };
|
||||
}
|
||||
},
|
||||
|
||||
saveStrategy: async (strategy: string): Promise<ApiResponse<SyncSettings>> => {
|
||||
try {
|
||||
const response = await fetch(`${API_PREFIX}/settings/strategy`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ strategy })
|
||||
});
|
||||
return await parseJson<SyncSettings>(response);
|
||||
} catch (error) {
|
||||
return { data: { syncStrategy: strategy, regexRules: [] }, status: 'error', message: '无法保存同步策略' };
|
||||
}
|
||||
},
|
||||
|
||||
getRegexRules: async (): Promise<ApiResponse<RegexReplacement[]>> => {
|
||||
try {
|
||||
const response = await fetch(`${API_PREFIX}/regex-rules`);
|
||||
return await parseJson<RegexReplacement[]>(response);
|
||||
} catch (error) {
|
||||
return { data: [], status: 'error', message: '无法获取正则规则' };
|
||||
}
|
||||
},
|
||||
|
||||
saveRegexRules: async (rules: RegexReplacement[]): Promise<ApiResponse<RegexReplacement[]>> => {
|
||||
try {
|
||||
const response = await fetch(`${API_PREFIX}/regex-rules`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(rules)
|
||||
});
|
||||
return await parseJson<RegexReplacement[]>(response);
|
||||
} catch (error) {
|
||||
return { data: [], status: 'error', message: '无法保存正则规则' };
|
||||
}
|
||||
},
|
||||
|
||||
getPlaylists: async (serverType: ServerType): Promise<ApiResponse<Playlist[]>> => {
|
||||
try {
|
||||
const data = await fetchPlaylists(serverType);
|
||||
return { data, status: 'success' };
|
||||
const response = await fetch(`${API_PREFIX}/playlists/${mapServerType(serverType)}`);
|
||||
const result = await parseJson<Playlist[]>(response);
|
||||
if (!response.ok || result.status !== 'success') {
|
||||
return { data: [], status: 'error', message: result.message || 'Failed to fetch playlists' };
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(`Error fetching ${serverType} playlists:`, error);
|
||||
return { data: [], status: 'error', message: 'Failed to fetch playlists' };
|
||||
@@ -101,27 +93,48 @@ export const apiService = {
|
||||
|
||||
getServerStatus: async (): Promise<ApiResponse<PlexServerConnection>> => {
|
||||
try {
|
||||
const data = await fetchServerStatus();
|
||||
return { data, status: 'success' };
|
||||
const response = await fetch(`${API_PREFIX}/server/status`);
|
||||
const result = await parseJson<PlexServerConnection>(response);
|
||||
if (!response.ok || result.status !== 'success') {
|
||||
return { data: { isConnected: false }, status: 'error', message: result.message || 'Failed to connect to server' };
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
return {
|
||||
data: { isConnected: false },
|
||||
status: 'error',
|
||||
message: 'Failed to connect to server'
|
||||
return {
|
||||
data: { isConnected: false },
|
||||
status: 'error',
|
||||
message: 'Failed to connect to server'
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
connectToPlex: async (settings: PlexConnectionSettings): Promise<ApiResponse<{ token: string, serverInfo: PlexServerConnection }>> => {
|
||||
try {
|
||||
const data = await authenticatePlex(settings);
|
||||
return { data, status: 'success', message: 'Connected successfully' };
|
||||
const response = await fetch(`${API_PREFIX}/server/connect`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(settings)
|
||||
});
|
||||
return await parseJson<{ token: string, serverInfo: PlexServerConnection }>(response);
|
||||
} catch (error: any) {
|
||||
return {
|
||||
data: { token: '', serverInfo: { isConnected: false } },
|
||||
status: 'error',
|
||||
message: error.message || 'Connection failed'
|
||||
return {
|
||||
data: { token: '', serverInfo: { isConnected: false } },
|
||||
status: 'error',
|
||||
message: error?.message || 'Connection failed'
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
selectLibrary: async (library: string): Promise<ApiResponse<{ libraryName: string }>> => {
|
||||
try {
|
||||
const response = await fetch(`${API_PREFIX}/server/library`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ library })
|
||||
});
|
||||
return await parseJson<{ libraryName: string }>(response);
|
||||
} catch (error) {
|
||||
return { data: { libraryName: library }, status: 'error', message: '无法切换媒体库' };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -61,4 +61,14 @@ export interface ApiResponse<T> {
|
||||
data: T;
|
||||
status: 'success' | 'error';
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface UiConfig {
|
||||
statusCheckIntervalSeconds: number;
|
||||
localPlaylistDir?: string;
|
||||
}
|
||||
|
||||
export interface SyncSettings {
|
||||
syncStrategy: string;
|
||||
regexRules?: RegexReplacement[];
|
||||
}
|
||||
@@ -18,6 +18,11 @@ export default defineConfig(({ mode }) => {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, '.'),
|
||||
}
|
||||
},
|
||||
base: '/',
|
||||
build: {
|
||||
outDir: path.resolve(__dirname, '../app/static/frontend'),
|
||||
emptyOutDir: true,
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user