feat (ui): eerste wersie webui
This commit is contained in:
+11
-1
@@ -1,15 +1,25 @@
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI, Request
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
from app.api.files import router as files_router
|
from app.api.files import router as files_router
|
||||||
from app.api.session import router as session_router
|
from app.api.session import router as session_router
|
||||||
from app.api.tvdb import router as tvdb_router
|
from app.api.tvdb import router as tvdb_router
|
||||||
|
|
||||||
app = FastAPI(title="Rename MVP")
|
app = FastAPI(title="Rename MVP")
|
||||||
|
templates = Jinja2Templates(directory="app/templates")
|
||||||
|
|
||||||
app.include_router(tvdb_router, prefix="/api/tvdb", tags=["tvdb"])
|
app.include_router(tvdb_router, prefix="/api/tvdb", tags=["tvdb"])
|
||||||
app.include_router(session_router, prefix="/api/session", tags=["session"])
|
app.include_router(session_router, prefix="/api/session", tags=["session"])
|
||||||
app.include_router(files_router, prefix="/api/files", tags=["files"])
|
app.include_router(files_router, prefix="/api/files", tags=["files"])
|
||||||
|
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
def health():
|
def health():
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/", response_class=HTMLResponse)
|
||||||
|
def index(request: Request):
|
||||||
|
return templates.TemplateResponse("index.html", {"request": request})
|
||||||
|
|||||||
@@ -0,0 +1,322 @@
|
|||||||
|
(function () {
|
||||||
|
const STORAGE_KEY = "rename_mvp_session_id";
|
||||||
|
const sessionId = initSessionId();
|
||||||
|
const state = {
|
||||||
|
sessionId,
|
||||||
|
selectedSeries: null,
|
||||||
|
episodes: [],
|
||||||
|
roots: [],
|
||||||
|
discoveredFiles: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const el = {
|
||||||
|
sessionMeta: document.getElementById("sessionMeta"),
|
||||||
|
outputBox: document.getElementById("outputBox"),
|
||||||
|
searchInput: document.getElementById("searchInput"),
|
||||||
|
searchBtn: document.getElementById("searchBtn"),
|
||||||
|
searchResults: document.getElementById("searchResults"),
|
||||||
|
seriesInfo: document.getElementById("seriesInfo"),
|
||||||
|
refreshEpisodesBtn: document.getElementById("refreshEpisodesBtn"),
|
||||||
|
episodesList: document.getElementById("episodesList"),
|
||||||
|
refreshSelectedEpisodesBtn: document.getElementById("refreshSelectedEpisodesBtn"),
|
||||||
|
clearSelectedEpisodesBtn: document.getElementById("clearSelectedEpisodesBtn"),
|
||||||
|
selectedEpisodesList: document.getElementById("selectedEpisodesList"),
|
||||||
|
rootsSelect: document.getElementById("rootsSelect"),
|
||||||
|
refreshRootsBtn: document.getElementById("refreshRootsBtn"),
|
||||||
|
subpathInput: document.getElementById("subpathInput"),
|
||||||
|
recursiveInput: document.getElementById("recursiveInput"),
|
||||||
|
discoverBtn: document.getElementById("discoverBtn"),
|
||||||
|
discoveredFilesList: document.getElementById("discoveredFilesList"),
|
||||||
|
refreshSelectedFilesBtn: document.getElementById("refreshSelectedFilesBtn"),
|
||||||
|
clearSelectedFilesBtn: document.getElementById("clearSelectedFilesBtn"),
|
||||||
|
selectedFilesList: document.getElementById("selectedFilesList"),
|
||||||
|
mappingPreviewBtn: document.getElementById("mappingPreviewBtn"),
|
||||||
|
filenamePreviewBtn: document.getElementById("filenamePreviewBtn"),
|
||||||
|
renameExecuteBtn: document.getElementById("renameExecuteBtn"),
|
||||||
|
};
|
||||||
|
|
||||||
|
function initSessionId() {
|
||||||
|
const existing = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (existing) return existing;
|
||||||
|
const created = "ui-" + Date.now() + "-" + Math.floor(Math.random() * 10000);
|
||||||
|
localStorage.setItem(STORAGE_KEY, created);
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
function q(path) {
|
||||||
|
return path.includes("?") ? `${path}&session_id=${encodeURIComponent(state.sessionId)}` : `${path}?session_id=${encodeURIComponent(state.sessionId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(path, options = {}) {
|
||||||
|
const resp = await fetch(path, options);
|
||||||
|
const text = await resp.text();
|
||||||
|
let data = {};
|
||||||
|
try {
|
||||||
|
data = text ? JSON.parse(text) : {};
|
||||||
|
} catch (_err) {
|
||||||
|
data = { raw: text };
|
||||||
|
}
|
||||||
|
if (!resp.ok) {
|
||||||
|
throw new Error(data.detail || data.raw || `HTTP ${resp.status}`);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function out(label, payload) {
|
||||||
|
el.outputBox.textContent = `${label}\n${JSON.stringify(payload, null, 2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeActionButton(label, handler, secondary) {
|
||||||
|
const btn = document.createElement("button");
|
||||||
|
btn.textContent = label;
|
||||||
|
if (secondary) btn.className = "secondary";
|
||||||
|
btn.addEventListener("click", handler);
|
||||||
|
return btn;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLoading(btn, loading) {
|
||||||
|
if (!btn) return;
|
||||||
|
btn.disabled = loading;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRoots() {
|
||||||
|
const data = await api("/api/files/roots");
|
||||||
|
state.roots = data.items || [];
|
||||||
|
el.rootsSelect.innerHTML = "";
|
||||||
|
for (const root of state.roots) {
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = root.id;
|
||||||
|
opt.textContent = `${root.id}: ${root.path}`;
|
||||||
|
el.rootsSelect.appendChild(opt);
|
||||||
|
}
|
||||||
|
out("Roots loaded", data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doSearch() {
|
||||||
|
const query = (el.searchInput.value || "").trim();
|
||||||
|
if (!query) return;
|
||||||
|
const data = await api(`/api/tvdb/search?q=${encodeURIComponent(query)}`);
|
||||||
|
el.searchResults.innerHTML = "";
|
||||||
|
(data.items || []).forEach((item) => {
|
||||||
|
const li = document.createElement("li");
|
||||||
|
const left = document.createElement("span");
|
||||||
|
left.textContent = item.display_name || item.name || "(no name)";
|
||||||
|
const right = document.createElement("div");
|
||||||
|
const btn = makeActionButton("Select", async () => {
|
||||||
|
state.selectedSeries = item;
|
||||||
|
el.seriesInfo.textContent = `Selected: ${item.display_name || item.name}`;
|
||||||
|
await loadEpisodes();
|
||||||
|
});
|
||||||
|
right.appendChild(btn);
|
||||||
|
li.appendChild(left);
|
||||||
|
li.appendChild(right);
|
||||||
|
el.searchResults.appendChild(li);
|
||||||
|
});
|
||||||
|
out("Search result", data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadEpisodes() {
|
||||||
|
if (!state.selectedSeries || !state.selectedSeries.id) {
|
||||||
|
throw new Error("Select a series first");
|
||||||
|
}
|
||||||
|
const data = await api(`/api/tvdb/series/${encodeURIComponent(state.selectedSeries.id)}/episodes?order_type=aired`);
|
||||||
|
state.episodes = data.items || [];
|
||||||
|
el.episodesList.innerHTML = "";
|
||||||
|
|
||||||
|
state.episodes.forEach((episode) => {
|
||||||
|
const li = document.createElement("li");
|
||||||
|
const left = document.createElement("span");
|
||||||
|
left.textContent = episode.label || `${episode.season_number}x${episode.episode_number} ${episode.title}`;
|
||||||
|
const right = document.createElement("div");
|
||||||
|
const btn = makeActionButton("Add", async () => {
|
||||||
|
const payload = {
|
||||||
|
id: episode.id,
|
||||||
|
series: state.selectedSeries.name,
|
||||||
|
year: state.selectedSeries.year,
|
||||||
|
season_number: episode.season_number,
|
||||||
|
episode_number: episode.episode_number,
|
||||||
|
title: episode.title,
|
||||||
|
aired: episode.aired,
|
||||||
|
label: episode.label,
|
||||||
|
};
|
||||||
|
const res = await api(q("/api/session/selected-episodes"), {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ items: [payload] }),
|
||||||
|
});
|
||||||
|
await loadSelectedEpisodes();
|
||||||
|
out("Episode added", res);
|
||||||
|
});
|
||||||
|
right.appendChild(btn);
|
||||||
|
li.appendChild(left);
|
||||||
|
li.appendChild(right);
|
||||||
|
el.episodesList.appendChild(li);
|
||||||
|
});
|
||||||
|
out("Episodes loaded", data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSelectedEpisodes() {
|
||||||
|
const data = await api(q("/api/session/selected-episodes"));
|
||||||
|
el.selectedEpisodesList.innerHTML = "";
|
||||||
|
(data.items || []).forEach((item, idx) => {
|
||||||
|
const li = document.createElement("li");
|
||||||
|
const left = document.createElement("span");
|
||||||
|
left.textContent = item.episode.label || item.episode.title || `Episode #${idx + 1}`;
|
||||||
|
const right = document.createElement("div");
|
||||||
|
right.appendChild(makeActionButton("Up", async () => {
|
||||||
|
if (idx === 0) return;
|
||||||
|
await api(q("/api/session/selected-episodes/reorder"), {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ from_index: idx, to_index: idx - 1 }),
|
||||||
|
});
|
||||||
|
await loadSelectedEpisodes();
|
||||||
|
}, true));
|
||||||
|
right.appendChild(makeActionButton("Down", async () => {
|
||||||
|
if (idx >= data.items.length - 1) return;
|
||||||
|
await api(q("/api/session/selected-episodes/reorder"), {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ from_index: idx, to_index: idx + 1 }),
|
||||||
|
});
|
||||||
|
await loadSelectedEpisodes();
|
||||||
|
}, true));
|
||||||
|
right.appendChild(makeActionButton("Remove", async () => {
|
||||||
|
await api(q(`/api/session/selected-episodes/${item.selection_id}`), { method: "DELETE" });
|
||||||
|
await loadSelectedEpisodes();
|
||||||
|
}, true));
|
||||||
|
li.appendChild(left);
|
||||||
|
li.appendChild(right);
|
||||||
|
el.selectedEpisodesList.appendChild(li);
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function discoverFiles() {
|
||||||
|
const rootId = el.rootsSelect.value;
|
||||||
|
if (!rootId) throw new Error("No root selected");
|
||||||
|
const subpath = encodeURIComponent((el.subpathInput.value || "").trim());
|
||||||
|
const recursive = el.recursiveInput.checked ? "true" : "false";
|
||||||
|
const data = await api(`/api/files/discover?root_id=${encodeURIComponent(rootId)}&subpath=${subpath}&recursive=${recursive}&limit=200`);
|
||||||
|
state.discoveredFiles = data.items || [];
|
||||||
|
el.discoveredFilesList.innerHTML = "";
|
||||||
|
|
||||||
|
state.discoveredFiles.forEach((file) => {
|
||||||
|
const li = document.createElement("li");
|
||||||
|
const left = document.createElement("span");
|
||||||
|
left.textContent = file.relative_path || file.name;
|
||||||
|
const right = document.createElement("div");
|
||||||
|
right.appendChild(makeActionButton("Add", async () => {
|
||||||
|
const payload = { path: file.path, name: file.name };
|
||||||
|
const res = await api(q("/api/session/selected-files"), {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ items: [payload] }),
|
||||||
|
});
|
||||||
|
await loadSelectedFiles();
|
||||||
|
out("File added", res);
|
||||||
|
}));
|
||||||
|
li.appendChild(left);
|
||||||
|
li.appendChild(right);
|
||||||
|
el.discoveredFilesList.appendChild(li);
|
||||||
|
});
|
||||||
|
out("Discover result", data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSelectedFiles() {
|
||||||
|
const data = await api(q("/api/session/selected-files"));
|
||||||
|
el.selectedFilesList.innerHTML = "";
|
||||||
|
(data.items || []).forEach((item, idx) => {
|
||||||
|
const li = document.createElement("li");
|
||||||
|
const left = document.createElement("span");
|
||||||
|
left.textContent = item.file.path || item.file.name || `File #${idx + 1}`;
|
||||||
|
const right = document.createElement("div");
|
||||||
|
right.appendChild(makeActionButton("Up", async () => {
|
||||||
|
if (idx === 0) return;
|
||||||
|
await api(q("/api/session/selected-files/reorder"), {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ from_index: idx, to_index: idx - 1 }),
|
||||||
|
});
|
||||||
|
await loadSelectedFiles();
|
||||||
|
}, true));
|
||||||
|
right.appendChild(makeActionButton("Down", async () => {
|
||||||
|
if (idx >= data.items.length - 1) return;
|
||||||
|
await api(q("/api/session/selected-files/reorder"), {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ from_index: idx, to_index: idx + 1 }),
|
||||||
|
});
|
||||||
|
await loadSelectedFiles();
|
||||||
|
}, true));
|
||||||
|
right.appendChild(makeActionButton("Remove", async () => {
|
||||||
|
await api(q(`/api/session/selected-files/${item.selection_id}`), { method: "DELETE" });
|
||||||
|
await loadSelectedFiles();
|
||||||
|
}, true));
|
||||||
|
li.appendChild(left);
|
||||||
|
li.appendChild(right);
|
||||||
|
el.selectedFilesList.appendChild(li);
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function callPreview(path, label) {
|
||||||
|
const data = await api(q(path));
|
||||||
|
out(label, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeRename() {
|
||||||
|
const data = await api(q("/api/session/rename-execute") + "&confirm=true", {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
out("Rename execute", data);
|
||||||
|
await loadSelectedFiles();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withHandler(fn, btn) {
|
||||||
|
try {
|
||||||
|
setLoading(btn, true);
|
||||||
|
await fn();
|
||||||
|
} catch (err) {
|
||||||
|
out("Error", { detail: err.message || String(err) });
|
||||||
|
} finally {
|
||||||
|
setLoading(btn, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindEvents() {
|
||||||
|
el.searchBtn.addEventListener("click", () => withHandler(doSearch, el.searchBtn));
|
||||||
|
el.refreshEpisodesBtn.addEventListener("click", () => withHandler(loadEpisodes, el.refreshEpisodesBtn));
|
||||||
|
el.refreshSelectedEpisodesBtn.addEventListener("click", () => withHandler(loadSelectedEpisodes, el.refreshSelectedEpisodesBtn));
|
||||||
|
el.clearSelectedEpisodesBtn.addEventListener("click", () =>
|
||||||
|
withHandler(async () => {
|
||||||
|
const res = await api(q("/api/session/selected-episodes"), { method: "DELETE" });
|
||||||
|
await loadSelectedEpisodes();
|
||||||
|
out("Selected episodes cleared", res);
|
||||||
|
}, el.clearSelectedEpisodesBtn)
|
||||||
|
);
|
||||||
|
el.refreshRootsBtn.addEventListener("click", () => withHandler(loadRoots, el.refreshRootsBtn));
|
||||||
|
el.discoverBtn.addEventListener("click", () => withHandler(discoverFiles, el.discoverBtn));
|
||||||
|
el.refreshSelectedFilesBtn.addEventListener("click", () => withHandler(loadSelectedFiles, el.refreshSelectedFilesBtn));
|
||||||
|
el.clearSelectedFilesBtn.addEventListener("click", () =>
|
||||||
|
withHandler(async () => {
|
||||||
|
const res = await api(q("/api/session/selected-files"), { method: "DELETE" });
|
||||||
|
await loadSelectedFiles();
|
||||||
|
out("Selected files cleared", res);
|
||||||
|
}, el.clearSelectedFilesBtn)
|
||||||
|
);
|
||||||
|
el.mappingPreviewBtn.addEventListener("click", () => withHandler(() => callPreview("/api/session/mapping-preview", "Mapping preview"), el.mappingPreviewBtn));
|
||||||
|
el.filenamePreviewBtn.addEventListener("click", () => withHandler(() => callPreview("/api/session/filename-preview", "Filename preview"), el.filenamePreviewBtn));
|
||||||
|
el.renameExecuteBtn.addEventListener("click", () => withHandler(executeRename, el.renameExecuteBtn));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
el.sessionMeta.textContent = `session_id: ${state.sessionId}`;
|
||||||
|
bindEvents();
|
||||||
|
await loadRoots();
|
||||||
|
await loadSelectedEpisodes();
|
||||||
|
await loadSelectedFiles();
|
||||||
|
}
|
||||||
|
|
||||||
|
init().catch((err) => out("Init error", { detail: err.message || String(err) }));
|
||||||
|
})();
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-family: "Segoe UI", Tahoma, sans-serif;
|
||||||
|
background: #f2f4f8;
|
||||||
|
color: #1a1f2b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: #0f172a;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#sessionMeta {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(280px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #d7dee9;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel h2 {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel h3 {
|
||||||
|
margin: 10px 0 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"],
|
||||||
|
select {
|
||||||
|
border: 1px solid #c3cedf;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
min-width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border: 1px solid #0f172a;
|
||||||
|
background: #0f172a;
|
||||||
|
color: #ffffff;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.secondary {
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #1a1f2b;
|
||||||
|
border-color: #c3cedf;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
max-height: 260px;
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid #e4eaf2;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list li {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
border-bottom: 1px solid #edf1f7;
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list li:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: #475569;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.output {
|
||||||
|
margin: 0 12px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#outputBox {
|
||||||
|
margin: 0;
|
||||||
|
background: #0b1220;
|
||||||
|
color: #dbeafe;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px;
|
||||||
|
max-height: 320px;
|
||||||
|
overflow: auto;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Rename MVP</title>
|
||||||
|
<link rel="stylesheet" href="/static/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="topbar">
|
||||||
|
<h1>Rename MVP</h1>
|
||||||
|
<div id="sessionMeta"></div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="grid">
|
||||||
|
<section class="panel" id="panelSearch">
|
||||||
|
<h2>Panel 1: TVDB Search</h2>
|
||||||
|
<div class="row">
|
||||||
|
<input id="searchInput" type="text" placeholder="Search series..." />
|
||||||
|
<button id="searchBtn">Search</button>
|
||||||
|
</div>
|
||||||
|
<div id="seriesInfo" class="muted"></div>
|
||||||
|
<ul id="searchResults" class="list"></ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel" id="panelEpisodes">
|
||||||
|
<h2>Panel 2: Episodes</h2>
|
||||||
|
<div class="row">
|
||||||
|
<button id="refreshEpisodesBtn">Refresh Episodes</button>
|
||||||
|
</div>
|
||||||
|
<ul id="episodesList" class="list"></ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel" id="panelSelectedEpisodes">
|
||||||
|
<h2>Panel 3: Selected Episodes</h2>
|
||||||
|
<div class="row">
|
||||||
|
<button id="refreshSelectedEpisodesBtn">Refresh</button>
|
||||||
|
<button id="clearSelectedEpisodesBtn">Clear</button>
|
||||||
|
</div>
|
||||||
|
<ul id="selectedEpisodesList" class="list"></ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel" id="panelSelectedFiles">
|
||||||
|
<h2>Panel 4: Selected Files + File Discovery</h2>
|
||||||
|
<div class="stack">
|
||||||
|
<div class="row">
|
||||||
|
<label for="rootsSelect">Root</label>
|
||||||
|
<select id="rootsSelect"></select>
|
||||||
|
<button id="refreshRootsBtn">Refresh Roots</button>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<input id="subpathInput" type="text" placeholder="Subpath (relative)" />
|
||||||
|
<label>
|
||||||
|
<input id="recursiveInput" type="checkbox" />
|
||||||
|
recursive
|
||||||
|
</label>
|
||||||
|
<button id="discoverBtn">Discover</button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3>Discovered Files</h3>
|
||||||
|
<ul id="discoveredFilesList" class="list"></ul>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<button id="refreshSelectedFilesBtn">Refresh Selected Files</button>
|
||||||
|
<button id="clearSelectedFilesBtn">Clear Selected Files</button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3>Selected Files</h3>
|
||||||
|
<ul id="selectedFilesList" class="list"></ul>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<button id="mappingPreviewBtn">Mapping Preview</button>
|
||||||
|
<button id="filenamePreviewBtn">Filename Preview</button>
|
||||||
|
<button id="renameExecuteBtn">Rename Execute (confirm=true)</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<section class="panel output">
|
||||||
|
<h2>Output</h2>
|
||||||
|
<pre id="outputBox"></pre>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<script src="/static/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Binary file not shown.
Executable
+88
@@ -0,0 +1,88 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [ -z "${BASE_URL:-}" ]; then
|
||||||
|
if curl --silent --fail http://127.0.0.1:8085/api/health >/dev/null 2>&1; then
|
||||||
|
BASE_URL="http://127.0.0.1:8085"
|
||||||
|
elif curl --silent --fail http://host.containers.internal:8085/api/health >/dev/null 2>&1; then
|
||||||
|
BASE_URL="http://host.containers.internal:8085"
|
||||||
|
else
|
||||||
|
echo "ERROR: could not determine BASE_URL. Tried 127.0.0.1 and host.containers.internal." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
TMP_DIR="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$TMP_DIR"' EXIT
|
||||||
|
|
||||||
|
echo "== Feature test 1: root page renders 4 panels and action controls =="
|
||||||
|
curl --fail --silent --show-error \
|
||||||
|
"${BASE_URL}/" \
|
||||||
|
-o "${TMP_DIR}/index.html"
|
||||||
|
|
||||||
|
python3 - "${TMP_DIR}/index.html" <<'PY'
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
html = Path(sys.argv[1]).read_text(encoding="utf-8")
|
||||||
|
required = [
|
||||||
|
"Panel 1: TVDB Search",
|
||||||
|
"Panel 2: Episodes",
|
||||||
|
"Panel 3: Selected Episodes",
|
||||||
|
"Panel 4: Selected Files + File Discovery",
|
||||||
|
"Mapping Preview",
|
||||||
|
"Filename Preview",
|
||||||
|
"Rename Execute (confirm=true)",
|
||||||
|
]
|
||||||
|
for needle in required:
|
||||||
|
assert needle in html, f"missing UI text: {needle}"
|
||||||
|
print("UI panel/control validation passed")
|
||||||
|
PY
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "== Feature test 2: static assets are served =="
|
||||||
|
curl --fail --silent --show-error \
|
||||||
|
"${BASE_URL}/static/styles.css" \
|
||||||
|
-o "${TMP_DIR}/styles.css"
|
||||||
|
curl --fail --silent --show-error \
|
||||||
|
"${BASE_URL}/static/app.js" \
|
||||||
|
-o "${TMP_DIR}/app.js"
|
||||||
|
|
||||||
|
python3 - "${TMP_DIR}/styles.css" "${TMP_DIR}/app.js" <<'PY'
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
css = Path(sys.argv[1]).read_text(encoding="utf-8")
|
||||||
|
js = Path(sys.argv[2]).read_text(encoding="utf-8")
|
||||||
|
assert ".grid" in css, "styles.css missing expected grid styles"
|
||||||
|
assert "session_id" in js, "app.js missing session_id usage"
|
||||||
|
assert "/api/tvdb/search" in js, "app.js missing search endpoint usage"
|
||||||
|
assert "/api/files/discover" in js, "app.js missing discovery endpoint usage"
|
||||||
|
print("static assets validation passed")
|
||||||
|
PY
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "== Feature test 3: UI references existing backend workflow endpoints =="
|
||||||
|
python3 - "${TMP_DIR}/app.js" <<'PY'
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
js = Path(sys.argv[1]).read_text(encoding="utf-8")
|
||||||
|
required_endpoints = [
|
||||||
|
"/api/tvdb/search",
|
||||||
|
"/api/tvdb/series/",
|
||||||
|
"/api/session/selected-episodes",
|
||||||
|
"/api/session/selected-files",
|
||||||
|
"/api/files/roots",
|
||||||
|
"/api/files/discover",
|
||||||
|
"/api/session/mapping-preview",
|
||||||
|
"/api/session/filename-preview",
|
||||||
|
"/api/session/rename-execute",
|
||||||
|
]
|
||||||
|
for endpoint in required_endpoints:
|
||||||
|
assert endpoint in js, f"missing endpoint usage in UI: {endpoint}"
|
||||||
|
print("endpoint wiring validation passed")
|
||||||
|
PY
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "All UI feature tests passed."
|
||||||
Reference in New Issue
Block a user