10 Commits

Author SHA1 Message Date
Koha9 6eefcc6820 Merge branch 'main' into testbed 2025-11-29 05:21:09 +09:00
Koha9 90294a29bf Add a manual sync button to the UI, along with a sync status indicator bar.
Merge commit '74b37a062cb63d578c1584f2ac1df067dc8b5ed3'
2025-11-29 04:08:38 +09:00
Koha9 74b37a062c Squashed 'sample-front-end/' changes from 601ffe4..552f9c4
552f9c4 feat: Centralize animation and timing constants
cc962c2 feat: Adjust sync animation gradient
e623426 feat: Add playlist sync functionality and animations

git-subtree-dir: sample-front-end
git-subtree-split: 552f9c471324793b85af14534e81d45d319036a2
2025-11-29 04:08:38 +09:00
Koha9 80a3e373cf Merge branch 'codex/integrate-sample-front-end-with-backend' 2025-11-29 03:35:40 +09:00
Koha9 c0e45dc674 Fixed the issue where the frontend was not copied to Docker. 2025-11-29 03:26:21 +09:00
Koha9 832dbc11d5 Fix frontend entrypoint for Vite build 2025-11-29 00:25:45 +09:00
Koha9 5a29265854 Squashed 'sample-front-end/' changes from 0881bf1..601ffe4
601ffe4 fix: Refine UI layout and visual elements
4689aaa feat: Add request timeout and cancellation to API calls

git-subtree-dir: sample-front-end
git-subtree-split: 601ffe468a78955839eef6c839314d9b96ea204d
2025-11-28 22:43:39 +09:00
Koha9 e5ba790b44 Merge commit '5a29265854cee9c039f746f5685a538eff048fae' 2025-11-28 22:43:39 +09:00
Koha9 4e91c2acdf Squashed 'sample-front-end/' content from commit 0881bf1
git-subtree-dir: sample-front-end
git-subtree-split: 0881bf1c045118585100360b2c47594cd94b89f1
2025-11-28 01:31:35 +09:00
Koha9 4c6af7115e Merge commit '4e91c2acdf7300f4342b83ea290487c4ef664df4' as 'sample-front-end' 2025-11-28 01:31:35 +09:00
40 changed files with 5813 additions and 12 deletions
+5
View File
@@ -129,6 +129,11 @@ ENV/
env.bak/ env.bak/
venv.bak/ venv.bak/
# Node / frontend assets
node_modules/
frontend/node_modules/
frontend/dist/
# Spyder project settings # Spyder project settings
.spyderproject .spyderproject
.spyproject .spyproject
+1
View File
@@ -13,6 +13,7 @@ COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app COPY app ./app
COPY frontend ./frontend
EXPOSE 8080 EXPOSE 8080
+5 -2
View File
@@ -2,7 +2,10 @@
"theme": "auto", "theme": "auto",
"token": "", "token": "",
"server_url": "", "server_url": "",
"server_port": "", "server_port": "32400",
"server_scheme": "", "server_scheme": "https",
"library_name": "",
"sync_mode": "merge_local_primary",
"local_path": "playlist",
"path_rules": [] "path_rules": []
} }
+321 -9
View File
@@ -1,19 +1,37 @@
import os import os
from app.utils.config import server_config from datetime import datetime
from app.utils.playlist_merge import SyncMode, sync_all_playlists, TEST_PLAYLIST_DIR from typing import Sequence
from fastapi import FastAPI, Request, Form
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi import FastAPI, Form, HTTPException, Query, Request
from fastapi.templating import Jinja2Templates from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from app.utils.plex_client import plex_client from fastapi.templating import Jinja2Templates
from pydantic import BaseModel, Field
from app.utils.config import server_config
from app.utils.local_playlist import load_local_playlist, scan_local_playlists
from app.utils.logger import logger from app.utils.logger import logger
from app.utils.local_playlist import scan_local_playlists from app.utils.playlist_merge import SyncMode, TEST_PLAYLIST_DIR, sync_all_playlists
from app.utils.plex_client import plex_client
app = FastAPI() app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
templates = Jinja2Templates( templates = Jinja2Templates(
directory=os.path.join(os.path.dirname(__file__), "templates") directory=os.path.join(os.path.dirname(__file__), "templates")
) )
FRONTEND_DIST_PATH = os.path.abspath(
os.path.join(os.path.dirname(__file__), "..", "frontend", "dist")
)
# mount static files # mount static files
# 这里的路径是相对于 main.py 文件所在的目录 # 这里的路径是相对于 main.py 文件所在的目录
app.mount( app.mount(
@@ -22,6 +40,13 @@ app.mount(
name="static", name="static",
) )
if os.path.isdir(os.path.join(FRONTEND_DIST_PATH, "assets")):
app.mount(
"/assets",
StaticFiles(directory=os.path.join(FRONTEND_DIST_PATH, "assets")),
name="frontend-assets",
)
SYNC_MODE_OPTIONS = [ SYNC_MODE_OPTIONS = [
{ {
@@ -47,6 +72,45 @@ SYNC_MODE_OPTIONS = [
] ]
class PlaylistItem(BaseModel):
id: str
title: str
trackCount: int = Field(..., ge=0)
lastUpdated: str | None = None
class RegexRule(BaseModel):
pattern: str
replacement: str = ""
class SyncSettingsResponse(BaseModel):
sync_mode: str
path_rules: list[RegexRule]
local_path: str
library_name: str | None = None
server_url: str | None = None
scheme: str | None = None
port: str | None = None
token: str | None = None
class ConnectRequest(BaseModel):
protocol: str = Field("https", pattern="https?", description="HTTP or HTTPS")
address: str
port: str = "32400"
token: str = ""
username: str | None = None
password: str | None = None
timeout: int | None = None
library_name: str | None = None
class ConnectResponse(BaseModel):
token: str
serverInfo: dict
def _get_cloud_playlists() -> tuple[list[dict], str, dict, str, list[str]]: def _get_cloud_playlists() -> tuple[list[dict], str, dict, str, list[str]]:
"""Fetch playlists and connection state from the remote Plex server.""" """Fetch playlists and connection state from the remote Plex server."""
@@ -110,6 +174,251 @@ def _get_cloud_playlists() -> tuple[list[dict], str, dict, str, list[str]]:
return playlists, status, server_info, selected_library, music_libraries return playlists, status, server_info, selected_library, music_libraries
def _library_dicts(names: Sequence[str]) -> list[dict]:
return [{"id": name, "title": name, "type": "artist"} for name in names]
def _playlist_item(name: str, track_count: int, prefix: str, last_updated: float | None = None) -> PlaylistItem:
updated_value = (
datetime.utcfromtimestamp(last_updated).isoformat() + "Z"
if last_updated
else datetime.utcnow().isoformat() + "Z"
)
return PlaylistItem(
id=f"{prefix}-{name}",
title=name,
trackCount=track_count,
lastUpdated=updated_value,
)
def _scan_local_playlists_with_meta(local_path: str) -> list[PlaylistItem]:
items: list[PlaylistItem] = []
base_path = local_path or server_config.local_path
if not base_path:
return items
absolute_path = os.path.abspath(base_path)
if not os.path.isdir(absolute_path):
logger.warning(f"Playlist path does not exist or is not a directory: {absolute_path}")
return items
for entry in os.scandir(absolute_path):
if not entry.is_file():
continue
if not entry.name.lower().endswith((".m3u", ".m3u8")):
continue
tracks = load_local_playlist(entry.path)
stat_info = entry.stat()
items.append(
_playlist_item(
name=entry.name,
track_count=len(tracks),
prefix="local",
last_updated=stat_info.st_mtime,
)
)
items.sort(key=lambda playlist: playlist.title.lower())
return items
def _get_server_status() -> tuple[dict, str, list[dict]]:
"""Return server connection status and available libraries."""
server_config.load()
if not server_config.url:
return {"isConnected": False}, "unset", []
connection_status = "failed"
libraries: list[dict] = []
server_info = {
"isConnected": False,
"name": "未设置",
"ip": server_config.url,
"port": server_config.port,
"libraryName": server_config.library_name,
}
try:
plex_client.connect(
token=server_config.token,
scheme=server_config.scheme,
url=server_config.url,
port=server_config.port,
)
connection_status = "connected" if plex_client.connected else "failed"
server_info.update(
{
"isConnected": plex_client.connected,
"name": getattr(plex_client.server, "friendlyName", "未命名服务器"),
"ip": server_config.url,
"port": server_config.port,
}
)
if plex_client.connected:
lib_names = plex_client.get_libs_name_list()
libraries = _library_dicts(lib_names)
if lib_names:
selected_library = server_config.library_name or lib_names[0]
if selected_library not in lib_names:
selected_library = lib_names[0]
server_config.set_and_save_config(library_name=selected_library)
server_info["libraryName"] = selected_library
except Exception as exc:
logger.warning(f"Failed to connect to Plex server: {exc}")
return server_info, connection_status, libraries
class SyncModePayload(BaseModel):
mode: SyncMode
class RegexRulePayload(BaseModel):
rules: list[RegexRule]
class LibrarySelection(BaseModel):
library_name: str
class SyncRequest(BaseModel):
mode: SyncMode | None = None
local_path: str | None = None
@app.get("/api/settings", response_model=SyncSettingsResponse)
async def get_settings():
server_config.load()
rules = [
RegexRule(pattern=rule.get("pattern", ""), replacement=rule.get("replacement", ""))
for rule in server_config.path_rules
]
return SyncSettingsResponse(
sync_mode=server_config.sync_mode,
path_rules=rules,
local_path=server_config.local_path,
library_name=server_config.library_name,
server_url=server_config.url,
scheme=server_config.scheme,
port=server_config.port,
token=server_config.token,
)
@app.put("/api/settings/sync-mode")
async def update_sync_mode(payload: SyncModePayload):
server_config.set_and_save_config(sync_mode=payload.mode.value)
return {"sync_mode": payload.mode.value}
@app.get("/api/settings/regex-rules")
async def get_regex_rules():
server_config.load()
return {"rules": server_config.path_rules}
@app.put("/api/settings/regex-rules")
async def update_regex_rules(payload: RegexRulePayload):
server_config.set_and_save_config(path_rules=[rule.model_dump() for rule in payload.rules])
return {"rules": payload.rules}
@app.put("/api/settings/library")
async def update_library(payload: LibrarySelection):
server_config.set_and_save_config(library_name=payload.library_name)
return {"library_name": server_config.library_name}
@app.get("/api/server")
async def api_server_status():
server_info, status, libraries = _get_server_status()
return {"status": status, "serverInfo": server_info, "libraries": libraries}
@app.post("/api/connect", response_model=ConnectResponse)
async def api_connect(payload: ConnectRequest):
try:
_, token = plex_client.connect(
username=payload.username or "",
password=payload.password or "",
token=payload.token or "",
scheme=payload.protocol,
url=payload.address,
port=payload.port,
)
libraries = []
selected_library = payload.library_name or server_config.library_name
if plex_client.connected:
lib_names = plex_client.get_libs_name_list()
libraries = _library_dicts(lib_names)
if lib_names:
if not selected_library or selected_library not in lib_names:
selected_library = lib_names[0]
server_config.set_and_save_config(
token=token,
scheme=payload.protocol,
url=payload.address,
port=payload.port,
library_name=selected_library or "",
)
server_info = {
"isConnected": plex_client.connected,
"name": getattr(plex_client.server, "friendlyName", "未命名服务器") if plex_client.connected else "未命名服务器",
"ip": payload.address,
"port": payload.port,
"libraryName": selected_library or "",
"libraries": libraries,
}
return ConnectResponse(token=token, serverInfo=server_info)
except Exception as exc:
logger.warning(f"Failed to connect via API: {exc}")
raise HTTPException(status_code=400, detail=str(exc))
@app.get("/api/playlists")
async def api_playlists(server: str = Query(..., pattern="(?i)^(local|cloud)$"), local_path: str | None = None):
server_type = server.lower()
if server_type == "local":
resolved_path = local_path or server_config.local_path
server_config.set_and_save_config(local_path=resolved_path)
playlists = _scan_local_playlists_with_meta(resolved_path)
return {"playlists": [item.model_dump() for item in playlists]}
playlists, connection_status, server_info, selected_library, libraries = _get_cloud_playlists()
items = [_playlist_item(item["name"], item.get("track_count", 0), "cloud") for item in playlists]
return {
"playlists": [item.model_dump() for item in items],
"connection_status": connection_status,
"server_info": server_info,
"library": selected_library,
"libraries": _library_dicts(libraries),
}
@app.post("/api/sync")
async def api_sync(payload: SyncRequest):
server_config.load()
try:
sync_mode = payload.mode or SyncMode(server_config.sync_mode)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
local_dir = payload.local_path or server_config.local_path
results = sync_all_playlists(local_dir=local_dir, mode=sync_mode, test_folder=TEST_PLAYLIST_DIR)
merged_count = sum(len(item.merged_paths) for item in results)
conflict_count = sum(len(item.conflicts) for item in results)
deleted_count = sum(1 for item in results if item.action == "deleted")
return {
"mode": sync_mode.value,
"merged_count": merged_count,
"conflict_count": conflict_count,
"delete_count": deleted_count,
"playlist_count": len(results),
"output_dir": TEST_PLAYLIST_DIR,
}
def _build_home_context( def _build_home_context(
request: Request, request: Request,
local_path: str, local_path: str,
@@ -140,7 +449,7 @@ def _build_home_context(
"selected_library": selected_library, "selected_library": selected_library,
"music_libraries": music_libraries, "music_libraries": music_libraries,
"sync_modes": SYNC_MODE_OPTIONS, "sync_modes": SYNC_MODE_OPTIONS,
"selected_mode": selected_mode, "selected_mode": selected_mode or server_config.sync_mode,
"message": message, "message": message,
"message_type": message_type, "message_type": message_type,
"sync_result": sync_result, "sync_result": sync_result,
@@ -151,8 +460,11 @@ def _build_home_context(
# 显示主页 # 显示主页
@app.get("/", response_class=HTMLResponse) @app.get("/", response_class=HTMLResponse)
async def home(request: Request, local_path: str = "playlist"): async def home(request: Request, local_path: str = "playlist"):
context = _build_home_context(request, local_path) index_path = os.path.join(FRONTEND_DIST_PATH, "index.html")
if os.path.exists(index_path):
return FileResponse(index_path)
context = _build_home_context(request, local_path or server_config.local_path)
return templates.TemplateResponse("home.html", context) return templates.TemplateResponse("home.html", context)
+20
View File
@@ -2,6 +2,8 @@ import json
import os import os
from app.utils.logger import logger from app.utils.logger import logger
DEFAULT_SYNC_MODE = "merge_local_primary"
CONFIG_PATH = os.path.abspath( CONFIG_PATH = os.path.abspath(
os.path.join(os.path.dirname(__file__), "..", "config.json") os.path.join(os.path.dirname(__file__), "..", "config.json")
) )
@@ -16,6 +18,8 @@ class ServerConfig:
self.scheme = "https" self.scheme = "https"
self.port = "32400" self.port = "32400"
self.library_name = "" self.library_name = ""
self.sync_mode = DEFAULT_SYNC_MODE
self.local_path = "playlist"
self.path_rules: list[dict[str, str]] = [] self.path_rules: list[dict[str, str]] = []
self.load() self.load()
@@ -40,6 +44,8 @@ class ServerConfig:
self.scheme = config.get("server_scheme", "https") self.scheme = config.get("server_scheme", "https")
self.port = config.get("server_port", "32400") self.port = config.get("server_port", "32400")
self.library_name = config.get("library_name", "") self.library_name = config.get("library_name", "")
self.sync_mode = config.get("sync_mode", DEFAULT_SYNC_MODE)
self.local_path = config.get("local_path", "playlist")
self.path_rules = config.get("path_rules", []) or [] self.path_rules = config.get("path_rules", []) or []
logger.info(f"Server config loaded: {self.__dict__}") logger.info(f"Server config loaded: {self.__dict__}")
@@ -51,6 +57,8 @@ class ServerConfig:
"server_scheme": self.scheme, "server_scheme": self.scheme,
"server_port": self.port, "server_port": self.port,
"library_name": self.library_name, "library_name": self.library_name,
"sync_mode": self.sync_mode,
"local_path": self.local_path,
"path_rules": self.path_rules, "path_rules": self.path_rules,
} }
with open(CONFIG_PATH, "w", encoding="utf-8") as f: with open(CONFIG_PATH, "w", encoding="utf-8") as f:
@@ -72,6 +80,12 @@ class ServerConfig:
def set_library(self, library_name: str) -> None: def set_library(self, library_name: str) -> None:
self.library_name = library_name or "" self.library_name = library_name or ""
def set_sync_mode(self, sync_mode: str) -> None:
self.sync_mode = sync_mode
def set_local_path(self, local_path: str) -> None:
self.local_path = local_path or "playlist"
def set_theme(self, theme: str) -> None: def set_theme(self, theme: str) -> None:
# check theme is valid # check theme is valid
if theme not in ["auto", "dark", "light"]: if theme not in ["auto", "dark", "light"]:
@@ -90,6 +104,8 @@ class ServerConfig:
scheme: str = None, scheme: str = None,
port: str = None, port: str = None,
library_name: str | None = None, library_name: str | None = None,
sync_mode: str | None = None,
local_path: str | None = None,
path_rules: list[dict[str, str]] | None = None, path_rules: list[dict[str, str]] | None = None,
) -> None: ) -> None:
if theme is not None: if theme is not None:
@@ -104,6 +120,10 @@ class ServerConfig:
self.set_port(port) self.set_port(port)
if library_name is not None: if library_name is not None:
self.set_library(library_name) self.set_library(library_name)
if sync_mode is not None:
self.set_sync_mode(sync_mode)
if local_path is not None:
self.set_local_path(local_path)
if path_rules is not None: if path_rules is not None:
self.set_path_rules(path_rules) self.set_path_rules(path_rules)
self.save() self.save()
+1 -1
View File
@@ -3,7 +3,7 @@ services:
build: . build: .
command: uvicorn app.main:app --host 0.0.0.0 --port 8080 --reload command: uvicorn app.main:app --host 0.0.0.0 --port 8080 --reload
ports: ports:
- "8080:8080" - "8888:8080"
volumes: volumes:
- ./dockerapp/test_playlists:/app/app/test_playlists - ./dockerapp/test_playlists:/app/app/test_playlists
- ./test_case/local_playlist:/app/playlist:ro - ./test_case/local_playlist:/app/playlist:ro
+24
View File
@@ -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?
+357
View File
@@ -0,0 +1,357 @@
import React, { useEffect, useState, useCallback, useRef } from 'react';
import { Playlist, ServerType, SyncStrategy, PlexServerConnection, RegexReplacement, PlexConnectionSettings } 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<Playlist[]>([]);
const [cloudPlaylists, setCloudPlaylists] = useState<Playlist[]>([]);
const [cloudServerInfo, setCloudServerInfo] = useState<PlexServerConnection | undefined>(undefined);
const [localPath, setLocalPath] = useState<string>('');
const [connectionSettings, setConnectionSettings] = useState<PlexConnectionSettings | null>(null);
const [loadingLocal, setLoadingLocal] = useState(false);
const [loadingCloud, setLoadingCloud] = useState(false);
// Abort Controllers for Refresh Actions
const localAbortRef = useRef<AbortController | null>(null);
const cloudAbortRef = useRef<AbortController | null>(null);
// Connection Modal State
const [isConnectionModalOpen, setIsConnectionModalOpen] = useState(false);
// Strategy State
const [currentStrategy, setCurrentStrategy] = useState<SyncStrategy>(SyncStrategy.LOCAL_OVERWRITE);
// Regex State
const [regexReplacements, setRegexReplacements] = useState<RegexReplacement[]>([]);
// Toast Notification System
const [toasts, setToasts] = useState<Toast[]>([]);
const timeoutsRef = useRef<{[key: number]: ReturnType<typeof setTimeout>}>({});
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]);
const loadSettings = useCallback(async () => {
const result = await apiService.getSettings();
if (result.status === 'success') {
setCurrentStrategy(result.data.strategy);
setRegexReplacements(result.data.regex);
setLocalPath(result.data.localPath || 'playlist');
setConnectionSettings(result.data.connection);
}
}, []);
// Fetch Local Playlists
const refreshLocal = useCallback(async () => {
if (localAbortRef.current) localAbortRef.current.abort();
const abortController = new AbortController();
localAbortRef.current = abortController;
setLoadingLocal(true);
const result = await apiService.getPlaylists(ServerType.LOCAL, abortController.signal, localPath || undefined);
if (result.status === 'success') {
setLocalPlaylists(result.data);
}
setLoadingLocal(false);
localAbortRef.current = null;
}, [localPath]);
const cancelLocalRefresh = () => {
if (localAbortRef.current) {
localAbortRef.current.abort();
localAbortRef.current = null;
setLoadingLocal(false);
addToast("Local refresh cancelled.");
}
};
// Fetch Cloud Playlists and Info
const refreshCloud = useCallback(async () => {
if (cloudAbortRef.current) cloudAbortRef.current.abort();
const abortController = new AbortController();
cloudAbortRef.current = abortController;
setLoadingCloud(true);
// Fetch playlists
const playlistResult = await apiService.getPlaylists(ServerType.CLOUD, abortController.signal);
if (!abortController.signal.aborted) {
if (playlistResult.status === 'success') {
setCloudPlaylists(playlistResult.data);
}
// Fetch server info
const infoResult = await apiService.getServerStatus(abortController.signal);
if (infoResult.status === 'success') {
setCloudServerInfo(infoResult.data);
}
setLoadingCloud(false);
cloudAbortRef.current = null;
}
}, []);
const cancelCloudRefresh = () => {
if (cloudAbortRef.current) {
cloudAbortRef.current.abort();
cloudAbortRef.current = null;
setLoadingCloud(false);
addToast("Cloud refresh cancelled.");
}
};
// Load persisted configuration
useEffect(() => {
loadSettings();
}, [loadSettings]);
// Initial Load
useEffect(() => {
refreshLocal();
refreshCloud();
return () => {
// Cleanup on unmount
if (localAbortRef.current) localAbortRef.current.abort();
if (cloudAbortRef.current) cloudAbortRef.current.abort();
}
}, [refreshLocal, refreshCloud]);
// Handle Strategy Change
const handleStrategyChange = async (strategy: SyncStrategy, label: string) => {
setCurrentStrategy(strategy);
const result = await apiService.updateSyncStrategy(strategy);
if (result.status === 'success') {
addToast(`Selected strategy "${label}" has been saved.`);
} else {
addToast(result.message || 'Failed to save sync strategy.');
}
};
// Handle Regex Save
const handleSaveRegex = async (replacements: RegexReplacement[]) => {
setRegexReplacements(replacements);
const result = await apiService.saveRegexRules(replacements);
if (result.status === 'success') {
addToast('Regex preprocessing rules have been saved.');
} else {
addToast(result.message || 'Failed to save regex rules.');
}
};
const handleConnectSuccess = async (serverInfo: PlexServerConnection) => {
setCloudServerInfo(serverInfo);
if (serverInfo.libraryName) {
await apiService.updateLibrary(serverInfo.libraryName);
setConnectionSettings(prev => prev ? { ...prev, libraryName: serverInfo.libraryName } : prev);
}
// Refresh playlists after new connection
refreshCloud();
};
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 (
<div className="min-h-screen flex flex-col bg-gray-900 text-gray-100 font-sans overflow-hidden bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-gray-800 via-gray-900 to-black">
{/* App Header */}
<header className="flex-none bg-gray-800/80 border-b border-white/5 shadow-md z-20 relative backdrop-blur-md">
<div className="max-w-7xl mx-auto px-4 md:px-6 h-16 flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className="bg-gradient-to-br from-plex-orange to-yellow-600 p-1.5 rounded-lg text-gray-900 shadow-lg shadow-plex-orange/20">
<ArrowLeftRight size={24} strokeWidth={2.5} />
</div>
<h1 className="text-xl font-bold tracking-tight text-white">
Plex<span className="text-plex-orange">Sync</span>
</h1>
</div>
{/* Connection Status Button */}
<button
onClick={() => setIsConnectionModalOpen(true)}
className={`flex items-center justify-center w-9 h-9 rounded-full border transition-all duration-300 hover:scale-105 active:scale-95 shadow-md ${
isConnected
? 'bg-green-500/10 border-green-500/50 text-green-400 hover:bg-green-500/20 hover:shadow-green-500/20'
: 'bg-red-500/10 border-red-500/50 text-red-400 hover:bg-red-500/20 hover:shadow-red-500/20'
}`}
title={isConnected ? "Connected to Plex" : "Disconnected"}
>
{isConnected ? <Server size={18} /> : <ServerOff size={18} />}
</button>
</div>
</header>
{/* Notification Toasts Container */}
<div className="fixed top-20 left-0 right-0 flex justify-center h-0 overflow-visible z-[100] pointer-events-none">
{toasts.map((toast) => (
<div
key={toast.id}
className={getToastClasses()}
style={getToastStyles(toast)}
>
<ShieldCheck size={16} />
<span>{toast.message}</span>
<button
onClick={() => setToasts(prev => prev.map(t => t.id === toast.id ? { ...t, exiting: true } : t))}
className="ml-2 hover:text-white transition-colors"
>
<X size={14} />
</button>
</div>
))}
</div>
{/* Main Content Area */}
<main className="flex-1 overflow-hidden relative z-10">
{/* Reduced gap from gap-3/gap-6 to gap-2/gap-3 for tighter layout */}
<div className="absolute inset-0 flex flex-col md:flex-row max-w-7xl mx-auto p-4 md:p-6 gap-2 md:gap-3">
{/* Left Column - Local */}
<div className="flex-1 min-h-0 h-full w-full">
<ServerPanel
type={ServerType.LOCAL}
playlists={localPlaylists}
isLoading={loadingLocal}
onRefresh={refreshLocal}
onCancel={cancelLocalRefresh}
/>
</div>
{/* Strategy Selector - Positioned specifically between headers */}
<div className="absolute
z-30
/* Mobile Positioning: Center Vertically, Anchored Right */
top-1/2 right-[52px] transform translate-x-1/2 -translate-y-1/2
/* Desktop Positioning: Center Horizontally, Anchored Top */
md:top-[64px] md:right-auto md:left-1/2 md:transform md:-translate-x-1/2 md:-translate-y-1/2"
>
<StrategySelector
currentStrategy={currentStrategy}
onSelect={handleStrategyChange}
savedRegexReplacements={regexReplacements}
onSaveRegex={handleSaveRegex}
/>
</div>
{/* Right Column - Cloud */}
<div className="flex-1 min-h-0 h-full w-full">
<ServerPanel
type={ServerType.CLOUD}
playlists={cloudPlaylists}
isLoading={loadingCloud}
onRefresh={refreshCloud}
onCancel={cancelCloudRefresh}
serverInfo={cloudServerInfo}
/>
</div>
</div>
</main>
{/* Footer */}
<footer className="flex-none py-4 text-center text-xs text-gray-600 border-t border-white/5 bg-gray-900/50 backdrop-blur">
<p>&copy; {new Date().getFullYear()} PlexSync Manager. Connected to Docker backend.</p>
</footer>
{/* Modals */}
<ConnectionModal
isOpen={isConnectionModalOpen}
onClose={() => setIsConnectionModalOpen(false)}
onConnectSuccess={handleConnectSuccess}
onShowMessage={addToast}
initialSettings={connectionSettings || undefined}
/>
</div>
);
};
export default App;
+20
View File
@@ -0,0 +1,20 @@
<div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
</div>
# 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`
+409
View File
@@ -0,0 +1,409 @@
import React, { useState, useEffect, useRef } 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, ChevronRight, Settings, Loader2 } from 'lucide-react';
interface ConnectionModalProps {
isOpen: boolean;
onClose: () => void;
onConnectSuccess: (serverInfo: PlexServerConnection) => void | Promise<void>;
onShowMessage: (message: string) => void;
initialSettings?: Partial<PlexConnectionSettings>;
}
const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onConnectSuccess, onShowMessage, initialSettings }) => {
const [formData, setFormData] = useState<PlexConnectionSettings>({
protocol: 'http',
address: '',
port: '32400',
token: '',
username: '',
password: '',
timeout: 9,
libraryName: ''
});
const [isConnecting, setIsConnecting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showPassword, setShowPassword] = useState(false);
const [showAdvanced, setShowAdvanced] = useState(false);
// Post-connection state
const [connectedServerInfo, setConnectedServerInfo] = useState<PlexServerConnection | null>(null);
const [libraries, setLibraries] = useState<PlexLibrary[]>([]);
const [selectedLibraryId, setSelectedLibraryId] = useState<string>('');
const abortControllerRef = useRef<AbortController | null>(null);
// Reset state when opening
useEffect(() => {
if (isOpen) {
setError(null);
setConnectedServerInfo(null);
setLibraries([]);
setSelectedLibraryId('');
if (initialSettings) {
setFormData(prev => ({
...prev,
protocol: initialSettings.protocol || prev.protocol,
address: initialSettings.address || prev.address,
port: initialSettings.port || prev.port,
token: initialSettings.token || prev.token,
libraryName: initialSettings.libraryName || prev.libraryName,
}));
}
}
return () => {
// Cleanup any pending request if modal closes
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, [isOpen, initialSettings]);
if (!isOpen) return null;
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
};
const handleTimeoutChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = parseInt(e.target.value) || 0;
setFormData(prev => ({ ...prev, timeout: val }));
};
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 };
setConnectedServerInfo(updatedInfo);
onConnectSuccess(updatedInfo);
const saveResult = await apiService.updateLibrary(lib.title);
if (saveResult.status !== 'success') {
onShowMessage(saveResult.message || 'Failed to save library selection');
} else {
onShowMessage(`Library switched to ${lib.title}`);
}
}
};
const isTokenProvided = formData.token.trim().length > 0;
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();
// If already connecting, this acts as Cancel
if (isConnecting) {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
setIsConnecting(false);
setError("Connection cancelled by user.");
}
return;
}
setError(null);
setIsConnecting(true);
const abortController = new AbortController();
abortControllerRef.current = abortController;
const result = await apiService.connectToPlex(formData, abortController.signal);
// Only proceed if we weren't aborted/cancelled (though apiService handles error msg)
if (abortController.signal.aborted) return;
setIsConnecting(false);
abortControllerRef.current = null;
if (result.status === 'success' && result.data) {
setFormData(prev => ({
...prev,
token: result.data.token,
username: '',
password: ''
}));
const info = result.data.serverInfo;
setConnectedServerInfo(info);
onShowMessage(`Successfully connected to ${info.name || 'Plex Server'}`);
const libs = info.libraries || [];
setLibraries(libs);
if (libs.length > 0) {
const preferred = info.libraryName || formData.libraryName;
const defaultLib = libs.find(lib => lib.title === preferred) || libs[0];
setSelectedLibraryId(defaultLib.id);
setFormData(prev => ({ ...prev, libraryName: defaultLib.title }));
onConnectSuccess({
...info,
libraryName: defaultLib.title
});
const saveResult = await apiService.updateLibrary(defaultLib.title);
if (saveResult.status !== 'success') {
setError(saveResult.message || 'Failed to save library selection');
}
} else {
onConnectSuccess(info);
}
} else {
setError(result.message || "Connection failed");
}
};
const isConnected = !!connectedServerInfo;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
<div className="bg-gray-900 border border-gray-700 rounded-xl shadow-2xl w-full max-w-md overflow-hidden animate-in fade-in zoom-in duration-200 flex flex-col max-h-[90vh]">
{/* Header */}
<div className="px-6 py-4 bg-gray-800 border-b border-gray-700 flex items-center justify-between flex-none">
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
<Server size={18} className={isConnected ? "text-green-400" : "text-plex-orange"} />
{isConnected ? 'Server Connected' : 'Connect Plex Server'}
</h3>
<button onClick={onClose} className="text-gray-400 hover:text-white transition-colors">
<X size={20} />
</button>
</div>
{/* Body */}
<div className="p-6 overflow-y-auto custom-scrollbar">
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="p-3 bg-red-500/10 border border-red-500/20 text-red-400 text-xs rounded-md">
{error}
</div>
)}
{/* Server Connection */}
<div className="space-y-3">
<label className="text-xs font-semibold text-gray-400 uppercase tracking-wider">Server Details</label>
<div className="grid grid-cols-4 gap-3">
<div className="col-span-1">
<select
name="protocol"
value={formData.protocol}
onChange={handleChange}
disabled={isConnected || isConnecting}
className={`w-full h-10 px-2 bg-gray-800 border border-gray-600 rounded-md text-sm text-white focus:border-plex-orange focus:outline-none ${isConnected ? 'opacity-60 cursor-not-allowed' : ''}`}
>
<option value="http">HTTP</option>
<option value="https">HTTPS</option>
</select>
</div>
<div className="col-span-3">
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Globe size={14} className="text-gray-500" />
</div>
<input
type="text"
name="address"
required
disabled={isConnected || isConnecting}
placeholder="IP Address or Domain"
value={formData.address}
onChange={handleChange}
className={`w-full h-10 pl-9 pr-3 bg-gray-800 border border-gray-600 rounded-md text-sm text-white placeholder-gray-500 focus:border-plex-orange focus:outline-none focus:ring-1 focus:ring-plex-orange transition-all ${isConnected ? 'opacity-60 cursor-not-allowed' : ''}`}
/>
</div>
</div>
</div>
<div>
<input
type="text"
name="port"
disabled={isConnected || isConnecting}
placeholder="Port (e.g. 32400)"
value={formData.port}
onChange={handleChange}
className={`w-full h-10 px-3 bg-gray-800 border border-gray-600 rounded-md text-sm text-white placeholder-gray-500 focus:border-plex-orange focus:outline-none focus:ring-1 focus:ring-plex-orange transition-all ${isConnected ? 'opacity-60 cursor-not-allowed' : ''}`}
/>
</div>
</div>
<div className="h-px bg-gray-800 my-4" />
{/* Authentication */}
<div className="space-y-3">
<label className="text-xs font-semibold text-gray-400 uppercase tracking-wider">Authentication</label>
{/* Token */}
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Key size={14} className="text-plex-orange" />
</div>
<input
type="text"
name="token"
disabled={isConnected || isConnecting}
placeholder="X-Plex-Token (Optional)"
value={formData.token}
onChange={handleChange}
className={`w-full h-10 pl-9 pr-3 bg-gray-800 border border-gray-600 rounded-md text-sm text-white placeholder-gray-500 focus:border-plex-orange focus:outline-none focus:ring-1 focus:ring-plex-orange transition-all font-mono ${isConnected ? 'opacity-60 cursor-not-allowed' : ''}`}
/>
</div>
{!isConnected && (
<>
<div className="text-center text-[10px] text-gray-500 uppercase tracking-widest font-semibold py-1">
OR
</div>
{/* Username */}
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<User size={14} className={isTokenProvided ? "text-gray-600" : "text-gray-400"} />
</div>
<input
type="text"
name="username"
disabled={isTokenProvided || isConnecting}
placeholder="Username / Email"
value={formData.username}
onChange={handleChange}
className={`w-full h-10 pl-9 pr-3 rounded-md text-sm transition-all focus:outline-none ${disabledInputClass}`}
/>
</div>
{/* Password */}
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Lock size={14} className={isTokenProvided ? "text-gray-600" : "text-gray-400"} />
</div>
<input
type={showPassword ? "text" : "password"}
name="password"
disabled={isTokenProvided || isConnecting}
placeholder="Password"
value={formData.password}
onChange={handleChange}
className={`w-full h-10 pl-9 pr-10 rounded-md text-sm transition-all focus:outline-none ${disabledInputClass}`}
/>
<button
type="button"
disabled={isTokenProvided || isConnecting}
onClick={() => setShowPassword(!showPassword)}
className={`absolute inset-y-0 right-0 pr-3 flex items-center ${isTokenProvided ? 'cursor-not-allowed opacity-50' : 'cursor-pointer text-gray-400 hover:text-white'}`}
>
{showPassword ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div>
</>
)}
</div>
{/* Advanced Options */}
{!isConnected && (
<div className="border border-gray-800 rounded-lg overflow-hidden">
<button
type="button"
onClick={() => setShowAdvanced(!showAdvanced)}
className="w-full flex items-center justify-between px-3 py-2 bg-gray-800/50 hover:bg-gray-800 text-xs font-medium text-gray-400 hover:text-gray-200 transition-colors"
>
<div className="flex items-center gap-2">
<Settings size={14} />
<span>Advanced Options</span>
</div>
{showAdvanced ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</button>
{showAdvanced && (
<div className="p-3 bg-gray-900/50 space-y-3 animate-in slide-in-from-top-2">
<div>
<label className="text-xs text-gray-500 mb-1 block">Connection Timeout (Seconds)</label>
<input
type="number"
min="1"
max="60"
name="timeout"
value={formData.timeout || 9}
onChange={handleTimeoutChange}
disabled={isConnecting}
className="w-full h-8 px-2 bg-gray-800 border border-gray-700 rounded-md text-xs text-white focus:border-plex-orange focus:outline-none"
/>
</div>
</div>
)}
</div>
)}
{!isConnected ? (
<button
type="submit"
className={`w-full mt-4 py-2.5 rounded-lg text-sm font-bold text-gray-900 transition-all shadow-lg flex items-center justify-center gap-2
${isConnecting
? 'bg-red-500/80 hover:bg-red-500 text-white animate-pulse'
: 'bg-plex-orange hover:bg-yellow-500 active:scale-[0.98] shadow-plex-orange/20'
}`}
>
{isConnecting ? (
<>
<Loader2 size={16} className="animate-spin" />
<span>Connecting... <span className="opacity-75 font-normal ml-1">(Cancel)</span></span>
</>
) : 'Connect Server'}
</button>
) : (
<div className="mt-2 p-2 bg-green-500/10 border border-green-500/20 rounded-lg text-center">
<p className="text-green-400 text-sm font-semibold flex items-center justify-center gap-2">
<CheckCircle size={16} />
Connected Successfully
</p>
</div>
)}
</form>
{/* Library Selection - Appears after connection */}
{isConnected && libraries.length > 0 && (
<div className="mt-6 pt-5 border-t border-gray-700 animate-in slide-in-from-top-2 fade-in">
<label className="text-xs font-semibold text-gray-400 uppercase tracking-wider block mb-2">Select Library to Sync</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Library size={14} className="text-plex-orange" />
</div>
<select
value={selectedLibraryId}
onChange={handleLibraryChange}
className="w-full h-10 pl-9 pr-3 bg-gray-800 border border-gray-600 rounded-md text-sm text-white focus:border-plex-orange focus:outline-none focus:ring-1 focus:ring-plex-orange appearance-none cursor-pointer hover:bg-gray-700/50 transition-colors"
>
{libraries.map(lib => (
<option key={lib.id} value={lib.id}>{lib.title}</option>
))}
</select>
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
<ChevronDown size={14} className="text-gray-500" />
</div>
</div>
<div className="mt-6 flex justify-end">
<button
onClick={onClose}
className="px-6 py-2 bg-gray-700 hover:bg-gray-600 text-white text-sm font-medium rounded-lg transition-colors border border-gray-600 hover:border-gray-500"
>
Done
</button>
</div>
</div>
)}
</div>
</div>
</div>
);
};
export default ConnectionModal;
+32
View File
@@ -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<PlaylistCardProps> = ({ playlist }) => {
return (
<div className="group flex flex-col w-full p-2.5 bg-gray-800/60 rounded-md border border-gray-700/50 hover:bg-gray-700 hover:border-plex-orange/50 transition-all duration-200 cursor-pointer shadow-sm">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium text-gray-200 truncate flex-1 mr-2 group-hover:text-white transition-colors">
{playlist.title}
</h4>
</div>
<div className="flex items-center mt-1.5 space-x-4 text-xs text-gray-500 group-hover:text-gray-400">
<span className="flex items-center" title="Track Count">
<Disc3 size={12} className="mr-1.5 opacity-70" />
{playlist.trackCount}
</span>
<span className="flex items-center" title="Last Updated">
<Clock size={12} className="mr-1.5 opacity-70" />
{new Date(playlist.lastUpdated).toLocaleDateString()}
</span>
</div>
</div>
);
};
export default PlaylistCard;
+162
View File
@@ -0,0 +1,162 @@
import React from 'react';
import { Playlist, ServerType, PlexServerConnection } from '../types';
import PlaylistCard from './PlaylistCard';
import { RefreshCw, Server, Cloud, WifiOff, X } from 'lucide-react';
interface ServerPanelProps {
type: ServerType;
playlists: Playlist[];
isLoading: boolean;
onRefresh: () => void;
onCancel?: () => void;
serverInfo?: PlexServerConnection;
}
const ServerPanel: React.FC<ServerPanelProps> = ({ type, playlists, isLoading, onRefresh, onCancel, 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 = (
<p className="text-xs text-gray-400 font-medium mt-0.5 md:mt-0 md:ml-0">
{playlists.length} Playlists
</p>
);
} else {
// Cloud Logic
if (serverInfo) {
if (serverInfo.isConnected) {
displayTitle = serverInfo.name || 'Cloud Server';
displaySubtitle = (
<div className="flex items-center text-xs text-gray-300 font-medium space-x-1.5 truncate mt-0.5 md:mt-0">
<span className="text-plex-orange truncate font-semibold">{serverInfo.libraryName}</span>
<span className="text-gray-600 hidden md:inline"></span>
<span className="text-gray-500 font-mono text-[10px] hidden md:inline">{serverInfo.ip}:{serverInfo.port}</span>
</div>
);
} else {
displayTitle = 'Not Connected';
Icon = WifiOff;
headerColor = 'text-red-400';
displaySubtitle = (
<p className="text-xs text-gray-500 font-medium mt-0.5">
Connection failed
</p>
);
}
} else {
displayTitle = 'Cloud Server';
displaySubtitle = (
<p className="text-xs text-gray-500 font-medium mt-0.5">
{isLoading ? 'Connecting...' : 'Waiting...'}
</p>
);
}
}
// Handle Refresh/Cancel Click
const handleAction = () => {
if (isLoading && onCancel) {
onCancel();
} else {
onRefresh();
}
};
return (
<div className={`flex flex-row md:flex-col h-full ${bgGradient} rounded-2xl border ${borderColor} backdrop-blur-xl shadow-xl overflow-hidden transition-all duration-300`}>
{/* Header */}
<div
className={`
relative flex-none
order-last md:order-first
w-[72px] md:w-full
h-full md:h-auto md:min-h-[80px]
flex flex-col md:flex-row items-center justify-between
py-6 md:py-0 md:px-8
bg-gray-800/60 border-l md:border-l-0 md:border-b border-white/5
`}
>
{/* Title Group */}
<div className="flex flex-col md:flex-row items-center md:space-x-4 overflow-hidden w-full md:w-auto h-full md:h-full md:py-4">
{/* Icon Box */}
<div className={`p-2.5 rounded-xl bg-gray-900/50 border border-white/5 ${headerColor} shadow-inner flex-shrink-0 mb-4 md:mb-0`}>
<Icon size={22} strokeWidth={2} />
</div>
{/* Text Container */}
<div className="flex-1 min-w-0 flex flex-col justify-center items-center md:items-start h-full md:h-auto w-full md:w-auto">
<div className="flex flex-col justify-center w-full md:w-auto [writing-mode:vertical-rl] rotate-180 md:[writing-mode:horizontal-tb] md:rotate-0 items-center md:items-start gap-1 md:gap-0">
<h2 className="text-sm md:text-lg font-bold text-gray-100 tracking-wide whitespace-nowrap" title={displayTitle}>
{displayTitle}
</h2>
<div className="transform md:translate-y-0">
{displaySubtitle}
</div>
</div>
</div>
</div>
{/* Refresh/Stop Button */}
<button
onClick={handleAction}
className={`flex-shrink-0 p-2.5 rounded-full transition-all active:scale-90 mt-4 md:mt-0 md:ml-4 border border-transparent group relative
${isLoading
? 'text-plex-orange bg-plex-orange/10 border-plex-orange/20 hover:bg-red-500/10 hover:border-red-500/30'
: 'text-gray-400 hover:text-white hover:bg-white/10'
}
`}
title={isLoading ? "Cancel Refresh" : "Refresh Playlists"}
>
{isLoading ? (
<div className="relative flex items-center justify-center">
{/* Outer Spinner */}
<RefreshCw size={20} strokeWidth={2} className="animate-spin opacity-40 group-hover:opacity-20 transition-opacity" />
{/* Inner Cancel X */}
<X size={12} strokeWidth={3} className="absolute text-plex-orange group-hover:text-red-400 transition-colors" />
</div>
) : (
<RefreshCw size={20} strokeWidth={2} />
)}
</button>
</div>
{/* Content List */}
<div className="flex-1 overflow-y-auto p-3 md:p-5 custom-scrollbar bg-black/20">
{isLoading && playlists.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-gray-500 space-y-3">
<RefreshCw size={24} className="animate-spin text-plex-orange/50" />
<p className="text-xs font-medium tracking-wide uppercase">Syncing...</p>
</div>
) : playlists.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-gray-500">
<p className="text-sm">No playlists found.</p>
</div>
) : (
<div className="space-y-2.5 md:space-y-3">
{playlists.map((playlist) => (
<PlaylistCard key={playlist.id} playlist={playlist} />
))}
</div>
)}
</div>
</div>
);
};
export default ServerPanel;
+297
View File
@@ -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<StrategySelectorProps> = ({
currentStrategy,
onSelect,
savedRegexReplacements,
onSaveRegex
}) => {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
// Local state for regex editing
const [localReplacements, setLocalReplacements] = useState<RegexReplacement[]>([]);
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 (
<div className="relative group" ref={dropdownRef}>
{/* Trigger Button - Added Ring to create visual 'cutout' over panels */}
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center justify-center w-12 h-12 rounded-full bg-gray-800/90 border border-gray-600 hover:border-plex-orange text-gray-300 hover:text-white hover:bg-gray-700/80 transition-all shadow-2xl hover:shadow-plex-orange/30 ring-[6px] md:ring-8 ring-gray-900 backdrop-blur-sm active:scale-95"
title={`Current Strategy: ${selectedOption.label}`}
>
<selectedOption.icon size={22} className={selectedOption.color} strokeWidth={2.5} />
<div className="absolute -bottom-1 -right-1 bg-gray-900 rounded-full border border-gray-600 p-[2px] shadow-sm">
<ChevronDown size={10} className="text-gray-400" />
</div>
</button>
{/* Dropdown Menu - Persistent Mount for State Preservation */}
<div
className={`absolute
top-14
/* Mobile: Open to left */
right-0 origin-top-right
/* Desktop: Center alignment */
md:left-1/2 md:right-auto md:origin-top md:-translate-x-1/2
w-80 md:w-[30rem] bg-gray-800/95 border border-white/10 rounded-xl shadow-2xl z-50 overflow-hidden backdrop-blur-xl
transition-all duration-200 ease-out
${isOpen ? 'opacity-100 scale-100 visible translate-y-0' : 'opacity-0 scale-95 invisible pointer-events-none -translate-y-2'}`}
>
{/* Section 1: Sync Strategy */}
<div className="px-4 py-3 bg-black/20 border-b border-white/5">
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest mb-2">Sync Strategy</h3>
<div className="space-y-1">
{STRATEGIES.map((strategy) => (
<div
key={strategy.value}
onClick={() => 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'
}`}
>
<div className="flex items-center space-x-3 overflow-hidden">
<strategy.icon size={18} className={strategy.color} />
<span className={`text-sm font-medium truncate ${currentStrategy === strategy.value ? 'text-white' : 'text-gray-300 group-hover:text-white'}`}>
{strategy.label}
</span>
</div>
<div className="flex items-center space-x-2">
<div className="relative group/tooltip">
<HelpCircle size={14} className="text-gray-600 hover:text-gray-400 transition-colors" />
<div className="absolute right-0 bottom-full mb-2 w-48 p-2.5 bg-gray-900 text-xs text-gray-300 rounded-lg shadow-xl border border-gray-700 pointer-events-none opacity-0 group-hover/tooltip:opacity-100 transition-opacity z-50">
{strategy.description}
</div>
</div>
{currentStrategy === strategy.value && (
<Check size={14} className="text-plex-orange" strokeWidth={3} />
)}
</div>
</div>
))}
</div>
</div>
{/* Section 2: Regex Preprocessing */}
<div className="p-4 bg-gray-900/40">
<div className="flex items-center justify-between mb-3">
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest">Regex Rules</h3>
{localReplacements.length === 0 && (
<button
onClick={handleAddRegex}
className="p-1 rounded bg-gray-700/50 hover:bg-gray-600 text-gray-400 hover:text-white transition-colors"
title="Add Rule"
>
<Plus size={14} />
</button>
)}
</div>
<div className="space-y-2 mb-4 max-h-52 overflow-y-auto pr-1 custom-scrollbar">
{localReplacements.length === 0 ? (
<div className="text-xs text-gray-600 italic text-center py-4 border border-dashed border-gray-700/50 rounded-lg">
No regex replacements configured.
</div>
) : (
localReplacements.map((regex) => (
<div key={regex.id} className="flex items-center space-x-2 animate-in slide-in-from-left-2 duration-200">
<div className="flex-1 min-w-0">
<input
type="text"
placeholder="Regex Pattern"
value={regex.pattern}
onChange={(e) => 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'}`}
/>
</div>
<div className="flex-none text-gray-600">
<ArrowRightCircle size={12} />
</div>
<div className="flex-1 min-w-0">
<input
type="text"
placeholder="Replacement"
value={regex.replacement}
onChange={(e) => 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"
/>
</div>
<button
onClick={() => handleDeleteRegex(regex.id)}
className="text-gray-600 hover:text-red-400 p-1.5 hover:bg-red-500/10 rounded transition-colors"
title="Delete Rule"
>
<Trash2 size={14} />
</button>
</div>
))
)}
</div>
{/* Actions */}
<div className="space-y-3 pt-3 border-t border-white/5">
{localReplacements.length > 0 && (
<div className="flex justify-center">
<button
onClick={handleAddRegex}
className="flex items-center space-x-1.5 text-xs text-plex-orange hover:text-yellow-400 transition-colors opacity-80 hover:opacity-100"
>
<Plus size={12} />
<span className="font-medium">Add Rule</span>
</button>
</div>
)}
<div className="grid grid-cols-2 gap-3">
<button
onClick={handleReset}
disabled={!isDirty}
className={`flex items-center justify-center space-x-2 py-1.5 rounded-lg text-xs font-medium border transition-all
${isDirty
? 'bg-gray-800 border-gray-600 text-gray-300 hover:bg-gray-700 hover:text-white'
: 'bg-transparent border-gray-800 text-gray-600 cursor-not-allowed'}`}
>
<RotateCcw size={14} />
<span>Revert</span>
</button>
<button
onClick={handleSave}
disabled={!isDirty}
className={`flex items-center justify-center space-x-2 py-1.5 rounded-lg text-xs font-bold border transition-all
${isDirty
? 'bg-plex-orange border-plex-orange text-gray-900 hover:bg-yellow-500 shadow-lg shadow-plex-orange/10'
: 'bg-gray-800/50 border-gray-800 text-gray-600 cursor-not-allowed'}`}
>
<Save size={14} />
<span>Save Changes</span>
</button>
</div>
</div>
</div>
</div>
</div>
);
};
export default StrategySelector;
+55
View File
@@ -0,0 +1,55 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PlexSync Manager</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
plex: {
orange: '#e5a00d',
dark: '#1f2937',
darker: '#111827',
card: '#374151'
}
}
}
}
}
</script>
<style>
/* Custom scrollbar for webkit to match dark theme */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: #111827;
}
::-webkit-scrollbar-thumb {
background: #4b5563;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #6b7280;
}
</style>
<script type="importmap">
{
"imports": {
"lucide-react": "https://aistudiocdn.com/lucide-react@^0.555.0",
"react/": "https://aistudiocdn.com/react@^19.2.0/",
"react": "https://aistudiocdn.com/react@^19.2.0",
"react-dom/": "https://aistudiocdn.com/react-dom@^19.2.0/"
}
}
</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>
</body>
</html>
+15
View File
@@ -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(
<React.StrictMode>
<App />
</React.StrictMode>
);
+5
View File
@@ -0,0 +1,5 @@
{
"name": "PlexSync Manager",
"description": "A modern dashboard to synchronize and manage playlists between Local and Cloud Plex servers.",
"requestFramePermissions": []
}
+1771
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -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"
}
}
+158
View File
@@ -0,0 +1,158 @@
import { Playlist, ServerType, ApiResponse, PlexServerConnection, PlexConnectionSettings, PlexLibrary, RegexReplacement, SyncStrategy } from '../types';
const API_BASE = import.meta.env.VITE_API_BASE_URL || '';
const MODE_TO_STRATEGY: Record<string, SyncStrategy> = {
local_force: SyncStrategy.LOCAL_OVERWRITE,
remote_force: SyncStrategy.CLOUD_OVERWRITE,
merge_local_primary: SyncStrategy.MERGE_LOCAL,
merge_remote_primary: SyncStrategy.MERGE_CLOUD,
};
const STRATEGY_TO_MODE: Record<SyncStrategy, string> = {
[SyncStrategy.LOCAL_OVERWRITE]: 'local_force',
[SyncStrategy.CLOUD_OVERWRITE]: 'remote_force',
[SyncStrategy.MERGE_LOCAL]: 'merge_local_primary',
[SyncStrategy.MERGE_CLOUD]: 'merge_remote_primary',
};
const handleResponse = async <T>(response: Response): Promise<ApiResponse<T>> => {
try {
const data = await response.json();
if (!response.ok) {
return { data: data as T, status: 'error', message: (data as any)?.detail || response.statusText };
}
return { data, status: 'success' };
} catch (error: any) {
return { data: {} as T, status: 'error', message: error?.message || 'Unexpected error' };
}
};
const mapPlaylist = (item: any): Playlist => ({
id: item.id || `${item.title}-${item.trackCount}`,
title: item.title ?? item.name ?? 'Unknown',
trackCount: item.trackCount ?? item.track_count ?? 0,
lastUpdated: item.lastUpdated || item.last_updated || new Date().toISOString(),
});
const mapLibrary = (item: any): PlexLibrary => ({
id: item.id ?? item.title,
title: item.title ?? item.id,
type: item.type ?? 'artist',
});
const mapRegexRules = (rules: any[]): RegexReplacement[] =>
(rules || []).map((rule, index) => ({
id: rule.id || `${rule.pattern || 'rule'}-${index}`,
pattern: rule.pattern || '',
replacement: rule.replacement || '',
}));
export const apiService = {
async getSettings(): Promise<ApiResponse<{ strategy: SyncStrategy; regex: RegexReplacement[]; connection: PlexConnectionSettings; localPath: string }>> {
const response = await fetch(`${API_BASE}/api/settings`);
const result = await handleResponse<any>(response);
if (result.status === 'success') {
const mode = result.data.sync_mode as string;
const strategy = MODE_TO_STRATEGY[mode] || SyncStrategy.LOCAL_OVERWRITE;
const regex = mapRegexRules(result.data.path_rules || []);
const connection: PlexConnectionSettings = {
protocol: (result.data.scheme as 'http' | 'https') || 'https',
address: result.data.server_url || '',
port: result.data.port || '32400',
token: result.data.token || '',
libraryName: result.data.library_name || '',
};
return { status: 'success', data: { strategy, regex, connection, localPath: result.data.local_path || '' } };
}
return result as ApiResponse<any> as ApiResponse<{ strategy: SyncStrategy; regex: RegexReplacement[]; connection: PlexConnectionSettings; localPath: string }>;
},
async updateSyncStrategy(strategy: SyncStrategy): Promise<ApiResponse<{ sync_mode: string }>> {
const payload = { mode: STRATEGY_TO_MODE[strategy] };
const response = await fetch(`${API_BASE}/api/settings/sync-mode`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
return handleResponse(response);
},
async saveRegexRules(replacements: RegexReplacement[]): Promise<ApiResponse<{ rules: RegexReplacement[] }>> {
const payload = { rules: replacements.map(({ pattern, replacement }) => ({ pattern, replacement })) };
const response = await fetch(`${API_BASE}/api/settings/regex-rules`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
return handleResponse(response);
},
async updateLibrary(libraryName: string): Promise<ApiResponse<{ library_name: string }>> {
const response = await fetch(`${API_BASE}/api/settings/library`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ library_name: libraryName }),
});
return handleResponse(response);
},
async getPlaylists(serverType: ServerType, signal?: AbortSignal, localPath?: string): Promise<ApiResponse<Playlist[]>> {
const params = new URLSearchParams({ server: serverType.toLowerCase() });
if (serverType === ServerType.LOCAL && localPath) {
params.append('local_path', localPath);
}
const response = await fetch(`${API_BASE}/api/playlists?${params.toString()}`, { signal });
const result = await handleResponse<any>(response);
if (result.status === 'success' && (result.data as any)?.playlists) {
return { data: (result.data.playlists as any[]).map(mapPlaylist), status: 'success' };
}
return result as ApiResponse<Playlist[]>;
},
async getServerStatus(signal?: AbortSignal): Promise<ApiResponse<PlexServerConnection>> {
const response = await fetch(`${API_BASE}/api/server`, { signal });
const result = await handleResponse<any>(response);
if (result.status === 'success') {
const info = result.data.serverInfo || {};
const libraries: PlexLibrary[] = (result.data.libraries || []).map(mapLibrary);
return {
status: 'success',
data: {
isConnected: !!info.isConnected,
name: info.name,
ip: info.ip,
port: info.port ? Number(info.port) : undefined,
libraryName: info.libraryName,
libraries,
},
};
}
return result as ApiResponse<PlexServerConnection>;
},
async connectToPlex(settings: PlexConnectionSettings, signal?: AbortSignal): Promise<ApiResponse<{ token: string; serverInfo: PlexServerConnection }>> {
const response = await fetch(`${API_BASE}/api/connect`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
protocol: settings.protocol,
address: settings.address,
port: settings.port,
token: settings.token,
username: settings.username,
password: settings.password,
library_name: settings.libraryName,
timeout: settings.timeout,
}),
signal,
});
const result = await handleResponse<any>(response);
if (result.status === 'success') {
const info = result.data.serverInfo;
info.libraries = (info.libraries || []).map(mapLibrary);
return { status: 'success', data: { token: result.data.token, serverInfo: info } };
}
return result as ApiResponse<{ token: string; serverInfo: PlexServerConnection }>;
},
};
+14
View File
@@ -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' },
];
+29
View File
@@ -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
}
}
+66
View File
@@ -0,0 +1,66 @@
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;
libraryName?: string;
timeout?: number; // in seconds
}
export interface ApiResponse<T> {
data: T;
status: 'success' | 'error';
message?: string;
}
+30
View File
@@ -0,0 +1,30 @@
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',
proxy: {
'/api': {
target: env.VITE_API_BASE_URL || 'http://localhost:8000',
changeOrigin: true,
rewrite: (path) => path,
},
},
},
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, '.'),
}
}
};
});
+24
View File
@@ -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?
+543
View File
@@ -0,0 +1,543 @@
import React, { useEffect, useState, useCallback, useRef } from 'react';
import { Playlist, ServerType, SyncStrategy, PlexServerConnection, RegexReplacement, SyncState } from './types';
import { apiService } from './services/api';
import {
STRIPE_BASE_SPEED,
STRIPE_DECEL_DURATION_MS,
STRIPE_TILE_SIZE,
STRIPE_BACKGROUND_SIZE,
SYNC_SUCCESS_TOTAL_MS,
SYNC_ERROR_RESET_MS,
TOAST_AUTO_DISMISS_MS,
TOAST_EXIT_DURATION_MS
} from './Config';
import { SYNC_BANNER_PADDING_X, SYNC_BANNER_PADDING_Y, SYNC_BANNER_MIN_WIDTH } from './Config';
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;
}
// Custom hook to handle the stripe animation logic
const useStripeAnimation = (syncState: SyncState) => {
const leftYellowRef = useRef<HTMLDivElement>(null);
const leftGreenRef = useRef<HTMLDivElement>(null);
const rightYellowRef = useRef<HTMLDivElement>(null);
const rightGreenRef = useRef<HTMLDivElement>(null);
const requestRef = useRef<number>();
const lastTimeRef = useRef<number>(0);
const offsetRef = useRef<number>(0);
// State tracking for deceleration
const isDeceleratingRef = useRef(false);
const decelStartTimeRef = useRef(0);
const animate = (time: number) => {
if (lastTimeRef.current === 0) lastTimeRef.current = time;
const dt = (time - lastTimeRef.current) / 1000;
lastTimeRef.current = time;
let speed = STRIPE_BASE_SPEED; // pixels per second
if (isDeceleratingRef.current) {
const t = time - decelStartTimeRef.current;
const duration = STRIPE_DECEL_DURATION_MS; // deceleration duration
if (t >= duration) {
speed = 0;
} else {
// Linear slow down
speed = speed * (1 - (t / duration));
}
}
// Update offset
offsetRef.current += speed * dt;
const modOffset = offsetRef.current % STRIPE_TILE_SIZE;
// Apply to DOM elements directly for performance
const leftPos = `right ${modOffset}px top 0`;
const rightPos = `left ${modOffset}px top 0`;
if (leftYellowRef.current) leftYellowRef.current.style.backgroundPosition = leftPos;
if (leftGreenRef.current) leftGreenRef.current.style.backgroundPosition = leftPos;
if (rightYellowRef.current) rightYellowRef.current.style.backgroundPosition = rightPos;
if (rightGreenRef.current) rightGreenRef.current.style.backgroundPosition = rightPos;
// Continue loop if moving or if we are in the middle of decelerating
if (speed > 0 || (isDeceleratingRef.current && (time - decelStartTimeRef.current) < STRIPE_DECEL_DURATION_MS)) {
requestRef.current = requestAnimationFrame(animate);
}
};
useEffect(() => {
if (syncState === SyncState.SYNCING) {
isDeceleratingRef.current = false;
lastTimeRef.current = 0;
// Start animation loop
if (!requestRef.current) {
requestRef.current = requestAnimationFrame(animate);
}
} else if (syncState === SyncState.SUCCESS) {
isDeceleratingRef.current = true;
decelStartTimeRef.current = performance.now();
// Ensure loop is running to handle deceleration phase
if (!requestRef.current) {
requestRef.current = requestAnimationFrame(animate);
}
} else {
// IDLE or ERROR: Stop animation
if (requestRef.current) {
cancelAnimationFrame(requestRef.current);
requestRef.current = undefined;
}
offsetRef.current = 0;
}
return () => {
if (requestRef.current) {
cancelAnimationFrame(requestRef.current);
requestRef.current = undefined;
}
};
}, [syncState]);
return { leftYellowRef, leftGreenRef, rightYellowRef, rightGreenRef };
};
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);
// Sync State
const [syncState, setSyncState] = useState<SyncState>(SyncState.IDLE);
// Animation Refs
const { leftYellowRef, leftGreenRef, rightYellowRef, rightGreenRef } = useStripeAnimation(syncState);
// Abort Controllers for Refresh Actions
const localAbortRef = useRef<AbortController | null>(null);
const cloudAbortRef = useRef<AbortController | null>(null);
// Connection Modal State
const [isConnectionModalOpen, setIsConnectionModalOpen] = useState(false);
// Strategy State
const [currentStrategy, setCurrentStrategy] = useState<SyncStrategy>(SyncStrategy.LOCAL_OVERWRITE);
// Regex State
const [regexReplacements, setRegexReplacements] = useState<RegexReplacement[]>([]);
// Toast Notification System
const [toasts, setToasts] = useState<Toast[]>([]);
const timeoutsRef = useRef<{[key: number]: ReturnType<typeof setTimeout>}>({});
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));
}, TOAST_AUTO_DISMISS_MS);
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}`];
}, TOAST_EXIT_DURATION_MS);
}
});
}, [toasts]);
// Fetch Local Playlists
const refreshLocal = useCallback(async () => {
if (localAbortRef.current) localAbortRef.current.abort();
const abortController = new AbortController();
localAbortRef.current = abortController;
setLoadingLocal(true);
const result = await apiService.getPlaylists(ServerType.LOCAL, abortController.signal);
if (result.status === 'success') {
setLocalPlaylists(result.data);
}
setLoadingLocal(false);
localAbortRef.current = null;
}, []);
const cancelLocalRefresh = () => {
if (localAbortRef.current) {
localAbortRef.current.abort();
localAbortRef.current = null;
setLoadingLocal(false);
addToast("Local refresh cancelled.");
}
};
// Fetch Cloud Playlists and Info
const refreshCloud = useCallback(async () => {
if (cloudAbortRef.current) cloudAbortRef.current.abort();
const abortController = new AbortController();
cloudAbortRef.current = abortController;
setLoadingCloud(true);
// Fetch playlists
const playlistResult = await apiService.getPlaylists(ServerType.CLOUD, abortController.signal);
if (!abortController.signal.aborted) {
if (playlistResult.status === 'success') {
setCloudPlaylists(playlistResult.data);
}
// Fetch server info
const infoResult = await apiService.getServerStatus(abortController.signal);
if (infoResult.status === 'success') {
setCloudServerInfo(infoResult.data);
}
setLoadingCloud(false);
cloudAbortRef.current = null;
}
}, []);
const cancelCloudRefresh = () => {
if (cloudAbortRef.current) {
cloudAbortRef.current.abort();
cloudAbortRef.current = null;
setLoadingCloud(false);
addToast("Cloud refresh cancelled.");
}
};
// Initial Load
useEffect(() => {
refreshLocal();
refreshCloud();
return () => {
// Cleanup on unmount
if (localAbortRef.current) localAbortRef.current.abort();
if (cloudAbortRef.current) cloudAbortRef.current.abort();
}
}, [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.');
};
// Handle Sync Trigger
const handleSyncTrigger = async () => {
if (syncState !== SyncState.IDLE) return;
setSyncState(SyncState.SYNCING);
// Note: We deliberately do not clear playlists here to keep UI populated during sync
const result = await apiService.syncPlaylists(currentStrategy, regexReplacements);
if (result.status === 'success') {
// Transition to Success state
setSyncState(SyncState.SUCCESS);
// Timing Breakdown:
// T+0.0s: State is SUCCESS.
// - JS Animation loop detects change and begins decelerating speed from 56 -> 0 over 0.5s.
// - CSS opacity transitions Yellow -> Green over 0.3s.
// T+0.5s: Deceleration complete. Speed is 0. Background is static.
// We hold this static state for another 0.5s.
// T+1.0s: Total success duration complete. Disappear.
setTimeout(() => {
setSyncState(SyncState.IDLE);
refreshLocal();
refreshCloud();
}, SYNC_SUCCESS_TOTAL_MS);
} else {
setSyncState(SyncState.ERROR);
addToast("Sync failed. Please check connection.");
setTimeout(() => setSyncState(SyncState.IDLE), SYNC_ERROR_RESET_MS);
}
};
const handleConnectSuccess = (serverInfo: PlexServerConnection) => {
setCloudServerInfo(serverInfo);
// Refresh playlists after new connection
refreshCloud();
};
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 (
<div className="min-h-screen flex flex-col bg-gray-900 text-gray-100 font-sans overflow-hidden bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-gray-800 via-gray-900 to-black">
{/* App Header */}
<header className={`flex-none shadow-md z-20 relative backdrop-blur-md transition-all duration-500 ease-in-out h-16 ${syncState === SyncState.IDLE ? 'bg-gray-800/80 border-b border-white/5' : 'bg-black border-none'}`}>
{/* Syncing/Success Animated Background Layer */}
{syncState !== SyncState.IDLE && (
<div className="absolute inset-0 w-full h-full overflow-hidden bg-black">
{/* Left Side: Gradient 135deg (TR -> BL /), Anchored RIGHT (Center). Moves LEFT (Right offset increases). */}
<div className="absolute left-0 top-0 bottom-0 w-1/2 overflow-hidden z-0">
{/* Layer 1: Yellow (Syncing) */}
<div
ref={leftYellowRef}
className={`absolute inset-0 transition-opacity duration-300 ease-out ${syncState === SyncState.SUCCESS ? 'opacity-0' : 'opacity-100'}`}
style={{
backgroundPosition: 'right 0 top 0',
backgroundImage: `repeating-linear-gradient(135deg, transparent, transparent 20px, #e5a00d 20px, #e5a00d 40px)`,
backgroundSize: STRIPE_BACKGROUND_SIZE,
}}
/>
{/* Layer 2: Green (Success) - Fade In */}
<div
ref={leftGreenRef}
className={`absolute inset-0 transition-opacity duration-300 ease-out ${syncState === SyncState.SUCCESS ? 'opacity-100' : 'opacity-0'}`}
style={{
backgroundPosition: 'right 0 top 0',
backgroundImage: `repeating-linear-gradient(135deg, transparent, transparent 20px, #22c55e 20px, #22c55e 40px)`,
backgroundSize: STRIPE_BACKGROUND_SIZE,
}}
/>
</div>
{/* Right Side: Gradient 225deg (TL -> BR \), Anchored LEFT (Center). Moves RIGHT (Left offset increases). */}
<div className="absolute right-0 top-0 bottom-0 w-1/2 overflow-hidden z-0">
{/* Layer 1: Yellow (Syncing) */}
<div
ref={rightYellowRef}
className={`absolute inset-0 transition-opacity duration-300 ease-out ${syncState === SyncState.SUCCESS ? 'opacity-0' : 'opacity-100'}`}
style={{
backgroundPosition: 'left 0 top 0',
backgroundImage: `repeating-linear-gradient(225deg, transparent, transparent 20px, #e5a00d 20px, #e5a00d 40px)`,
backgroundSize: STRIPE_BACKGROUND_SIZE,
}}
/>
{/* Layer 2: Green (Success) - Fade In */}
<div
ref={rightGreenRef}
className={`absolute inset-0 transition-opacity duration-300 ease-out ${syncState === SyncState.SUCCESS ? 'opacity-100' : 'opacity-0'}`}
style={{
backgroundPosition: 'left 0 top 0',
backgroundImage: `repeating-linear-gradient(225deg, transparent, transparent 20px, #22c55e 20px, #22c55e 40px)`,
backgroundSize: STRIPE_BACKGROUND_SIZE,
}}
/>
</div>
</div>
)}
{/* Content Container */}
<div className="relative max-w-7xl mx-auto px-4 md:px-6 h-full flex items-center justify-between">
{syncState === SyncState.IDLE ? (
<>
{/* Normal Toolbar */}
<div className="flex items-center space-x-3">
<div className="p-1.5 rounded-lg shadow-lg bg-gradient-to-br from-plex-orange to-yellow-600 text-gray-900 shadow-plex-orange/20">
<ArrowLeftRight size={24} strokeWidth={2.5} />
</div>
<h1 className="text-xl font-bold tracking-tight text-white">
Plex<span className="text-plex-orange">Sync</span>
</h1>
</div>
{/* Connection Status Button */}
<button
onClick={() => setIsConnectionModalOpen(true)}
className={`flex items-center justify-center w-9 h-9 rounded-full border transition-all duration-300 hover:scale-105 active:scale-95 shadow-md
${isConnected
? "bg-green-500/10 border-green-500/50 text-green-400 hover:bg-green-500/20 hover:shadow-green-500/20"
: "bg-red-500/10 border-red-500/50 text-red-400 hover:bg-red-500/20 hover:shadow-red-500/20"
}`}
title={isConnected ? "Connected to Plex" : "Disconnected"}
>
{isConnected ? <Server size={18} /> : <ServerOff size={18} />}
</button>
</>
) : (
/* Syncing / Success Text Banner */
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
<div
className="bg-black shadow-none rounded-none border-none"
style={{
padding: `${SYNC_BANNER_PADDING_Y}px ${SYNC_BANNER_PADDING_X}px`,
minWidth: `${SYNC_BANNER_MIN_WIDTH}px`,
}}
>
<h1 className={`text-xl md:text-2xl font-black tracking-[0.2em] uppercase whitespace-nowrap transition-colors duration-300 ${syncState === SyncState.SUCCESS ? 'text-[#22c55e]' : 'text-[#F59E0B]'}`}>
{syncState === SyncState.SYNCING ? 'SYNCHRONIZING' : 'SYNC COMPLETE'}
</h1>
</div>
</div>
)}
</div>
</header>
{/* Notification Toasts Container */}
<div className="fixed top-20 left-0 right-0 flex justify-center h-0 overflow-visible z-[100] pointer-events-none">
{toasts.map((toast) => (
<div
key={toast.id}
className={getToastClasses()}
style={getToastStyles(toast)}
>
<ShieldCheck size={16} />
<span>{toast.message}</span>
<button
onClick={() => setToasts(prev => prev.map(t => t.id === toast.id ? { ...t, exiting: true } : t))}
className="ml-2 hover:text-white transition-colors"
>
<X size={14} />
</button>
</div>
))}
</div>
{/* Main Content Area */}
<main className="flex-1 overflow-hidden relative z-10">
{/* Reduced gap from gap-3/gap-6 to gap-2/gap-3 for tighter layout */}
<div className="absolute inset-0 flex flex-col md:flex-row max-w-7xl mx-auto p-4 md:p-6 gap-2 md:gap-3">
{/* Left Column - Local */}
<div className="flex-1 min-h-0 h-full w-full">
<ServerPanel
type={ServerType.LOCAL}
playlists={localPlaylists}
isLoading={loadingLocal}
onRefresh={refreshLocal}
onCancel={cancelLocalRefresh}
/>
</div>
{/* Strategy Selector - Positioned specifically between headers */}
<div className="absolute
z-30
/* Mobile Positioning: Center Vertically, Anchored Right */
top-1/2 right-[52px] transform translate-x-1/2 -translate-y-1/2
/* Desktop Positioning: Center Horizontally, Anchored Top */
md:top-[64px] md:right-auto md:left-1/2 md:transform md:-translate-x-1/2 md:-translate-y-1/2"
>
<StrategySelector
currentStrategy={currentStrategy}
onSelect={handleStrategyChange}
savedRegexReplacements={regexReplacements}
onSaveRegex={handleSaveRegex}
syncState={syncState}
onSync={handleSyncTrigger}
/>
</div>
{/* Right Column - Cloud */}
<div className="flex-1 min-h-0 h-full w-full">
<ServerPanel
type={ServerType.CLOUD}
playlists={cloudPlaylists}
isLoading={loadingCloud}
onRefresh={refreshCloud}
onCancel={cancelCloudRefresh}
serverInfo={cloudServerInfo}
/>
</div>
</div>
</main>
{/* Footer */}
<footer className="flex-none py-4 text-center text-xs text-gray-600 border-t border-white/5 bg-gray-900/50 backdrop-blur">
<p>&copy; {new Date().getFullYear()} PlexSync Manager. Connected to Docker backend.</p>
</footer>
{/* Modals */}
<ConnectionModal
isOpen={isConnectionModalOpen}
onClose={() => setIsConnectionModalOpen(false)}
onConnectSuccess={handleConnectSuccess}
onShowMessage={addToast}
/>
</div>
);
};
export default App;
+23
View File
@@ -0,0 +1,23 @@
// Animation and timing configuration centralization.
// Adjust these values for debugging or tuning animation behavior.
export const STRIPE_TILE_SIZE = 56.57; // px size for repeating background pattern
export const STRIPE_BASE_SPEED = 56.57; // px per second initial scroll speed
export const STRIPE_DECEL_DURATION_MS = 500; // ms duration of deceleration phase
export const SYNC_SUCCESS_TOTAL_MS = 1000; // ms until header returns to idle after success
export const SYNC_ERROR_RESET_MS = 2000; // ms until reset after error state
export const TOAST_AUTO_DISMISS_MS = 3000; // ms before toast begins exit
export const TOAST_EXIT_DURATION_MS = 300; // ms exit animation duration
// If needed later for entrance timing tweaks
export const TOAST_ENTER_FRAME_DELAY_MS = 0; // logical placeholder (double rAF currently)
// Helper: derive CSS backgroundSize string
export const STRIPE_BACKGROUND_SIZE = `${STRIPE_TILE_SIZE}px ${STRIPE_TILE_SIZE}px`;
// Sync banner sizing (background behind SYNCHRONIZING / SYNC COMPLETE text)
// Adjust these to change the black rectangle size.
export const SYNC_BANNER_PADDING_X = 32; // horizontal padding in px
export const SYNC_BANNER_PADDING_Y = 6; // vertical padding in px
export const SYNC_BANNER_MIN_WIDTH = 260; // optional minimum width (px)
+20
View File
@@ -0,0 +1,20 @@
<div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
</div>
# 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`
@@ -0,0 +1,386 @@
import React, { useState, useEffect, useRef } 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, ChevronRight, Settings, Loader2 } from 'lucide-react';
interface ConnectionModalProps {
isOpen: boolean;
onClose: () => void;
onConnectSuccess: (serverInfo: PlexServerConnection) => void;
onShowMessage: (message: string) => void;
}
const ConnectionModal: React.FC<ConnectionModalProps> = ({ isOpen, onClose, onConnectSuccess, onShowMessage }) => {
const [formData, setFormData] = useState<PlexConnectionSettings>({
protocol: 'http',
address: '',
port: '32400',
token: '',
username: '',
password: '',
timeout: 9
});
const [isConnecting, setIsConnecting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showPassword, setShowPassword] = useState(false);
const [showAdvanced, setShowAdvanced] = useState(false);
// Post-connection state
const [connectedServerInfo, setConnectedServerInfo] = useState<PlexServerConnection | null>(null);
const [libraries, setLibraries] = useState<PlexLibrary[]>([]);
const [selectedLibraryId, setSelectedLibraryId] = useState<string>('');
const abortControllerRef = useRef<AbortController | null>(null);
// Reset state when opening
useEffect(() => {
if (isOpen) {
setError(null);
setConnectedServerInfo(null);
setLibraries([]);
setSelectedLibraryId('');
}
return () => {
// Cleanup any pending request if modal closes
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, [isOpen]);
if (!isOpen) return null;
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
};
const handleTimeoutChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = parseInt(e.target.value) || 0;
setFormData(prev => ({ ...prev, timeout: val }));
};
const handleLibraryChange = (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 };
setConnectedServerInfo(updatedInfo);
onConnectSuccess(updatedInfo);
onShowMessage(`Library switched to ${lib.title}`);
}
};
const isTokenProvided = formData.token.trim().length > 0;
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();
// If already connecting, this acts as Cancel
if (isConnecting) {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
setIsConnecting(false);
setError("Connection cancelled by user.");
}
return;
}
setError(null);
setIsConnecting(true);
const abortController = new AbortController();
abortControllerRef.current = abortController;
const result = await apiService.connectToPlex(formData, abortController.signal);
// Only proceed if we weren't aborted/cancelled (though apiService handles error msg)
if (abortController.signal.aborted) return;
setIsConnecting(false);
abortControllerRef.current = null;
if (result.status === 'success' && result.data) {
setFormData(prev => ({
...prev,
token: result.data.token,
username: '',
password: ''
}));
const info = result.data.serverInfo;
setConnectedServerInfo(info);
onShowMessage(`Successfully connected to ${info.name || 'Plex Server'}`);
const libs = info.libraries || [];
setLibraries(libs);
if (libs.length > 0) {
const defaultLib = libs[0];
setSelectedLibraryId(defaultLib.id);
onConnectSuccess({
...info,
libraryName: defaultLib.title
});
} else {
onConnectSuccess(info);
}
} else {
setError(result.message || "Connection failed");
}
};
const isConnected = !!connectedServerInfo;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
<div className="bg-gray-900 border border-gray-700 rounded-xl shadow-2xl w-full max-w-md overflow-hidden animate-in fade-in zoom-in duration-200 flex flex-col max-h-[90vh]">
{/* Header */}
<div className="px-6 py-4 bg-gray-800 border-b border-gray-700 flex items-center justify-between flex-none">
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
<Server size={18} className={isConnected ? "text-green-400" : "text-plex-orange"} />
{isConnected ? 'Server Connected' : 'Connect Plex Server'}
</h3>
<button onClick={onClose} className="text-gray-400 hover:text-white transition-colors">
<X size={20} />
</button>
</div>
{/* Body */}
<div className="p-6 overflow-y-auto custom-scrollbar">
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="p-3 bg-red-500/10 border border-red-500/20 text-red-400 text-xs rounded-md">
{error}
</div>
)}
{/* Server Connection */}
<div className="space-y-3">
<label className="text-xs font-semibold text-gray-400 uppercase tracking-wider">Server Details</label>
<div className="grid grid-cols-4 gap-3">
<div className="col-span-1">
<select
name="protocol"
value={formData.protocol}
onChange={handleChange}
disabled={isConnected || isConnecting}
className={`w-full h-10 px-2 bg-gray-800 border border-gray-600 rounded-md text-sm text-white focus:border-plex-orange focus:outline-none ${isConnected ? 'opacity-60 cursor-not-allowed' : ''}`}
>
<option value="http">HTTP</option>
<option value="https">HTTPS</option>
</select>
</div>
<div className="col-span-3">
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Globe size={14} className="text-gray-500" />
</div>
<input
type="text"
name="address"
required
disabled={isConnected || isConnecting}
placeholder="IP Address or Domain"
value={formData.address}
onChange={handleChange}
className={`w-full h-10 pl-9 pr-3 bg-gray-800 border border-gray-600 rounded-md text-sm text-white placeholder-gray-500 focus:border-plex-orange focus:outline-none focus:ring-1 focus:ring-plex-orange transition-all ${isConnected ? 'opacity-60 cursor-not-allowed' : ''}`}
/>
</div>
</div>
</div>
<div>
<input
type="text"
name="port"
disabled={isConnected || isConnecting}
placeholder="Port (e.g. 32400)"
value={formData.port}
onChange={handleChange}
className={`w-full h-10 px-3 bg-gray-800 border border-gray-600 rounded-md text-sm text-white placeholder-gray-500 focus:border-plex-orange focus:outline-none focus:ring-1 focus:ring-plex-orange transition-all ${isConnected ? 'opacity-60 cursor-not-allowed' : ''}`}
/>
</div>
</div>
<div className="h-px bg-gray-800 my-4" />
{/* Authentication */}
<div className="space-y-3">
<label className="text-xs font-semibold text-gray-400 uppercase tracking-wider">Authentication</label>
{/* Token */}
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Key size={14} className="text-plex-orange" />
</div>
<input
type="text"
name="token"
disabled={isConnected || isConnecting}
placeholder="X-Plex-Token (Optional)"
value={formData.token}
onChange={handleChange}
className={`w-full h-10 pl-9 pr-3 bg-gray-800 border border-gray-600 rounded-md text-sm text-white placeholder-gray-500 focus:border-plex-orange focus:outline-none focus:ring-1 focus:ring-plex-orange transition-all font-mono ${isConnected ? 'opacity-60 cursor-not-allowed' : ''}`}
/>
</div>
{!isConnected && (
<>
<div className="text-center text-[10px] text-gray-500 uppercase tracking-widest font-semibold py-1">
OR
</div>
{/* Username */}
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<User size={14} className={isTokenProvided ? "text-gray-600" : "text-gray-400"} />
</div>
<input
type="text"
name="username"
disabled={isTokenProvided || isConnecting}
placeholder="Username / Email"
value={formData.username}
onChange={handleChange}
className={`w-full h-10 pl-9 pr-3 rounded-md text-sm transition-all focus:outline-none ${disabledInputClass}`}
/>
</div>
{/* Password */}
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Lock size={14} className={isTokenProvided ? "text-gray-600" : "text-gray-400"} />
</div>
<input
type={showPassword ? "text" : "password"}
name="password"
disabled={isTokenProvided || isConnecting}
placeholder="Password"
value={formData.password}
onChange={handleChange}
className={`w-full h-10 pl-9 pr-10 rounded-md text-sm transition-all focus:outline-none ${disabledInputClass}`}
/>
<button
type="button"
disabled={isTokenProvided || isConnecting}
onClick={() => setShowPassword(!showPassword)}
className={`absolute inset-y-0 right-0 pr-3 flex items-center ${isTokenProvided ? 'cursor-not-allowed opacity-50' : 'cursor-pointer text-gray-400 hover:text-white'}`}
>
{showPassword ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div>
</>
)}
</div>
{/* Advanced Options */}
{!isConnected && (
<div className="border border-gray-800 rounded-lg overflow-hidden">
<button
type="button"
onClick={() => setShowAdvanced(!showAdvanced)}
className="w-full flex items-center justify-between px-3 py-2 bg-gray-800/50 hover:bg-gray-800 text-xs font-medium text-gray-400 hover:text-gray-200 transition-colors"
>
<div className="flex items-center gap-2">
<Settings size={14} />
<span>Advanced Options</span>
</div>
{showAdvanced ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</button>
{showAdvanced && (
<div className="p-3 bg-gray-900/50 space-y-3 animate-in slide-in-from-top-2">
<div>
<label className="text-xs text-gray-500 mb-1 block">Connection Timeout (Seconds)</label>
<input
type="number"
min="1"
max="60"
name="timeout"
value={formData.timeout || 9}
onChange={handleTimeoutChange}
disabled={isConnecting}
className="w-full h-8 px-2 bg-gray-800 border border-gray-700 rounded-md text-xs text-white focus:border-plex-orange focus:outline-none"
/>
</div>
</div>
)}
</div>
)}
{!isConnected ? (
<button
type="submit"
className={`w-full mt-4 py-2.5 rounded-lg text-sm font-bold text-gray-900 transition-all shadow-lg flex items-center justify-center gap-2
${isConnecting
? 'bg-red-500/80 hover:bg-red-500 text-white animate-pulse'
: 'bg-plex-orange hover:bg-yellow-500 active:scale-[0.98] shadow-plex-orange/20'
}`}
>
{isConnecting ? (
<>
<Loader2 size={16} className="animate-spin" />
<span>Connecting... <span className="opacity-75 font-normal ml-1">(Cancel)</span></span>
</>
) : 'Connect Server'}
</button>
) : (
<div className="mt-2 p-2 bg-green-500/10 border border-green-500/20 rounded-lg text-center">
<p className="text-green-400 text-sm font-semibold flex items-center justify-center gap-2">
<CheckCircle size={16} />
Connected Successfully
</p>
</div>
)}
</form>
{/* Library Selection - Appears after connection */}
{isConnected && libraries.length > 0 && (
<div className="mt-6 pt-5 border-t border-gray-700 animate-in slide-in-from-top-2 fade-in">
<label className="text-xs font-semibold text-gray-400 uppercase tracking-wider block mb-2">Select Library to Sync</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Library size={14} className="text-plex-orange" />
</div>
<select
value={selectedLibraryId}
onChange={handleLibraryChange}
className="w-full h-10 pl-9 pr-3 bg-gray-800 border border-gray-600 rounded-md text-sm text-white focus:border-plex-orange focus:outline-none focus:ring-1 focus:ring-plex-orange appearance-none cursor-pointer hover:bg-gray-700/50 transition-colors"
>
{libraries.map(lib => (
<option key={lib.id} value={lib.id}>{lib.title}</option>
))}
</select>
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
<ChevronDown size={14} className="text-gray-500" />
</div>
</div>
<div className="mt-6 flex justify-end">
<button
onClick={onClose}
className="px-6 py-2 bg-gray-700 hover:bg-gray-600 text-white text-sm font-medium rounded-lg transition-colors border border-gray-600 hover:border-gray-500"
>
Done
</button>
</div>
</div>
)}
</div>
</div>
</div>
);
};
export default ConnectionModal;
@@ -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<PlaylistCardProps> = ({ playlist }) => {
return (
<div className="group flex flex-col w-full p-2.5 bg-gray-800/60 rounded-md border border-gray-700/50 hover:bg-gray-700 hover:border-plex-orange/50 transition-all duration-200 cursor-pointer shadow-sm">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium text-gray-200 truncate flex-1 mr-2 group-hover:text-white transition-colors">
{playlist.title}
</h4>
</div>
<div className="flex items-center mt-1.5 space-x-4 text-xs text-gray-500 group-hover:text-gray-400">
<span className="flex items-center" title="Track Count">
<Disc3 size={12} className="mr-1.5 opacity-70" />
{playlist.trackCount}
</span>
<span className="flex items-center" title="Last Updated">
<Clock size={12} className="mr-1.5 opacity-70" />
{new Date(playlist.lastUpdated).toLocaleDateString()}
</span>
</div>
</div>
);
};
export default PlaylistCard;
+162
View File
@@ -0,0 +1,162 @@
import React from 'react';
import { Playlist, ServerType, PlexServerConnection } from '../types';
import PlaylistCard from './PlaylistCard';
import { RefreshCw, Server, Cloud, WifiOff, X } from 'lucide-react';
interface ServerPanelProps {
type: ServerType;
playlists: Playlist[];
isLoading: boolean;
onRefresh: () => void;
onCancel?: () => void;
serverInfo?: PlexServerConnection;
}
const ServerPanel: React.FC<ServerPanelProps> = ({ type, playlists, isLoading, onRefresh, onCancel, 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 = (
<p className="text-xs text-gray-400 font-medium mt-0.5 md:mt-0 md:ml-0">
{playlists.length} Playlists
</p>
);
} else {
// Cloud Logic
if (serverInfo) {
if (serverInfo.isConnected) {
displayTitle = serverInfo.name || 'Cloud Server';
displaySubtitle = (
<div className="flex items-center text-xs text-gray-300 font-medium space-x-1.5 truncate mt-0.5 md:mt-0">
<span className="text-plex-orange truncate font-semibold">{serverInfo.libraryName}</span>
<span className="text-gray-600 hidden md:inline"></span>
<span className="text-gray-500 font-mono text-[10px] hidden md:inline">{serverInfo.ip}:{serverInfo.port}</span>
</div>
);
} else {
displayTitle = 'Not Connected';
Icon = WifiOff;
headerColor = 'text-red-400';
displaySubtitle = (
<p className="text-xs text-gray-500 font-medium mt-0.5">
Connection failed
</p>
);
}
} else {
displayTitle = 'Cloud Server';
displaySubtitle = (
<p className="text-xs text-gray-500 font-medium mt-0.5">
{isLoading ? 'Connecting...' : 'Waiting...'}
</p>
);
}
}
// Handle Refresh/Cancel Click
const handleAction = () => {
if (isLoading && onCancel) {
onCancel();
} else {
onRefresh();
}
};
return (
<div className={`flex flex-row md:flex-col h-full ${bgGradient} rounded-2xl border ${borderColor} backdrop-blur-xl shadow-xl overflow-hidden transition-all duration-300`}>
{/* Header */}
<div
className={`
relative flex-none
order-last md:order-first
w-[72px] md:w-full
h-full md:h-auto md:min-h-[80px]
flex flex-col md:flex-row items-center justify-between
py-6 md:py-0 md:px-8
bg-gray-800/60 border-l md:border-l-0 md:border-b border-white/5
`}
>
{/* Title Group */}
<div className="flex flex-col md:flex-row items-center md:space-x-4 overflow-hidden w-full md:w-auto h-full md:h-full md:py-4">
{/* Icon Box */}
<div className={`p-2.5 rounded-xl bg-gray-900/50 border border-white/5 ${headerColor} shadow-inner flex-shrink-0 mb-4 md:mb-0`}>
<Icon size={22} strokeWidth={2} />
</div>
{/* Text Container */}
<div className="flex-1 min-w-0 flex flex-col justify-center items-center md:items-start h-full md:h-auto w-full md:w-auto">
<div className="flex flex-col justify-center w-full md:w-auto [writing-mode:vertical-rl] rotate-180 md:[writing-mode:horizontal-tb] md:rotate-0 items-center md:items-start gap-1 md:gap-0">
<h2 className="text-sm md:text-lg font-bold text-gray-100 tracking-wide whitespace-nowrap" title={displayTitle}>
{displayTitle}
</h2>
<div className="transform md:translate-y-0">
{displaySubtitle}
</div>
</div>
</div>
</div>
{/* Refresh/Stop Button */}
<button
onClick={handleAction}
className={`flex-shrink-0 p-2.5 rounded-full transition-all active:scale-90 mt-4 md:mt-0 md:ml-4 border border-transparent group relative
${isLoading
? 'text-plex-orange bg-plex-orange/10 border-plex-orange/20 hover:bg-red-500/10 hover:border-red-500/30'
: 'text-gray-400 hover:text-white hover:bg-white/10'
}
`}
title={isLoading ? "Cancel Refresh" : "Refresh Playlists"}
>
{isLoading ? (
<div className="relative flex items-center justify-center">
{/* Outer Spinner */}
<RefreshCw size={20} strokeWidth={2} className="animate-spin opacity-40 group-hover:opacity-20 transition-opacity" />
{/* Inner Cancel X */}
<X size={12} strokeWidth={3} className="absolute text-plex-orange group-hover:text-red-400 transition-colors" />
</div>
) : (
<RefreshCw size={20} strokeWidth={2} />
)}
</button>
</div>
{/* Content List */}
<div className="flex-1 overflow-y-auto p-3 md:p-5 custom-scrollbar bg-black/20">
{isLoading && playlists.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-gray-500 space-y-3">
<RefreshCw size={24} className="animate-spin text-plex-orange/50" />
<p className="text-xs font-medium tracking-wide uppercase">Syncing...</p>
</div>
) : playlists.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-gray-500">
<p className="text-sm">No playlists found.</p>
</div>
) : (
<div className="space-y-2.5 md:space-y-3">
{playlists.map((playlist) => (
<PlaylistCard key={playlist.id} playlist={playlist} />
))}
</div>
)}
</div>
</div>
);
};
export default ServerPanel;
@@ -0,0 +1,354 @@
import React, { useState, useRef, useEffect } from 'react';
import { SyncStrategy, RegexReplacement, SyncState } from '../types';
import {
ArrowRightCircle,
ArrowLeftCircle,
GitMerge,
ChevronDown,
Check,
HelpCircle,
Plus,
Trash2,
Save,
RotateCcw,
Zap,
Loader2
} 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;
syncState: SyncState;
onSync: () => void;
}
const StrategySelector: React.FC<StrategySelectorProps> = ({
currentStrategy,
onSelect,
savedRegexReplacements,
onSaveRegex,
syncState,
onSync
}) => {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
// Local state for regex editing
const [localReplacements, setLocalReplacements] = useState<RegexReplacement[]>([]);
const [isDirty, setIsDirty] = useState(false);
const isSyncing = syncState === SyncState.SYNCING;
const isLocked = isSyncing || syncState === SyncState.SUCCESS;
// 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) => {
if (isLocked) return;
onSelect(strategy.value, strategy.label);
};
// Regex Handlers
const handleAddRegex = () => {
if (isLocked) return;
const newId = Date.now().toString();
setLocalReplacements(prev => [...prev, { id: newId, pattern: '', replacement: '' }]);
};
const handleDeleteRegex = (id: string) => {
if (isLocked) return;
setLocalReplacements(prev => prev.filter(r => r.id !== id));
};
const handleUpdateRegex = (id: string, field: 'pattern' | 'replacement', value: string) => {
if (isLocked) return;
setLocalReplacements(prev => prev.map(r =>
r.id === id ? { ...r, [field]: value } : r
));
};
const handleReset = () => {
if (isLocked) return;
setLocalReplacements(JSON.parse(JSON.stringify(savedRegexReplacements)));
};
const handleSave = () => {
if (isLocked) return;
const validReplacements = localReplacements.filter(r => r.pattern.trim() !== '');
setLocalReplacements(validReplacements);
onSaveRegex(validReplacements);
};
const handleSyncClick = () => {
if (isLocked) return;
onSync();
};
// If syncing or locked, apply grayscale filter to content sections
const contentClass = isLocked ? "opacity-50 pointer-events-none grayscale transition-all" : "transition-all";
return (
<div className="relative group" ref={dropdownRef}>
{/* Trigger Button - Added Ring to create visual 'cutout' over panels */}
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center justify-center w-12 h-12 rounded-full bg-gray-800/90 border border-gray-600 hover:border-plex-orange text-gray-300 hover:text-white hover:bg-gray-700/80 transition-all shadow-2xl hover:shadow-plex-orange/30 ring-[6px] md:ring-8 ring-gray-900 backdrop-blur-sm active:scale-95"
title={`Current Strategy: ${selectedOption.label}`}
>
<selectedOption.icon size={22} className={selectedOption.color} strokeWidth={2.5} />
<div className="absolute -bottom-1 -right-1 bg-gray-900 rounded-full border border-gray-600 p-[2px] shadow-sm">
<ChevronDown size={10} className="text-gray-400" />
</div>
</button>
{/* Dropdown Menu - Persistent Mount for State Preservation */}
<div
className={`absolute
top-14
/* Mobile: Open to left */
right-0 origin-top-right
/* Desktop: Center alignment */
md:left-1/2 md:right-auto md:origin-top md:-translate-x-1/2
w-80 md:w-[30rem] bg-gray-800/95 border border-white/10 rounded-xl shadow-2xl z-50 overflow-hidden backdrop-blur-xl
transition-all duration-200 ease-out
${isOpen ? 'opacity-100 scale-100 visible translate-y-0' : 'opacity-0 scale-95 invisible pointer-events-none -translate-y-2'}`}
>
<div className={contentClass}>
{/* Section 1: Sync Strategy */}
<div className="px-4 py-3 bg-black/20 border-b border-white/5">
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest mb-2">Sync Strategy</h3>
<div className="space-y-1">
{STRATEGIES.map((strategy) => (
<div
key={strategy.value}
onClick={() => 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'
}`}
>
<div className="flex items-center space-x-3 overflow-hidden">
<strategy.icon size={18} className={strategy.color} />
<span className={`text-sm font-medium truncate ${currentStrategy === strategy.value ? 'text-white' : 'text-gray-300 group-hover:text-white'}`}>
{strategy.label}
</span>
</div>
<div className="flex items-center space-x-2">
<div className="relative group/tooltip">
<HelpCircle size={14} className="text-gray-600 hover:text-gray-400 transition-colors" />
<div className="absolute right-0 bottom-full mb-2 w-48 p-2.5 bg-gray-900 text-xs text-gray-300 rounded-lg shadow-xl border border-gray-700 pointer-events-none opacity-0 group-hover/tooltip:opacity-100 transition-opacity z-50">
{strategy.description}
</div>
</div>
{currentStrategy === strategy.value && (
<Check size={14} className="text-plex-orange" strokeWidth={3} />
)}
</div>
</div>
))}
</div>
</div>
{/* Section 2: Regex Preprocessing */}
<div className="p-4 bg-gray-900/40">
<div className="flex items-center justify-between mb-3">
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest">Regex Rules</h3>
{localReplacements.length === 0 && (
<button
onClick={handleAddRegex}
className="p-1 rounded bg-gray-700/50 hover:bg-gray-600 text-gray-400 hover:text-white transition-colors"
title="Add Rule"
>
<Plus size={14} />
</button>
)}
</div>
<div className="space-y-2 mb-4 max-h-52 overflow-y-auto pr-1 custom-scrollbar">
{localReplacements.length === 0 ? (
<div className="text-xs text-gray-600 italic text-center py-4 border border-dashed border-gray-700/50 rounded-lg">
No regex replacements configured.
</div>
) : (
localReplacements.map((regex) => (
<div key={regex.id} className="flex items-center space-x-2 animate-in slide-in-from-left-2 duration-200">
<div className="flex-1 min-w-0">
<input
type="text"
placeholder="Regex Pattern"
value={regex.pattern}
onChange={(e) => 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'}`}
/>
</div>
<div className="flex-none text-gray-600">
<ArrowRightCircle size={12} />
</div>
<div className="flex-1 min-w-0">
<input
type="text"
placeholder="Replacement"
value={regex.replacement}
onChange={(e) => 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"
/>
</div>
<button
onClick={() => handleDeleteRegex(regex.id)}
className="text-gray-600 hover:text-red-400 p-1.5 hover:bg-red-500/10 rounded transition-colors"
title="Delete Rule"
>
<Trash2 size={14} />
</button>
</div>
))
)}
</div>
{/* Actions */}
<div className="space-y-3 pt-3 border-t border-white/5">
{localReplacements.length > 0 && (
<div className="flex justify-center">
<button
onClick={handleAddRegex}
className="flex items-center space-x-1.5 text-xs text-plex-orange hover:text-yellow-400 transition-colors opacity-80 hover:opacity-100"
>
<Plus size={12} />
<span className="font-medium">Add Rule</span>
</button>
</div>
)}
<div className="grid grid-cols-2 gap-3">
<button
onClick={handleReset}
disabled={!isDirty}
className={`flex items-center justify-center space-x-2 py-1.5 rounded-lg text-xs font-medium border transition-all
${isDirty
? 'bg-gray-800 border-gray-600 text-gray-300 hover:bg-gray-700 hover:text-white'
: 'bg-transparent border-gray-800 text-gray-600 cursor-not-allowed'}`}
>
<RotateCcw size={14} />
<span>Revert</span>
</button>
<button
onClick={handleSave}
disabled={!isDirty}
className={`flex items-center justify-center space-x-2 py-1.5 rounded-lg text-xs font-bold border transition-all
${isDirty
? 'bg-plex-orange border-plex-orange text-gray-900 hover:bg-yellow-500 shadow-lg shadow-plex-orange/10'
: 'bg-gray-800/50 border-gray-800 text-gray-600 cursor-not-allowed'}`}
>
<Save size={14} />
<span>Save Changes</span>
</button>
</div>
</div>
</div>
</div>
{/* Section 3: Sync Now Button */}
<div className="p-4 bg-gray-950/50 border-t border-white/5">
<button
onClick={handleSyncClick}
disabled={isLocked || isDirty} // Disable if syncing OR if there are unsaved regex changes
className={`w-full py-3 rounded-lg text-sm font-bold flex items-center justify-center gap-2 transition-all shadow-lg
${isLocked
? 'bg-gray-700/30 text-gray-500 cursor-not-allowed border border-gray-700/50'
: isDirty
? 'bg-gray-800 text-gray-500 cursor-not-allowed border border-gray-700' // Must save rules first
: 'bg-green-600 hover:bg-green-500 text-white border border-green-500 shadow-green-900/20 active:scale-[0.98]'
}`}
>
{isSyncing ? (
<>
<Loader2 size={16} className="animate-spin" />
<span>Sync in Progress...</span>
</>
) : (
<>
<Zap size={16} fill="currentColor" />
<span>Sync Now</span>
</>
)}
</button>
{isDirty && (
<p className="text-[10px] text-plex-orange text-center mt-2">
Please save or revert regex rules changes before syncing.
</p>
)}
</div>
</div>
</div>
);
};
export default StrategySelector;
+75
View File
@@ -0,0 +1,75 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PlexSync Manager</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
plex: {
orange: '#e5a00d',
dark: '#1f2937',
darker: '#111827',
card: '#374151'
}
},
animation: {
'scroll-out-left': 'scroll-out-left 1s linear infinite',
'scroll-out-right': 'scroll-out-right 1s linear infinite',
}
}
}
}
</script>
<style>
/* Custom scrollbar for webkit to match dark theme */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: #111827;
}
::-webkit-scrollbar-thumb {
background: #4b5563;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #6b7280;
}
/*
Symmetrical Diagonal Scroll Animations
Pattern width: 40px (20px color + 20px transparent).
Diagonal length: 40 * sqrt(2) ≈ 56.57px.
Left Side: Anchored to Right (Center). Moves Left (increases right offset).
Right Side: Anchored to Left (Center). Moves Right (increases left offset).
*/
@keyframes scroll-out-left {
0% { background-position: right 0 top 0; }
100% { background-position: right 56.57px top 0; }
}
@keyframes scroll-out-right {
0% { background-position: left 0 top 0; }
100% { background-position: left 56.57px top 0; }
}
</style>
<script type="importmap">
{
"imports": {
"lucide-react": "https://aistudiocdn.com/lucide-react@^0.555.0",
"react/": "https://aistudiocdn.com/react@^19.2.0/",
"react": "https://aistudiocdn.com/react@^19.2.0",
"react-dom/": "https://aistudiocdn.com/react-dom@^19.2.0/"
}
}
</script>
</head>
<body class="bg-gray-900 text-gray-100 antialiased min-h-screen">
<div id="root"></div>
</body>
</html>
+15
View File
@@ -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(
<React.StrictMode>
<App />
</React.StrictMode>
);
+5
View File
@@ -0,0 +1,5 @@
{
"name": "PlexSync Manager",
"description": "A modern dashboard to synchronize and manage playlists between Local and Cloud Plex servers.",
"requestFramePermissions": []
}
+22
View File
@@ -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"
}
}
+195
View File
@@ -0,0 +1,195 @@
import { Playlist, ServerType, ApiResponse, PlexServerConnection, PlexConnectionSettings, PlexLibrary, SyncStrategy, RegexReplacement } 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, signal?: AbortSignal): Promise<Playlist[]> => {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
if (type === ServerType.LOCAL) {
resolve([...MOCK_LOCAL_PLAYLISTS]);
} else {
resolve([...MOCK_CLOUD_PLAYLISTS]);
}
}, SIMULATE_DELAY_MS);
if (signal) {
signal.addEventListener('abort', () => {
clearTimeout(timer);
reject(new DOMException('Aborted', 'AbortError'));
});
}
});
};
const fetchServerStatus = async (signal?: AbortSignal): Promise<PlexServerConnection> => {
return new Promise((resolve, reject) => {
const timer = 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);
if (signal) {
signal.addEventListener('abort', () => {
clearTimeout(timer);
reject(new DOMException('Aborted', 'AbortError'));
});
}
});
};
const authenticatePlex = async (settings: PlexConnectionSettings, signal?: AbortSignal): Promise<{ token: string, serverInfo: PlexServerConnection }> => {
return new Promise((resolve, reject) => {
// Determine effective timeout
const timeoutSeconds = settings.timeout || 9;
const timeoutMs = timeoutSeconds * 1000;
// Simulate latency (random between 1s and 2s, or longer to test timeout)
const latency = 1500;
const timer = setTimeout(() => {
// Check if we timed out (simulated)
if (latency > timeoutMs) {
reject(new Error(`Connection timed out after ${settings.timeout}s`));
return;
}
// 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
}
});
}, latency);
// Handle User Cancellation
if (signal) {
signal.addEventListener('abort', () => {
clearTimeout(timer);
reject(new DOMException('Aborted', 'AbortError'));
});
}
// Handle Actual Timeout Logic (if latency was indeterminate in real world)
// In this mock, the latency is fixed at 1500, but logic above simulates the check.
// However, if the user set timeout < 1.5s, we should reject sooner.
if (timeoutMs < latency) {
setTimeout(() => {
clearTimeout(timer);
reject(new Error(`Connection timed out after ${settings.timeout}s`));
}, timeoutMs);
}
});
}
const triggerSync = async (strategy: SyncStrategy, regexRules: RegexReplacement[]): Promise<void> => {
return new Promise((resolve) => {
// Simulate a sync process taking 3 seconds
setTimeout(() => {
resolve();
}, 3000);
});
};
export const apiService = {
getPlaylists: async (serverType: ServerType, signal?: AbortSignal): Promise<ApiResponse<Playlist[]>> => {
try {
const data = await fetchPlaylists(serverType, signal);
return { data, status: 'success' };
} catch (error: any) {
if (error.name === 'AbortError') {
// Return a specific status or handle gracefully
return { data: [], status: 'error', message: 'Request cancelled' };
}
console.error(`Error fetching ${serverType} playlists:`, error);
return { data: [], status: 'error', message: 'Failed to fetch playlists' };
}
},
getServerStatus: async (signal?: AbortSignal): Promise<ApiResponse<PlexServerConnection>> => {
try {
const data = await fetchServerStatus(signal);
return { data, status: 'success' };
} catch (error: any) {
if (error.name === 'AbortError') return { data: { isConnected: false }, status: 'error', message: 'Cancelled' };
return {
data: { isConnected: false },
status: 'error',
message: 'Failed to connect to server'
};
}
},
connectToPlex: async (settings: PlexConnectionSettings, signal?: AbortSignal): Promise<ApiResponse<{ token: string, serverInfo: PlexServerConnection }>> => {
try {
const data = await authenticatePlex(settings, signal);
return { data, status: 'success', message: 'Connected successfully' };
} catch (error: any) {
if (error.name === 'AbortError') {
return {
data: { token: '', serverInfo: { isConnected: false } },
status: 'error',
message: 'Connection cancelled'
};
}
return {
data: { token: '', serverInfo: { isConnected: false } },
status: 'error',
message: error.message || 'Connection failed'
};
}
},
syncPlaylists: async (strategy: SyncStrategy, regexRules: RegexReplacement[]): Promise<ApiResponse<null>> => {
try {
await triggerSync(strategy, regexRules);
return { data: null, status: 'success', message: 'Sync complete' };
} catch (error) {
return { data: null, status: 'error', message: 'Sync failed' };
}
}
};
+14
View File
@@ -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' },
];
+29
View File
@@ -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
}
}
+72
View File
@@ -0,0 +1,72 @@
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 enum SyncState {
IDLE = 'IDLE',
SYNCING = 'SYNCING',
SUCCESS = 'SUCCESS',
ERROR = 'ERROR'
}
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;
timeout?: number; // in seconds
}
export interface ApiResponse<T> {
data: T;
status: 'success' | 'error';
message?: string;
}
+23
View File
@@ -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, '.'),
}
}
};
});