774 lines
21 KiB
Svelte
774 lines
21 KiB
Svelte
<script>
|
||
import { onMount } from "svelte";
|
||
import { apiFetch } from "../utils/api.js";
|
||
import { showToast } from "../stores/toastStore.js";
|
||
|
||
const tabs = [
|
||
{ id: "general", label: "General", icon: "sliders" },
|
||
{ id: "youtube", label: "YouTube", icon: "youtube" },
|
||
{ id: "rclone", label: "Rclone", icon: "cloud" },
|
||
{ id: "advanced", label: "Advanced", icon: "gear" }
|
||
];
|
||
|
||
let activeTab = "youtube";
|
||
let youtubeCookies = "";
|
||
let cookiesUpdatedAt = null;
|
||
let loadingCookies = false;
|
||
let savingCookies = false;
|
||
let loadingYtSettings = false;
|
||
let savingYtSettings = false;
|
||
let selectedResolution = "1080p";
|
||
let onlyAudio = false;
|
||
const resolutionOptions = ["1080p", "720p", "480p", "360p", "240p", "144p"];
|
||
let error = null;
|
||
|
||
let rcloneStatus = null;
|
||
let rcloneLoading = false;
|
||
let rcloneSaving = false;
|
||
let rcloneAutoMove = false;
|
||
let rcloneAutoMount = false;
|
||
let rcloneCacheCleanMinutes = 0;
|
||
let rcloneConfText = "";
|
||
let rcloneConfVisible = false;
|
||
|
||
async function loadCookies() {
|
||
loadingCookies = true;
|
||
error = null;
|
||
try {
|
||
const resp = await apiFetch("/api/youtube/cookies");
|
||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||
const data = await resp.json();
|
||
youtubeCookies = data?.cookies || "";
|
||
cookiesUpdatedAt = data?.updatedAt || null;
|
||
} catch (err) {
|
||
error = err?.message || "Cookies alınamadı.";
|
||
} finally {
|
||
loadingCookies = false;
|
||
}
|
||
}
|
||
|
||
async function saveCookies() {
|
||
if (savingCookies) return;
|
||
error = null;
|
||
savingCookies = true;
|
||
try {
|
||
const payload = {
|
||
cookies: youtubeCookies
|
||
.split("\n")
|
||
.map((line) => line.trim())
|
||
.filter(Boolean)
|
||
.join("\n")
|
||
};
|
||
const resp = await apiFetch("/api/youtube/cookies", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(payload)
|
||
});
|
||
const data = await resp.json().catch(() => ({}));
|
||
if (!resp.ok || !data?.ok) {
|
||
throw new Error(data?.error || `HTTP ${resp.status}`);
|
||
}
|
||
cookiesUpdatedAt = data.updatedAt || Date.now();
|
||
showToast("Cookies kaydedildi.", "success");
|
||
} catch (err) {
|
||
error = err?.message || "Cookies kaydedilemedi.";
|
||
} finally {
|
||
savingCookies = false;
|
||
}
|
||
}
|
||
|
||
async function loadYoutubeSettings() {
|
||
loadingYtSettings = true;
|
||
error = null;
|
||
try {
|
||
const resp = await apiFetch("/api/youtube/settings");
|
||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||
const data = await resp.json();
|
||
if (data?.resolution) selectedResolution = data.resolution;
|
||
if (typeof data?.onlyAudio === "boolean") onlyAudio = data.onlyAudio;
|
||
} catch (err) {
|
||
error = err?.message || "YouTube ayarları alınamadı.";
|
||
} finally {
|
||
loadingYtSettings = false;
|
||
}
|
||
}
|
||
|
||
async function saveYoutubeSettings() {
|
||
if (savingYtSettings) return;
|
||
savingYtSettings = true;
|
||
error = null;
|
||
try {
|
||
const resp = await apiFetch("/api/youtube/settings", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
resolution: selectedResolution,
|
||
onlyAudio
|
||
})
|
||
});
|
||
const data = await resp.json().catch(() => ({}));
|
||
if (!resp.ok || !data?.ok) {
|
||
throw new Error(data?.error || `HTTP ${resp.status}`);
|
||
}
|
||
showToast("YouTube indirme ayarları kaydedildi.", "success");
|
||
} catch (err) {
|
||
error = err?.message || "YouTube ayarları kaydedilemedi.";
|
||
} finally {
|
||
savingYtSettings = false;
|
||
}
|
||
}
|
||
|
||
async function loadRcloneStatus() {
|
||
rcloneLoading = true;
|
||
error = null;
|
||
try {
|
||
const resp = await apiFetch("/api/rclone/status");
|
||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||
const data = await resp.json();
|
||
rcloneStatus = data;
|
||
rcloneAutoMove = Boolean(data?.autoMove);
|
||
rcloneAutoMount = Boolean(data?.autoMount);
|
||
rcloneCacheCleanMinutes = Number(data?.cacheCleanMinutes) || 0;
|
||
} catch (err) {
|
||
error = err?.message || "Rclone durumu alınamadı.";
|
||
} finally {
|
||
rcloneLoading = false;
|
||
}
|
||
}
|
||
|
||
async function loadRcloneConf() {
|
||
try {
|
||
const resp = await apiFetch("/api/rclone/conf");
|
||
const data = await resp.json().catch(() => ({}));
|
||
if (!resp.ok || !data?.ok) {
|
||
throw new Error(data?.error || `HTTP ${resp.status}`);
|
||
}
|
||
rcloneConfText = data.content || "";
|
||
} catch (err) {
|
||
error = err?.message || "rclone.conf okunamadı.";
|
||
}
|
||
}
|
||
|
||
async function saveRcloneSettings() {
|
||
if (rcloneSaving) return;
|
||
rcloneSaving = true;
|
||
error = null;
|
||
try {
|
||
const resp = await apiFetch("/api/rclone/settings", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
autoMove: rcloneAutoMove,
|
||
autoMount: rcloneAutoMount,
|
||
cacheCleanMinutes: rcloneCacheCleanMinutes
|
||
})
|
||
});
|
||
const data = await resp.json().catch(() => ({}));
|
||
if (!resp.ok || !data?.ok) {
|
||
throw new Error(data?.error || `HTTP ${resp.status}`);
|
||
}
|
||
const confResp = await apiFetch("/api/rclone/conf", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ content: rcloneConfText })
|
||
});
|
||
const confData = await confResp.json().catch(() => ({}));
|
||
if (!confResp.ok || !confData?.ok) {
|
||
throw new Error(confData?.error || `HTTP ${confResp.status}`);
|
||
}
|
||
showToast("Rclone ayarları kaydedildi.", "success");
|
||
await loadRcloneStatus();
|
||
} catch (err) {
|
||
error = err?.message || "Rclone ayarları kaydedilemedi.";
|
||
} finally {
|
||
rcloneSaving = false;
|
||
}
|
||
}
|
||
|
||
async function startRcloneMount() {
|
||
error = null;
|
||
try {
|
||
const resp = await apiFetch("/api/rclone/mount", { method: "POST" });
|
||
const data = await resp.json().catch(() => ({}));
|
||
if (!resp.ok || !data?.ok) {
|
||
throw new Error(data?.error || `HTTP ${resp.status}`);
|
||
}
|
||
|
||
// Mount başlatıldı, birkaç saniye bekleyip tekrar kontrol et
|
||
showToast("Rclone mount başlatılıyor...", "info");
|
||
|
||
// 2 saniye sonra status güncelle
|
||
setTimeout(async () => {
|
||
await loadRcloneStatus();
|
||
// Status yüklendikten sonra mesajı güncelle
|
||
if (rcloneStatus?.mounted) {
|
||
showToast("Rclone mount başarıyla başlatıldı.", "success");
|
||
} else if (rcloneStatus?.running) {
|
||
showToast("Rclone mount başlatıldı, mount tamamlanıyor...", "info");
|
||
} else {
|
||
error = "Rclone mount başlatılamadı.";
|
||
}
|
||
}, 2000);
|
||
|
||
// İlk status güncellemesi
|
||
await loadRcloneStatus();
|
||
} catch (err) {
|
||
error = err?.message || "Rclone mount başlatılamadı.";
|
||
}
|
||
}
|
||
|
||
async function stopRcloneMount() {
|
||
error = null;
|
||
try {
|
||
const resp = await apiFetch("/api/rclone/unmount", { method: "POST" });
|
||
const data = await resp.json().catch(() => ({}));
|
||
if (!resp.ok || !data?.ok) {
|
||
throw new Error(data?.error || `HTTP ${resp.status}`);
|
||
}
|
||
showToast("Rclone mount durduruldu.", "success");
|
||
await loadRcloneStatus();
|
||
} catch (err) {
|
||
error = err?.message || "Rclone mount durdurulamadı.";
|
||
}
|
||
}
|
||
|
||
async function cleanRcloneCache() {
|
||
error = null;
|
||
try {
|
||
const resp = await apiFetch("/api/rclone/cache/clean", { method: "POST" });
|
||
const data = await resp.json().catch(() => ({}));
|
||
if (!resp.ok || !data?.ok) {
|
||
throw new Error(data?.error || `HTTP ${resp.status}`);
|
||
}
|
||
showToast("Cache temizlendi.", "success");
|
||
await loadRcloneStatus();
|
||
} catch (err) {
|
||
error = err?.message || "Cache temizlenemedi.";
|
||
showToast(error, "error");
|
||
}
|
||
}
|
||
|
||
onMount(() => {
|
||
loadCookies();
|
||
loadYoutubeSettings();
|
||
loadRcloneStatus();
|
||
loadRcloneConf();
|
||
});
|
||
|
||
function formatDate(ts) {
|
||
if (!ts) return "—";
|
||
const d = new Date(Number(ts));
|
||
if (Number.isNaN(d.getTime())) return "—";
|
||
return d.toLocaleString();
|
||
}
|
||
</script>
|
||
|
||
<section class="files">
|
||
<div class="files-header">
|
||
<div class="header-title">
|
||
<h2>Settings</h2>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="tabs">
|
||
<button
|
||
class="tab {activeTab === 'general' ? 'active' : ''}"
|
||
type="button"
|
||
on:click={() => activeTab = 'general'}
|
||
>
|
||
<i class="fa-solid fa-sliders"></i>
|
||
<span>General</span>
|
||
</button>
|
||
<button
|
||
class="tab {activeTab === 'youtube' ? 'active' : ''}"
|
||
type="button"
|
||
on:click={() => activeTab = 'youtube'}
|
||
>
|
||
<i class="fa-brands fa-youtube"></i>
|
||
<span>YouTube</span>
|
||
</button>
|
||
<button
|
||
class="tab {activeTab === 'rclone' ? 'active' : ''}"
|
||
type="button"
|
||
on:click={() => activeTab = 'rclone'}
|
||
>
|
||
<i class="fa-solid fa-cloud"></i>
|
||
<span>Rclone</span>
|
||
</button>
|
||
<button
|
||
class="tab {activeTab === 'advanced' ? 'active' : ''}"
|
||
type="button"
|
||
on:click={() => activeTab = 'advanced'}
|
||
>
|
||
<i class="fa-solid fa-gear"></i>
|
||
<span>Advanced</span>
|
||
</button>
|
||
</div>
|
||
|
||
{#if activeTab === "youtube"}
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<div class="title">
|
||
<i class="fa-solid fa-circle-down"></i>
|
||
<span>YouTube Download</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="field inline compact left-align">
|
||
<div class="inline-field">
|
||
<label for="resolution">Çözünürlük</label>
|
||
<select
|
||
id="resolution"
|
||
bind:value={selectedResolution}
|
||
disabled={loadingYtSettings || savingYtSettings}
|
||
>
|
||
{#each resolutionOptions as res}
|
||
<option value={res}>{res}</option>
|
||
{/each}
|
||
</select>
|
||
</div>
|
||
<label class="checkbox-row">
|
||
<input
|
||
type="checkbox"
|
||
bind:checked={onlyAudio}
|
||
disabled={loadingYtSettings || savingYtSettings}
|
||
/>
|
||
<span>Only Audio (sadece ses indir)</span>
|
||
</label>
|
||
</div>
|
||
|
||
<div class="actions left">
|
||
<button class="btn" on:click={loadYoutubeSettings} disabled={loadingYtSettings || savingYtSettings}>
|
||
<i class="fa-solid fa-rotate"></i> Yenile
|
||
</button>
|
||
<button class="btn primary" on:click={saveYoutubeSettings} disabled={loadingYtSettings || savingYtSettings}>
|
||
<i class="fa-solid fa-floppy-disk"></i> Kaydet
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<div class="title">
|
||
<i class="fa-brands fa-youtube"></i>
|
||
<span>YouTube Cookies</span>
|
||
</div>
|
||
{#if cookiesUpdatedAt}
|
||
<div class="meta">Son güncelleme: {formatDate(cookiesUpdatedAt)}</div>
|
||
{/if}
|
||
</div>
|
||
|
||
<div class="field">
|
||
<label for="cookies">cookies.txt içeriği</label>
|
||
<textarea
|
||
id="cookies"
|
||
spellcheck="false"
|
||
placeholder="Netscape HTTP Cookie formatında (cookies.txt) içerik girin"
|
||
bind:value={youtubeCookies}
|
||
></textarea>
|
||
<small>
|
||
Zararlı komut çalıştırılamaz; yalnızca düz metin cookie satırları yazılır.
|
||
Maksimum 20KB. Engellenen karakterler otomatik reddedilir.
|
||
</small>
|
||
</div>
|
||
|
||
<div class="actions">
|
||
<button class="btn" on:click={loadCookies} disabled={loadingCookies || savingCookies}>
|
||
<i class="fa-solid fa-rotate"></i> Yenile
|
||
</button>
|
||
<button class="btn primary" on:click={saveCookies} disabled={loadingCookies || savingCookies}>
|
||
<i class="fa-solid fa-floppy-disk"></i> Kaydet
|
||
</button>
|
||
</div>
|
||
|
||
{#if error}
|
||
<div class="alert error">
|
||
<i class="fa-solid fa-circle-exclamation"></i>
|
||
{error}
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
{:else if activeTab === "general"}
|
||
<div class="card muted">Genel ayarlar burada yer alacak.</div>
|
||
{:else if activeTab === "rclone"}
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<div class="title">
|
||
<i class="fa-solid fa-cloud"></i>
|
||
<span>Google Drive (Rclone)</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="field inline compact left-align">
|
||
<label class="checkbox-row">
|
||
<input
|
||
type="checkbox"
|
||
bind:checked={rcloneAutoMove}
|
||
disabled={rcloneLoading || rcloneSaving}
|
||
/>
|
||
<span>İndirince otomatik taşı</span>
|
||
</label>
|
||
<label class="checkbox-row">
|
||
<input
|
||
type="checkbox"
|
||
bind:checked={rcloneAutoMount}
|
||
disabled={rcloneLoading || rcloneSaving}
|
||
/>
|
||
<span>Başlangıçta otomatik mount</span>
|
||
</label>
|
||
</div>
|
||
|
||
<div class="field inline compact left-align">
|
||
<div class="inline-field">
|
||
<label for="rclone-cache-clean">Cache temizleme (dakika)</label>
|
||
<input
|
||
id="rclone-cache-clean"
|
||
type="number"
|
||
min="0"
|
||
step="1"
|
||
bind:value={rcloneCacheCleanMinutes}
|
||
disabled={rcloneLoading || rcloneSaving}
|
||
/>
|
||
</div>
|
||
<button class="btn primary" on:click={cleanRcloneCache}>
|
||
<i class="fa-solid fa-broom"></i> Cache Temizle
|
||
</button>
|
||
</div>
|
||
|
||
<div class="field">
|
||
<label>rclone.conf</label>
|
||
<div class="password-field">
|
||
<textarea
|
||
class="conf-textarea {rcloneConfVisible ? '' : 'masked'}"
|
||
bind:value={rcloneConfText}
|
||
placeholder="rclone.conf içeriğini yapıştırın"
|
||
></textarea>
|
||
<button
|
||
class="eye-btn"
|
||
type="button"
|
||
on:click={() => (rcloneConfVisible = !rcloneConfVisible)}
|
||
aria-label="Gizli/Görünür"
|
||
>
|
||
<i class={rcloneConfVisible ? "fa-solid fa-eye-slash" : "fa-solid fa-eye"}></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="actions">
|
||
<button class="btn primary" on:click={saveRcloneSettings} disabled={rcloneLoading || rcloneSaving}>
|
||
<i class="fa-solid fa-floppy-disk"></i> Kaydet
|
||
</button>
|
||
</div>
|
||
|
||
{#if error}
|
||
<div class="alert error" style="margin-top:10px;">
|
||
<i class="fa-solid fa-circle-exclamation"></i>
|
||
{error}
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
|
||
<!-- Mount Kontrol Kartı -->
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<div class="title">
|
||
<i class="fa-solid fa-cloud"></i>
|
||
<span>Mount Kontrol</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="actions left">
|
||
<button class="btn" on:click={startRcloneMount}>
|
||
<i class="fa-solid fa-play"></i> Mount Başlat
|
||
</button>
|
||
<button class="btn" on:click={stopRcloneMount}>
|
||
<i class="fa-solid fa-stop"></i> Mount Durdur
|
||
</button>
|
||
</div>
|
||
|
||
{#if rcloneStatus}
|
||
<div class="card muted" style="margin-top:12px;">
|
||
<div><strong>Durum:</strong></div>
|
||
<div>Enabled: {rcloneStatus.enabled ? "Evet" : "Hayır"}</div>
|
||
<div>
|
||
Mounted:
|
||
{#if rcloneStatus.mountStatus === "starting"}
|
||
<span style="color: #f57c00;">Başlatılıyor...</span>
|
||
{:else if rcloneStatus.mounted}
|
||
<span style="color: #388e3c;">Evet</span>
|
||
{:else}
|
||
Hayır
|
||
{/if}
|
||
</div>
|
||
<div>Running: {rcloneStatus.running ? "Evet" : "Hayır"}</div>
|
||
<div>Remote: {rcloneStatus.remoteConfigured ? "Hazır" : "Eksik"}</div>
|
||
{#if rcloneStatus.vfsCacheMode}
|
||
<div>VFS Cache Mode: <code>{rcloneStatus.vfsCacheMode}</code></div>
|
||
{/if}
|
||
{#if rcloneStatus.diskUsage}
|
||
<div style="margin-top: 8px;">
|
||
<div style="font-size: 12px; color: #666;">Disk Kullanımı:</div>
|
||
<div style="font-size: 13px;">
|
||
Kullanım: %{rcloneStatus.diskUsage.usedPercent} |
|
||
Boş: {rcloneStatus.diskUsage.availableGB.toFixed(1)}GB /
|
||
{rcloneStatus.diskUsage.totalGB.toFixed(1)}GB
|
||
</div>
|
||
{#if rcloneStatus.cacheCleanThreshold}
|
||
<div style="font-size: 11px; color: #888;">
|
||
Temizleme eşiği: %{rcloneStatus.cacheCleanThreshold}
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
{#if rcloneStatus.lastError}
|
||
<div style="margin-top: 8px; color: #d32f2f;">
|
||
<i class="fa-solid fa-circle-exclamation"></i> Son hata: {rcloneStatus.lastError}
|
||
</div>
|
||
{/if}
|
||
{#if rcloneStatus.lastLog && !rcloneStatus.lastError}
|
||
<div style="margin-top: 8px; font-size: 11px; color: #666;">
|
||
<i class="fa-solid fa-circle-info"></i> Son log: {rcloneStatus.lastLog}
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
{:else if activeTab === "advanced"}
|
||
<div class="card muted">Gelişmiş ayarlar burada yer alacak.</div>
|
||
{/if}
|
||
</section>
|
||
|
||
<style>
|
||
.files {
|
||
padding: 16px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
}
|
||
|
||
.files-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.header-title {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.tabs {
|
||
display: flex;
|
||
gap: 8px;
|
||
}
|
||
|
||
.tab {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 8px 12px;
|
||
border: 1px solid var(--border, #dcdcdc);
|
||
border-radius: 8px;
|
||
background: #f7f7f7;
|
||
cursor: pointer;
|
||
color: #333;
|
||
}
|
||
|
||
.tab.active {
|
||
background: #222;
|
||
color: #fff;
|
||
border-color: #222;
|
||
}
|
||
|
||
.card {
|
||
border: 1px solid var(--border, #e0e0e0);
|
||
border-radius: 10px;
|
||
padding: 14px;
|
||
background: #fff;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
}
|
||
|
||
.card.muted {
|
||
color: #777;
|
||
}
|
||
|
||
.card-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.card-header .title {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.card-header .meta {
|
||
color: #666;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.field {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
}
|
||
|
||
.field label {
|
||
font-weight: 600;
|
||
}
|
||
|
||
.field.inline {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.field.inline.compact {
|
||
align-items: flex-end;
|
||
}
|
||
|
||
.field.inline.left-align {
|
||
flex-direction: column;
|
||
align-items: flex-start;
|
||
justify-content: flex-start;
|
||
gap: 10px;
|
||
}
|
||
|
||
.inline-field {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
}
|
||
|
||
.field select {
|
||
min-width: 180px;
|
||
max-width: 240px;
|
||
padding: 8px 10px;
|
||
border: 1px solid var(--border, #dcdcdc);
|
||
border-radius: 8px;
|
||
background: #fff;
|
||
}
|
||
|
||
.checkbox-row {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.field textarea {
|
||
min-height: 180px;
|
||
padding: 10px;
|
||
border: 1px solid var(--border, #dcdcdc);
|
||
border-radius: 8px;
|
||
font-family: monospace;
|
||
font-size: 13px;
|
||
resize: vertical;
|
||
}
|
||
|
||
.field small {
|
||
color: #666;
|
||
}
|
||
|
||
.actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
.actions.left {
|
||
justify-content: flex-start;
|
||
}
|
||
|
||
.divider {
|
||
height: 1px;
|
||
background: #eee;
|
||
margin: 6px 0;
|
||
}
|
||
|
||
.btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 8px 12px;
|
||
border: 1px solid var(--border, #dcdcdc);
|
||
border-radius: 8px;
|
||
background: #f7f7f7;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.btn.primary {
|
||
background: #222;
|
||
color: #fff;
|
||
border-color: #222;
|
||
}
|
||
|
||
.btn:disabled {
|
||
opacity: 0.5;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.alert {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 10px 12px;
|
||
border-radius: 8px;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.alert.error {
|
||
background: #ffe2e2;
|
||
color: #b30000;
|
||
}
|
||
|
||
.alert.success {
|
||
background: #e5ffe7;
|
||
color: #0f7a1f;
|
||
}
|
||
|
||
:global(code) {
|
||
background: #f0f0f0;
|
||
padding: 2px 6px;
|
||
border-radius: 4px;
|
||
font-family: monospace;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.password-field {
|
||
position: relative;
|
||
}
|
||
|
||
.conf-textarea {
|
||
width: 100%;
|
||
min-height: 180px;
|
||
resize: vertical;
|
||
font-family: "Courier New", monospace;
|
||
letter-spacing: 0.2px;
|
||
}
|
||
|
||
.conf-textarea.masked {
|
||
-webkit-text-security: disc;
|
||
text-security: disc;
|
||
}
|
||
|
||
.eye-btn {
|
||
position: absolute;
|
||
right: 20px;
|
||
top: 8px;
|
||
background: #f5f5f5;
|
||
border: 1px solid #ddd;
|
||
border-radius: 6px;
|
||
padding: 6px 8px;
|
||
cursor: pointer;
|
||
}
|
||
</style>
|