Add OpenSubtitles subtitle search + download (new Subtitles panel)
New web client (crates/aurora-curator-core/src/web/opensubtitles.rs, 9 tests): search, login, download, and fetch-subtitle-text against OpenSubtitles' REST API. Search only needs a free app-level API key; download additionally needs the end user's own OpenSubtitles account login (username/password -> JWT), since downloads are quota-limited per account. Settings gained three new fields (opensubtitles_api_key/username/password) following the existing TMDB/OMDb credential pattern, with one deliberate exception: the password is never returned to the frontend, only whether one is saved (has_opensubtitles_password), since it's a real account password rather than an API token. New src-tauri/src/subtitles.rs wraps search/download as Tauri commands and writes a downloaded subtitle right next to its video file (Show.S01E01.mkv -> Show.S01E01.en.srt). New src/components/SubtitlesPanel.tsx is the first real screen behind a sidebar entry besides Rename (previously a stub) - load a video, auto-fill the search form from the same local guess_episode/guess_movie Match uses, search, and download with live remaining/allowed quota shown per result. TheTVDB was considered instead but deliberately skipped for now: its v4 API requires either a paid commercial license or a per-end-user paid TheTVDB subscription just to log in, unlike every other source in this app. 144 tests passing (workspace). Updated README (status paragraph, project layout, new "Subtitles" section, roadmap items 5/6/7).
This commit is contained in:
+8
-1
@@ -2,6 +2,7 @@ import { useState } from "react";
|
||||
import Sidebar, { PANELS } from "./components/Sidebar";
|
||||
import Header from "./components/Header";
|
||||
import RenamePanel from "./components/RenamePanel";
|
||||
import SubtitlesPanel from "./components/SubtitlesPanel";
|
||||
import PanelStub from "./components/PanelStub";
|
||||
|
||||
export default function App() {
|
||||
@@ -14,7 +15,13 @@ export default function App() {
|
||||
<div className="main-column">
|
||||
<Header title={current.name} />
|
||||
<div className="main-content">
|
||||
{selected === "rename" ? <RenamePanel /> : <PanelStub name={current.name} />}
|
||||
{selected === "rename" ? (
|
||||
<RenamePanel />
|
||||
) : selected === "subtitles" ? (
|
||||
<SubtitlesPanel />
|
||||
) : (
|
||||
<PanelStub name={current.name} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
.subtitles-panel {
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
padding: 18px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.subtitles-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.subtitles-loaded-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--muted-text);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.subtitles-settings-button {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.subtitles-search-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--surface-deep);
|
||||
border-radius: 12px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.subtitles-search-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.subtitles-search-row-inline {
|
||||
flex-direction: row;
|
||||
gap: 12px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.subtitles-search-row-inline > div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.subtitles-search-row-inline > div:first-child,
|
||||
.subtitles-search-row-inline > div:nth-child(2) {
|
||||
flex: 0 0 88px;
|
||||
}
|
||||
|
||||
.subtitles-search-row-inline > div:nth-child(3) {
|
||||
flex: 0 0 96px;
|
||||
}
|
||||
|
||||
.subtitles-search-row-inline > div:last-child {
|
||||
flex: 1;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.subtitles-results {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.subtitles-result-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--surface-deep);
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.subtitles-result-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.subtitles-result-release {
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.subtitles-result-badges {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.subtitles-badge {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted-text);
|
||||
background: var(--surface-alt);
|
||||
border: 1px solid var(--surface-deep);
|
||||
border-radius: 6px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
.subtitles-badge-strong {
|
||||
color: var(--aurora-green);
|
||||
border-color: var(--aurora-green);
|
||||
}
|
||||
|
||||
.subtitles-result-meta {
|
||||
font-size: 11.5px;
|
||||
color: var(--muted-text);
|
||||
}
|
||||
|
||||
.subtitles-empty-note {
|
||||
color: var(--muted-text);
|
||||
font-size: 12.5px;
|
||||
padding: 8px 2px;
|
||||
}
|
||||
|
||||
.subtitles-status {
|
||||
font-size: 12px;
|
||||
color: var(--aurora-cyan);
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
interface FileEntry {
|
||||
path: string;
|
||||
name: string;
|
||||
ext: string;
|
||||
}
|
||||
|
||||
interface EpisodeGuess {
|
||||
seriesName: string;
|
||||
season: number;
|
||||
episode: number;
|
||||
}
|
||||
|
||||
interface MovieGuess {
|
||||
title: string;
|
||||
year: number | null;
|
||||
}
|
||||
|
||||
interface SubtitleResult {
|
||||
fileId: number;
|
||||
release: string;
|
||||
language: string;
|
||||
downloadCount: number;
|
||||
hearingImpaired: boolean;
|
||||
hd: boolean;
|
||||
moviehashMatch: boolean;
|
||||
uploader: string | null;
|
||||
}
|
||||
|
||||
interface SubtitleDownloadOutcome {
|
||||
savedPath: string;
|
||||
remainingDownloads: number;
|
||||
allowedDownloads: number;
|
||||
}
|
||||
|
||||
const LANGUAGE_OPTIONS: { code: string; label: string }[] = [
|
||||
{ code: "en", label: "English" },
|
||||
{ code: "es", label: "Spanish" },
|
||||
{ code: "fr", label: "French" },
|
||||
{ code: "de", label: "German" },
|
||||
{ code: "it", label: "Italian" },
|
||||
{ code: "pt", label: "Portuguese" },
|
||||
{ code: "pt-BR", label: "Portuguese (Brazil)" },
|
||||
{ code: "nl", label: "Dutch" },
|
||||
{ code: "sv", label: "Swedish" },
|
||||
{ code: "pl", label: "Polish" },
|
||||
{ code: "ru", label: "Russian" },
|
||||
{ code: "ja", label: "Japanese" },
|
||||
{ code: "ko", label: "Korean" },
|
||||
{ code: "zh-CN", label: "Chinese (Simplified)" },
|
||||
{ code: "ar", label: "Arabic" },
|
||||
];
|
||||
|
||||
export default function SubtitlesPanel() {
|
||||
const [videoPath, setVideoPath] = useState<string | null>(null);
|
||||
const [videoName, setVideoName] = useState<string | null>(null);
|
||||
|
||||
const [query, setQuery] = useState("");
|
||||
const [season, setSeason] = useState("");
|
||||
const [episode, setEpisode] = useState("");
|
||||
const [year, setYear] = useState("");
|
||||
const [language, setLanguage] = useState("en");
|
||||
|
||||
const [results, setResults] = useState<SubtitleResult[]>([]);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [searched, setSearched] = useState(false);
|
||||
const [downloadingId, setDownloadingId] = useState<number | null>(null);
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [savingSettings, setSavingSettings] = useState(false);
|
||||
const [hasApiKey, setHasApiKey] = useState<boolean | null>(null);
|
||||
const [hasUsername, setHasUsername] = useState<boolean | null>(null);
|
||||
const [hasPassword, setHasPassword] = useState<boolean | null>(null);
|
||||
const [apiKeyInput, setApiKeyInput] = useState("");
|
||||
const [usernameInput, setUsernameInput] = useState("");
|
||||
const [passwordInput, setPasswordInput] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
invoke<string | null>("get_opensubtitles_api_key")
|
||||
.then((key) => setHasApiKey(!!key))
|
||||
.catch(() => setHasApiKey(false));
|
||||
invoke<string | null>("get_opensubtitles_username")
|
||||
.then((name) => setHasUsername(!!name))
|
||||
.catch(() => setHasUsername(false));
|
||||
invoke<boolean>("has_opensubtitles_password")
|
||||
.then(setHasPassword)
|
||||
.catch(() => setHasPassword(false));
|
||||
}, []);
|
||||
|
||||
const showStatus = (message: string) => {
|
||||
setStatus(message);
|
||||
window.setTimeout(() => setStatus((current) => (current === message ? null : current)), 5000);
|
||||
};
|
||||
|
||||
const loadVideo = async () => {
|
||||
try {
|
||||
const entries = await invoke<FileEntry[]>("pick_files");
|
||||
const entry = entries?.[0];
|
||||
if (!entry) return;
|
||||
|
||||
setVideoPath(entry.path);
|
||||
setVideoName(entry.ext ? `${entry.name}.${entry.ext}` : entry.name);
|
||||
setResults([]);
|
||||
setSearched(false);
|
||||
|
||||
const filename = entry.ext ? `${entry.name}.${entry.ext}` : entry.name;
|
||||
const episodeGuess = await invoke<EpisodeGuess | null>("guess_episode", { filename }).catch(() => null);
|
||||
if (episodeGuess) {
|
||||
setQuery(episodeGuess.seriesName);
|
||||
setSeason(String(episodeGuess.season));
|
||||
setEpisode(String(episodeGuess.episode));
|
||||
setYear("");
|
||||
return;
|
||||
}
|
||||
|
||||
const movieGuess = await invoke<MovieGuess | null>("guess_movie", { filename }).catch(() => null);
|
||||
if (movieGuess) {
|
||||
setQuery(movieGuess.title);
|
||||
setSeason("");
|
||||
setEpisode("");
|
||||
setYear(movieGuess.year ? String(movieGuess.year) : "");
|
||||
} else {
|
||||
setQuery(entry.name);
|
||||
setSeason("");
|
||||
setEpisode("");
|
||||
setYear("");
|
||||
}
|
||||
} catch (err) {
|
||||
showStatus(`Couldn't load a file: ${err}`);
|
||||
}
|
||||
};
|
||||
|
||||
const runSearch = async () => {
|
||||
const trimmedQuery = query.trim();
|
||||
if (!trimmedQuery) {
|
||||
showStatus("Enter a title to search for first.");
|
||||
return;
|
||||
}
|
||||
setSearching(true);
|
||||
setSearched(false);
|
||||
try {
|
||||
const found = await invoke<SubtitleResult[]>("search_subtitles", {
|
||||
query: trimmedQuery,
|
||||
season: season.trim() ? Number(season.trim()) : null,
|
||||
episode: episode.trim() ? Number(episode.trim()) : null,
|
||||
year: year.trim() ? Number(year.trim()) : null,
|
||||
language,
|
||||
});
|
||||
setResults(found);
|
||||
setSearched(true);
|
||||
if (found.length === 0) {
|
||||
showStatus("No subtitles found for that search.");
|
||||
}
|
||||
} catch (err) {
|
||||
setResults([]);
|
||||
setSearched(true);
|
||||
const message = String(err);
|
||||
showStatus(message);
|
||||
if (message.toLowerCase().includes("settings")) {
|
||||
setSettingsOpen(true);
|
||||
}
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
const downloadResult = async (result: SubtitleResult) => {
|
||||
if (!videoPath) {
|
||||
showStatus("Load a video file first — a subtitle needs somewhere to be saved next to.");
|
||||
return;
|
||||
}
|
||||
setDownloadingId(result.fileId);
|
||||
try {
|
||||
const outcome = await invoke<SubtitleDownloadOutcome>("download_subtitle", {
|
||||
fileId: result.fileId,
|
||||
videoPath,
|
||||
language: result.language || language,
|
||||
});
|
||||
showStatus(
|
||||
`Saved ${outcome.savedPath.split("/").pop()} (${outcome.remainingDownloads}/${outcome.allowedDownloads} OpenSubtitles downloads left today).`
|
||||
);
|
||||
} catch (err) {
|
||||
const message = String(err);
|
||||
showStatus(message);
|
||||
if (message.toLowerCase().includes("settings")) {
|
||||
setSettingsOpen(true);
|
||||
}
|
||||
} finally {
|
||||
setDownloadingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const openSettings = () => {
|
||||
setApiKeyInput("");
|
||||
setUsernameInput("");
|
||||
setPasswordInput("");
|
||||
setSettingsOpen(true);
|
||||
};
|
||||
|
||||
const saveSettings = async () => {
|
||||
const trimmedKey = apiKeyInput.trim();
|
||||
const trimmedUsername = usernameInput.trim();
|
||||
const trimmedPassword = passwordInput.trim();
|
||||
if (!trimmedKey && !trimmedUsername && !trimmedPassword) {
|
||||
setSettingsOpen(false);
|
||||
return;
|
||||
}
|
||||
setSavingSettings(true);
|
||||
try {
|
||||
if (trimmedKey) {
|
||||
await invoke("save_opensubtitles_api_key", { key: trimmedKey });
|
||||
setHasApiKey(true);
|
||||
}
|
||||
if (trimmedUsername || trimmedPassword) {
|
||||
await invoke("save_opensubtitles_login", { username: usernameInput, password: passwordInput });
|
||||
if (trimmedUsername) setHasUsername(true);
|
||||
if (trimmedPassword) setHasPassword(true);
|
||||
}
|
||||
setApiKeyInput("");
|
||||
setUsernameInput("");
|
||||
setPasswordInput("");
|
||||
setSettingsOpen(false);
|
||||
showStatus("Subtitle settings saved.");
|
||||
} catch (err) {
|
||||
showStatus(`Couldn't save: ${err}`);
|
||||
} finally {
|
||||
setSavingSettings(false);
|
||||
}
|
||||
};
|
||||
|
||||
const clearApiKey = async () => {
|
||||
setSavingSettings(true);
|
||||
try {
|
||||
await invoke("save_opensubtitles_api_key", { key: "" });
|
||||
setHasApiKey(false);
|
||||
showStatus("OpenSubtitles API key removed.");
|
||||
} finally {
|
||||
setSavingSettings(false);
|
||||
}
|
||||
};
|
||||
|
||||
const clearLogin = async () => {
|
||||
setSavingSettings(true);
|
||||
try {
|
||||
await invoke("save_opensubtitles_login", { username: "", password: "" });
|
||||
setHasUsername(false);
|
||||
setHasPassword(false);
|
||||
showStatus("OpenSubtitles login removed.");
|
||||
} finally {
|
||||
setSavingSettings(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="subtitles-panel">
|
||||
<div className="subtitles-toolbar">
|
||||
<button className="action-button" onClick={loadVideo}>
|
||||
Load Video File…
|
||||
</button>
|
||||
<span className="subtitles-loaded-name">{videoName ?? "No video file loaded"}</span>
|
||||
<button className="action-button subtitles-settings-button" onClick={openSettings}>
|
||||
Subtitle Settings
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="subtitles-search-form">
|
||||
<div className="subtitles-search-row">
|
||||
<label className="modal-field-label" htmlFor="subtitles-query">
|
||||
Title
|
||||
</label>
|
||||
<input
|
||||
id="subtitles-query"
|
||||
className="modal-text-input"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Show or movie title"
|
||||
/>
|
||||
</div>
|
||||
<div className="subtitles-search-row subtitles-search-row-inline">
|
||||
<div>
|
||||
<label className="modal-field-label" htmlFor="subtitles-season">
|
||||
Season
|
||||
</label>
|
||||
<input
|
||||
id="subtitles-season"
|
||||
className="modal-text-input"
|
||||
value={season}
|
||||
onChange={(e) => setSeason(e.target.value)}
|
||||
placeholder="TV only"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="modal-field-label" htmlFor="subtitles-episode">
|
||||
Episode
|
||||
</label>
|
||||
<input
|
||||
id="subtitles-episode"
|
||||
className="modal-text-input"
|
||||
value={episode}
|
||||
onChange={(e) => setEpisode(e.target.value)}
|
||||
placeholder="TV only"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="modal-field-label" htmlFor="subtitles-year">
|
||||
Year
|
||||
</label>
|
||||
<input
|
||||
id="subtitles-year"
|
||||
className="modal-text-input"
|
||||
value={year}
|
||||
onChange={(e) => setYear(e.target.value)}
|
||||
placeholder="Optional"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="modal-field-label" htmlFor="subtitles-language">
|
||||
Language
|
||||
</label>
|
||||
<select
|
||||
id="subtitles-language"
|
||||
className="modal-text-input"
|
||||
value={language}
|
||||
onChange={(e) => setLanguage(e.target.value)}
|
||||
>
|
||||
{LANGUAGE_OPTIONS.map((l) => (
|
||||
<option key={l.code} value={l.code}>
|
||||
{l.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button className="action-button primary" onClick={runSearch} disabled={searching}>
|
||||
{searching ? "Searching…" : "Search Subtitles"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="subtitles-results">
|
||||
{results.map((r) => (
|
||||
<div className="subtitles-result-row" key={r.fileId}>
|
||||
<div className="subtitles-result-main">
|
||||
<span className="subtitles-result-release">{r.release}</span>
|
||||
<span className="subtitles-result-badges">
|
||||
<span className="subtitles-badge">{r.language}</span>
|
||||
{r.hd && <span className="subtitles-badge">HD</span>}
|
||||
{r.moviehashMatch && <span className="subtitles-badge subtitles-badge-strong">Hash match</span>}
|
||||
{r.hearingImpaired && <span className="subtitles-badge">HI</span>}
|
||||
</span>
|
||||
<span className="subtitles-result-meta">
|
||||
{r.uploader ? `by ${r.uploader} — ` : ""}
|
||||
{r.downloadCount.toLocaleString()} downloads
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className="action-button"
|
||||
onClick={() => downloadResult(r)}
|
||||
disabled={downloadingId === r.fileId}
|
||||
>
|
||||
{downloadingId === r.fileId ? "Downloading…" : "Download"}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{searched && results.length === 0 && <p className="subtitles-empty-note">No subtitles found.</p>}
|
||||
{!searched && (
|
||||
<p className="subtitles-empty-note">
|
||||
Load a video file (or just type a title) and search — OpenSubtitles searching needs a free API key,
|
||||
downloading also needs your OpenSubtitles account login. See Subtitle Settings above.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{status && <div className="subtitles-status">{status}</div>}
|
||||
|
||||
{settingsOpen && (
|
||||
<div className="modal-overlay" onClick={() => !savingSettings && setSettingsOpen(false)}>
|
||||
<div className="modal-card modal-card-compact" onClick={(e) => e.stopPropagation()}>
|
||||
<h2 className="modal-title">Subtitle Settings</h2>
|
||||
<p className="modal-subtitle">
|
||||
Get a free API key by creating an account at{" "}
|
||||
<a href="https://www.opensubtitles.com/" target="_blank" rel="noreferrer">
|
||||
OpenSubtitles
|
||||
</a>{" "}
|
||||
and registering an app under Account → API Consumers — needed just to search. Downloading a subtitle
|
||||
additionally needs your own OpenSubtitles account login below, and is limited to a daily quota that
|
||||
OpenSubtitles sets per account (shown after each download).
|
||||
</p>
|
||||
<label htmlFor="opensubtitles-key" className="modal-field-label">
|
||||
OpenSubtitles API Key
|
||||
</label>
|
||||
<input
|
||||
id="opensubtitles-key"
|
||||
className="modal-text-input"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
value={apiKeyInput}
|
||||
onChange={(e) => setApiKeyInput(e.target.value)}
|
||||
placeholder={hasApiKey ? "•••••••••• (already saved — enter a new key to replace it)" : "Paste your OpenSubtitles API key here"}
|
||||
/>
|
||||
{hasApiKey && (
|
||||
<button className="action-button modal-inline-remove" onClick={clearApiKey} disabled={savingSettings}>
|
||||
Remove API Key
|
||||
</button>
|
||||
)}
|
||||
|
||||
<label htmlFor="opensubtitles-username" className="modal-field-label">
|
||||
OpenSubtitles Username
|
||||
</label>
|
||||
<input
|
||||
id="opensubtitles-username"
|
||||
className="modal-text-input"
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
value={usernameInput}
|
||||
onChange={(e) => setUsernameInput(e.target.value)}
|
||||
placeholder={hasUsername ? "already saved — enter a new username to replace it" : "Your OpenSubtitles username"}
|
||||
/>
|
||||
<label htmlFor="opensubtitles-password" className="modal-field-label">
|
||||
OpenSubtitles Password
|
||||
</label>
|
||||
<input
|
||||
id="opensubtitles-password"
|
||||
className="modal-text-input"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
value={passwordInput}
|
||||
onChange={(e) => setPasswordInput(e.target.value)}
|
||||
placeholder={hasPassword ? "•••••••••• (already saved — enter a new password to replace it)" : "Your OpenSubtitles password"}
|
||||
/>
|
||||
{(hasUsername || hasPassword) && (
|
||||
<button className="action-button modal-inline-remove" onClick={clearLogin} disabled={savingSettings}>
|
||||
Remove Login
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="modal-actions">
|
||||
<button className="action-button" onClick={() => setSettingsOpen(false)} disabled={savingSettings}>
|
||||
Cancel
|
||||
</button>
|
||||
<button className="action-button primary" onClick={saveSettings} disabled={savingSettings}>
|
||||
{savingSettings ? "Saving…" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import "./App.css";
|
||||
import "./components/Sidebar.css";
|
||||
import "./components/Header.css";
|
||||
import "./components/RenamePanel.css";
|
||||
import "./components/SubtitlesPanel.css";
|
||||
import "./components/PanelStub.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
|
||||
Reference in New Issue
Block a user