feat: align motrix-style download UI/actions and stabilize aria2 ops
This commit is contained in:
505
src/App.vue
505
src/App.vue
@@ -2,14 +2,22 @@
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window'
|
||||
import type { UnlistenFn } from '@tauri-apps/api/event'
|
||||
import { open as openDialog } from '@tauri-apps/plugin-dialog'
|
||||
import {
|
||||
addAria2Torrent,
|
||||
addAria2Uri,
|
||||
detectAria2Binary,
|
||||
getEngineStatus,
|
||||
listAria2Tasks,
|
||||
loadTorrentFile,
|
||||
pauseAllAria2,
|
||||
pauseAria2Task,
|
||||
openPathInFileManager,
|
||||
removeAria2Task,
|
||||
removeAria2TaskRecord,
|
||||
resumeAllAria2,
|
||||
resumeAria2Task,
|
||||
startEngine,
|
||||
stopEngine,
|
||||
type Aria2Task,
|
||||
type Aria2TaskSnapshot,
|
||||
type EngineStatus,
|
||||
@@ -19,6 +27,17 @@ type TaskFilter = 'all' | 'active' | 'waiting' | 'stopped'
|
||||
type AddTab = 'url' | 'torrent'
|
||||
type AppPage = 'downloads' | 'settings'
|
||||
type SettingsTab = 'basic' | 'advanced' | 'lab'
|
||||
type PersistedSettings = {
|
||||
binaryPath?: string
|
||||
rpcPort?: number
|
||||
rpcSecret?: string
|
||||
downloadDir?: string
|
||||
split?: number
|
||||
maxConcurrentDownloads?: number
|
||||
autoRefresh?: boolean
|
||||
}
|
||||
|
||||
const SETTINGS_STORAGE_KEY = 'gdown.settings.v1'
|
||||
|
||||
const binaryPath = ref('aria2c')
|
||||
const rpcPort = ref(6800)
|
||||
@@ -30,10 +49,11 @@ const maxConcurrentDownloads = ref(5)
|
||||
const loadingEngine = ref(false)
|
||||
const loadingTasks = ref(false)
|
||||
const loadingAddTask = ref(false)
|
||||
const loadingTaskAction = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const successMessage = ref('')
|
||||
const autoRefresh = ref(true)
|
||||
const filter = ref<TaskFilter>('all')
|
||||
const filter = ref<TaskFilter>('active')
|
||||
const page = ref<AppPage>('downloads')
|
||||
const settingsTab = ref<SettingsTab>('basic')
|
||||
|
||||
@@ -83,16 +103,45 @@ const tasks = ref<Aria2TaskSnapshot>({
|
||||
let refreshTimer: number | null = null
|
||||
let unlistenDragDrop: UnlistenFn | null = null
|
||||
|
||||
const totalCount = computed(() => tasks.value.active.length + tasks.value.waiting.length + tasks.value.stopped.length)
|
||||
|
||||
const filteredTasks = computed(() => {
|
||||
if (filter.value === 'active') return tasks.value.active
|
||||
if (filter.value === 'waiting') return tasks.value.waiting
|
||||
if (filter.value === 'stopped') return tasks.value.stopped
|
||||
return [...tasks.value.active, ...tasks.value.waiting, ...tasks.value.stopped]
|
||||
})
|
||||
const filteredTaskCount = computed(() => filteredTasks.value.length)
|
||||
|
||||
const runtimeLabel = computed(() => (status.value.running ? 'Running' : 'Stopped'))
|
||||
function loadSettingsFromStorage() {
|
||||
try {
|
||||
const raw = localStorage.getItem(SETTINGS_STORAGE_KEY)
|
||||
if (!raw) return
|
||||
const parsed = JSON.parse(raw) as PersistedSettings
|
||||
if (typeof parsed.binaryPath === 'string') binaryPath.value = parsed.binaryPath
|
||||
if (typeof parsed.rpcPort === 'number' && Number.isFinite(parsed.rpcPort)) rpcPort.value = parsed.rpcPort
|
||||
if (typeof parsed.rpcSecret === 'string') rpcSecret.value = parsed.rpcSecret
|
||||
if (typeof parsed.downloadDir === 'string') downloadDir.value = parsed.downloadDir
|
||||
if (typeof parsed.split === 'number' && Number.isFinite(parsed.split)) split.value = parsed.split
|
||||
if (typeof parsed.maxConcurrentDownloads === 'number' && Number.isFinite(parsed.maxConcurrentDownloads)) {
|
||||
maxConcurrentDownloads.value = parsed.maxConcurrentDownloads
|
||||
}
|
||||
if (typeof parsed.autoRefresh === 'boolean') autoRefresh.value = parsed.autoRefresh
|
||||
} catch {
|
||||
// ignore malformed settings
|
||||
}
|
||||
}
|
||||
|
||||
function saveSettingsToStorage() {
|
||||
const payload: PersistedSettings = {
|
||||
binaryPath: binaryPath.value,
|
||||
rpcPort: rpcPort.value,
|
||||
rpcSecret: rpcSecret.value,
|
||||
downloadDir: downloadDir.value,
|
||||
split: split.value,
|
||||
maxConcurrentDownloads: maxConcurrentDownloads.value,
|
||||
autoRefresh: autoRefresh.value,
|
||||
}
|
||||
localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(payload))
|
||||
}
|
||||
|
||||
function pushError(message: string) {
|
||||
successMessage.value = ''
|
||||
@@ -111,11 +160,6 @@ function rpcConfig() {
|
||||
}
|
||||
}
|
||||
|
||||
function formatDateTime(epochSec: number | null): string {
|
||||
if (!epochSec) return '-'
|
||||
return new Date(epochSec * 1000).toLocaleString()
|
||||
}
|
||||
|
||||
function formatBytes(value: string): string {
|
||||
const n = Number(value)
|
||||
if (!Number.isFinite(n)) return '-'
|
||||
@@ -187,39 +231,164 @@ function updateRefreshTimer() {
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
async function onStartEngine() {
|
||||
async function autoStartEngine(silent = true) {
|
||||
loadingEngine.value = true
|
||||
try {
|
||||
let resolvedBinaryPath = binaryPath.value.trim() || undefined
|
||||
const probe = await detectAria2Binary(resolvedBinaryPath).catch(() => null)
|
||||
if (probe?.found && probe.binaryPath) {
|
||||
binaryPath.value = probe.binaryPath
|
||||
resolvedBinaryPath = probe.binaryPath
|
||||
}
|
||||
|
||||
status.value = await startEngine({
|
||||
binaryPath: binaryPath.value.trim() || undefined,
|
||||
binaryPath: resolvedBinaryPath,
|
||||
rpcListenPort: rpcPort.value,
|
||||
rpcSecret: rpcSecret.value.trim() || undefined,
|
||||
downloadDir: downloadDir.value.trim() || undefined,
|
||||
maxConcurrentDownloads: maxConcurrentDownloads.value,
|
||||
split: split.value,
|
||||
})
|
||||
pushSuccess('aria2 engine started.')
|
||||
saveSettingsToStorage()
|
||||
if (!silent) pushSuccess('aria2 engine started.')
|
||||
await refreshTasks()
|
||||
return true
|
||||
} catch (error) {
|
||||
pushError(String(error))
|
||||
return false
|
||||
} finally {
|
||||
loadingEngine.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onStopEngine() {
|
||||
loadingEngine.value = true
|
||||
async function ensureEngineRunning() {
|
||||
if (status.value.running) return true
|
||||
await refreshEngineStatus()
|
||||
if (status.value.running) return true
|
||||
return autoStartEngine(false)
|
||||
}
|
||||
|
||||
async function onPauseAllTasks() {
|
||||
const ready = await ensureEngineRunning()
|
||||
if (!ready) return
|
||||
loadingTaskAction.value = true
|
||||
try {
|
||||
status.value = await stopEngine()
|
||||
tasks.value = { active: [], waiting: [], stopped: [] }
|
||||
pushSuccess('aria2 engine stopped.')
|
||||
await pauseAllAria2(rpcConfig())
|
||||
pushSuccess('모든 작업을 일시정지했습니다.')
|
||||
await refreshTasks()
|
||||
} catch (error) {
|
||||
pushError(String(error))
|
||||
} finally {
|
||||
loadingEngine.value = false
|
||||
loadingTaskAction.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onResumeAllTasks() {
|
||||
const ready = await ensureEngineRunning()
|
||||
if (!ready) return
|
||||
loadingTaskAction.value = true
|
||||
try {
|
||||
await resumeAllAria2(rpcConfig())
|
||||
pushSuccess('모든 작업을 재개했습니다.')
|
||||
await refreshTasks()
|
||||
} catch (error) {
|
||||
pushError(String(error))
|
||||
} finally {
|
||||
loadingTaskAction.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onTaskAction(task: Aria2Task, action: 'pause' | 'resume' | 'remove' | 'purge') {
|
||||
const ready = await ensureEngineRunning()
|
||||
if (!ready) return
|
||||
loadingTaskAction.value = true
|
||||
try {
|
||||
const payload = { rpc: rpcConfig(), gid: task.gid }
|
||||
if (action === 'pause') {
|
||||
await pauseAria2Task(payload)
|
||||
} else if (action === 'resume') {
|
||||
await resumeAria2Task(payload)
|
||||
} else if (action === 'remove') {
|
||||
await removeAria2Task(payload)
|
||||
} else {
|
||||
await removeAria2TaskRecord(payload)
|
||||
}
|
||||
await refreshTasks()
|
||||
} catch (error) {
|
||||
pushError(String(error))
|
||||
} finally {
|
||||
loadingTaskAction.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onRemoveFilteredTasks() {
|
||||
const ready = await ensureEngineRunning()
|
||||
if (!ready) return
|
||||
const targetTasks = [...filteredTasks.value]
|
||||
if (targetTasks.length === 0) return
|
||||
|
||||
loadingTaskAction.value = true
|
||||
try {
|
||||
for (const task of targetTasks) {
|
||||
const payload = { rpc: rpcConfig(), gid: task.gid }
|
||||
if (task.status === 'stopped') {
|
||||
await removeAria2TaskRecord(payload)
|
||||
} else {
|
||||
await removeAria2Task(payload)
|
||||
}
|
||||
}
|
||||
if (filter.value === 'stopped') {
|
||||
pushSuccess(`${targetTasks.length}개 기록을 정리했습니다.`)
|
||||
} else {
|
||||
pushSuccess(`${targetTasks.length}개 작업을 삭제했습니다.`)
|
||||
}
|
||||
await refreshTasks()
|
||||
} catch (error) {
|
||||
pushError(String(error))
|
||||
} finally {
|
||||
loadingTaskAction.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function taskPrimaryAction(task: Aria2Task): 'pause' | 'resume' {
|
||||
return task.status === 'active' ? 'pause' : 'resume'
|
||||
}
|
||||
|
||||
function taskPrimaryIcon(task: Aria2Task): string {
|
||||
return task.status === 'active' ? '□' : '▶'
|
||||
}
|
||||
|
||||
function taskPrimaryTitle(task: Aria2Task): string {
|
||||
return task.status === 'active' ? '정지' : '재개'
|
||||
}
|
||||
|
||||
async function onOpenTaskFolder(task: Aria2Task) {
|
||||
const dir = task.dir?.trim()
|
||||
if (!dir) {
|
||||
pushError('작업 폴더 경로가 없습니다.')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await openPathInFileManager(dir)
|
||||
} catch (error) {
|
||||
pushError(String(error))
|
||||
}
|
||||
}
|
||||
|
||||
async function onCopyTaskLink(task: Aria2Task) {
|
||||
const text = task.uri?.trim() || task.gid
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
pushSuccess('작업 링크를 클립보드에 복사했습니다.')
|
||||
} catch (error) {
|
||||
pushError(`클립보드 복사 실패: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
function onShowTaskInfo(task: Aria2Task) {
|
||||
pushSuccess(`gid=${task.gid} / status=${task.status} / dir=${task.dir || '-'}`)
|
||||
}
|
||||
|
||||
function openAddModal() {
|
||||
showAddModal.value = true
|
||||
addTab.value = 'url'
|
||||
@@ -234,9 +403,28 @@ function openSettingsPage() {
|
||||
}
|
||||
|
||||
function saveSettings() {
|
||||
saveSettingsToStorage()
|
||||
pushSuccess('설정이 저장되었습니다.')
|
||||
}
|
||||
|
||||
async function pickDownloadFolder() {
|
||||
try {
|
||||
const selected = await openDialog({
|
||||
directory: true,
|
||||
multiple: false,
|
||||
defaultPath: downloadDir.value || undefined,
|
||||
title: '기본 다운로드 폴더 선택',
|
||||
})
|
||||
if (typeof selected === 'string' && selected.trim()) {
|
||||
downloadDir.value = selected
|
||||
saveSettingsToStorage()
|
||||
pushSuccess('기본 폴더를 변경했습니다.')
|
||||
}
|
||||
} catch (error) {
|
||||
pushError(String(error))
|
||||
}
|
||||
}
|
||||
|
||||
function closeAddModal() {
|
||||
showAddModal.value = false
|
||||
modalDropActive.value = false
|
||||
@@ -388,26 +576,32 @@ async function onWindowDrop(event: DragEvent) {
|
||||
}
|
||||
|
||||
async function onSubmitAddTask() {
|
||||
if (!status.value.running) {
|
||||
pushError('먼저 엔진을 시작하세요.')
|
||||
return
|
||||
}
|
||||
const ready = await ensureEngineRunning()
|
||||
if (!ready) return
|
||||
|
||||
loadingAddTask.value = true
|
||||
try {
|
||||
if (addTab.value === 'url') {
|
||||
if (!addUrl.value.trim()) {
|
||||
const uris = addUrl.value
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
if (uris.length === 0) {
|
||||
pushError('URL을 입력하세요.')
|
||||
return
|
||||
}
|
||||
const gid = await addAria2Uri({
|
||||
rpc: rpcConfig(),
|
||||
uri: addUrl.value.trim(),
|
||||
out: addOut.value.trim() || undefined,
|
||||
dir: downloadDir.value.trim() || undefined,
|
||||
split: addSplit.value,
|
||||
})
|
||||
pushSuccess(`작업이 추가되었습니다. gid=${gid}`)
|
||||
const gids: string[] = []
|
||||
for (const uri of uris) {
|
||||
const gid = await addAria2Uri({
|
||||
rpc: rpcConfig(),
|
||||
uri,
|
||||
out: uris.length === 1 ? (addOut.value.trim() || undefined) : undefined,
|
||||
dir: downloadDir.value.trim() || undefined,
|
||||
split: addSplit.value,
|
||||
})
|
||||
gids.push(gid)
|
||||
}
|
||||
pushSuccess(`${gids.length}개 작업이 추가되었습니다.`)
|
||||
} else {
|
||||
if (!torrentBase64.value.trim()) {
|
||||
pushError('.torrent 파일을 선택하세요.')
|
||||
@@ -439,7 +633,11 @@ async function onSubmitAddTask() {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
loadSettingsFromStorage()
|
||||
await refreshEngineStatus()
|
||||
if (!status.value.running) {
|
||||
await autoStartEngine(true)
|
||||
}
|
||||
await refreshTasks()
|
||||
updateRefreshTimer()
|
||||
|
||||
@@ -510,116 +708,85 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section v-if="page === 'downloads'" class="content">
|
||||
<header class="toolbar">
|
||||
<div class="toolbar-left">
|
||||
<h1>다운로드 중</h1>
|
||||
<span class="count">{{ totalCount }} tasks</span>
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<label class="switcher">
|
||||
<input v-model="autoRefresh" type="checkbox" @change="updateRefreshTimer" />
|
||||
<span>Auto refresh</span>
|
||||
</label>
|
||||
<button class="ghost" :disabled="loadingTasks" @click="refreshTasks()">새로고침</button>
|
||||
</div>
|
||||
</header>
|
||||
<section v-if="page === 'downloads'" class="content downloads-view">
|
||||
<aside class="downloads-filter card">
|
||||
<h2>작업</h2>
|
||||
<button :class="{ active: filter === 'active' }" @click="filter = 'active'">▶ 다운로드 중</button>
|
||||
<button :class="{ active: filter === 'waiting' }" @click="filter = 'waiting'">⏸ 대기 중</button>
|
||||
<button :class="{ active: filter === 'stopped' }" @click="filter = 'stopped'">■ 중단됨</button>
|
||||
</aside>
|
||||
|
||||
<section v-if="errorMessage" class="notice error">{{ errorMessage }}</section>
|
||||
<section v-if="successMessage" class="notice success">{{ successMessage }}</section>
|
||||
<section class="downloads-main">
|
||||
<header class="toolbar">
|
||||
<div class="toolbar-left">
|
||||
<h1>{{ filter === 'active' ? '다운로드 중' : filter === 'waiting' ? '대기 중' : '중단됨' }}</h1>
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<button
|
||||
class="icon-tool ghost"
|
||||
:disabled="loadingTaskAction || filteredTaskCount === 0"
|
||||
title="목록 삭제"
|
||||
@click="onRemoveFilteredTasks"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
<button class="icon-tool ghost" :disabled="loadingTasks" title="새로고침" @click="refreshTasks()">↻</button>
|
||||
<button class="icon-tool ghost" :disabled="loadingTaskAction || !status.running" title="전체 재개" @click="onResumeAllTasks">▶</button>
|
||||
<button class="icon-tool ghost" :disabled="loadingTaskAction || !status.running" title="전체 일시정지" @click="onPauseAllTasks">⏸</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section v-if="errorMessage" class="notice error">{{ errorMessage }}</section>
|
||||
<section v-if="successMessage" class="notice success">{{ successMessage }}</section>
|
||||
|
||||
<section class="main-grid">
|
||||
<article class="task-pane card">
|
||||
<div class="tabs">
|
||||
<button :class="{ active: filter === 'all' }" @click="filter = 'all'">전체</button>
|
||||
<button :class="{ active: filter === 'active' }" @click="filter = 'active'">
|
||||
다운로드 중 ({{ tasks.active.length }})
|
||||
</button>
|
||||
<button :class="{ active: filter === 'waiting' }" @click="filter = 'waiting'">
|
||||
대기 중 ({{ tasks.waiting.length }})
|
||||
</button>
|
||||
<button :class="{ active: filter === 'stopped' }" @click="filter = 'stopped'">
|
||||
중단됨 ({{ tasks.stopped.length }})
|
||||
</button>
|
||||
<div class="task-card-list">
|
||||
<article v-for="task in filteredTasks" :key="task.gid" class="task-card">
|
||||
<div class="task-card-head">
|
||||
<div class="file-cell">
|
||||
<strong>{{ task.fileName || '-' }}</strong>
|
||||
</div>
|
||||
<div class="task-actions-pill">
|
||||
<button
|
||||
class="icon-pill ghost"
|
||||
:disabled="loadingTaskAction || task.status === 'stopped'"
|
||||
:title="taskPrimaryTitle(task)"
|
||||
@click="onTaskAction(task, taskPrimaryAction(task))"
|
||||
>
|
||||
{{ taskPrimaryIcon(task) }}
|
||||
</button>
|
||||
<button
|
||||
class="icon-pill ghost"
|
||||
:disabled="loadingTaskAction"
|
||||
title="삭제"
|
||||
@click="onTaskAction(task, task.status === 'stopped' ? 'purge' : 'remove')"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
<button class="icon-pill ghost" title="폴더 열기" @click="onOpenTaskFolder(task)">📁</button>
|
||||
<button class="icon-pill ghost" :title="task.uri ? '링크 복사' : 'GID 복사'" @click="onCopyTaskLink(task)">
|
||||
🔗
|
||||
</button>
|
||||
<button class="icon-pill ghost" title="정보" @click="onShowTaskInfo(task)">ⓘ</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="progress-row">
|
||||
<div class="bar">
|
||||
<span :style="{ width: `${progress(task).toFixed(1)}%` }" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="task-meta-row">
|
||||
<span>{{ formatBytes(task.completedLength) }} / {{ formatBytes(task.totalLength) }}</span>
|
||||
<span class="task-meta-right">
|
||||
<span>{{ formatSpeed(task.downloadSpeed) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div v-if="filteredTasks.length === 0" class="empty">현재 다운로드 없음</div>
|
||||
</div>
|
||||
|
||||
<div class="task-table-wrap">
|
||||
<table class="task-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>작업</th>
|
||||
<th>상태</th>
|
||||
<th>진행률</th>
|
||||
<th>속도</th>
|
||||
<th>전체 크기</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="task in filteredTasks" :key="task.gid">
|
||||
<td>
|
||||
<div class="file-cell">
|
||||
<strong>{{ task.fileName || '-' }}</strong>
|
||||
<small>{{ task.gid }}</small>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="status-tag" :class="task.status">{{ task.status }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="progress-row">
|
||||
<div class="bar">
|
||||
<span :style="{ width: `${progress(task).toFixed(1)}%` }" />
|
||||
</div>
|
||||
<small>{{ progress(task).toFixed(1) }}%</small>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ formatSpeed(task.downloadSpeed) }}</td>
|
||||
<td>{{ formatBytes(task.totalLength) }}</td>
|
||||
</tr>
|
||||
<tr v-if="filteredTasks.length === 0">
|
||||
<td colspan="5" class="empty">현재 다운로드 없음</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="right-pane card">
|
||||
<h2>Engine</h2>
|
||||
<p class="runtime" :class="status.running ? 'ok' : 'off'">{{ runtimeLabel }}</p>
|
||||
<p><strong>PID:</strong> {{ status.pid ?? '-' }}</p>
|
||||
<p><strong>Started:</strong> {{ formatDateTime(status.startedAt) }}</p>
|
||||
|
||||
<div class="engine-actions">
|
||||
<button :disabled="loadingEngine || status.running" @click="onStartEngine">Start</button>
|
||||
<button class="ghost" :disabled="loadingEngine || !status.running" @click="onStopEngine">Stop</button>
|
||||
</div>
|
||||
|
||||
<h3>연결 설정</h3>
|
||||
<label>
|
||||
<span>Binary Path</span>
|
||||
<input v-model="binaryPath" type="text" placeholder="aria2c" />
|
||||
</label>
|
||||
<label>
|
||||
<span>RPC Port</span>
|
||||
<input v-model.number="rpcPort" type="number" min="1" max="65535" />
|
||||
</label>
|
||||
<label>
|
||||
<span>RPC Secret</span>
|
||||
<input v-model="rpcSecret" type="text" placeholder="optional" />
|
||||
</label>
|
||||
<label>
|
||||
<span>폴더</span>
|
||||
<input v-model="downloadDir" type="text" placeholder="/path/to/downloads" />
|
||||
</label>
|
||||
<label>
|
||||
<span>Split</span>
|
||||
<input v-model.number="split" type="number" min="1" max="64" />
|
||||
</label>
|
||||
<label>
|
||||
<span>Max Concurrent</span>
|
||||
<input v-model.number="maxConcurrentDownloads" type="number" min="1" max="20" />
|
||||
</label>
|
||||
</article>
|
||||
</section>
|
||||
</section>
|
||||
@@ -627,18 +794,32 @@ onUnmounted(() => {
|
||||
<section v-else class="content settings-content">
|
||||
<section class="settings-layout">
|
||||
<aside class="settings-nav card">
|
||||
<h2>설정</h2>
|
||||
<button :class="{ active: settingsTab === 'basic' }" @click="settingsTab = 'basic'">기본</button>
|
||||
<button :class="{ active: settingsTab === 'advanced' }" @click="settingsTab = 'advanced'">고급</button>
|
||||
<button :class="{ active: settingsTab === 'lab' }" @click="settingsTab = 'lab'">실험실</button>
|
||||
<h2>Settings</h2>
|
||||
<p class="settings-nav-sub">다운로드 엔진과 앱 동작을 조정합니다.</p>
|
||||
<button :class="{ active: settingsTab === 'basic' }" @click="settingsTab = 'basic'">
|
||||
<span>기본</span>
|
||||
<small>일반 사용 옵션</small>
|
||||
</button>
|
||||
<button :class="{ active: settingsTab === 'advanced' }" @click="settingsTab = 'advanced'">
|
||||
<span>고급</span>
|
||||
<small>네트워크/RPC</small>
|
||||
</button>
|
||||
<button :class="{ active: settingsTab === 'lab' }" @click="settingsTab = 'lab'">
|
||||
<span>실험실</span>
|
||||
<small>미리보기 기능</small>
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<article class="settings-panel card">
|
||||
<h1>기본</h1>
|
||||
<header class="settings-header">
|
||||
<h1>환경 설정</h1>
|
||||
<p>Motrix 스타일에 맞춘 엔진/다운로드 환경 구성</p>
|
||||
</header>
|
||||
|
||||
<div v-if="settingsTab === 'basic'" class="settings-form">
|
||||
<section class="settings-group">
|
||||
<label>테마</label>
|
||||
<section class="settings-group settings-card-section">
|
||||
<div class="group-title">UI & 표시</div>
|
||||
<label class="field-label">테마</label>
|
||||
<div class="theme-row">
|
||||
<button class="theme-tile" :class="{ active: settingTheme === 'auto' }" @click="settingTheme = 'auto'">자동</button>
|
||||
<button class="theme-tile" :class="{ active: settingTheme === 'light' }" @click="settingTheme = 'light'">밝게</button>
|
||||
@@ -646,13 +827,15 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-group">
|
||||
<section class="settings-group settings-card-section">
|
||||
<div class="group-title">시작/표시 동작</div>
|
||||
<label class="check-row"><input v-model="settingHideWindowOnStartup" type="checkbox" /> 창 자동으로 숨기기</label>
|
||||
<label class="check-row"><input v-model="settingTraySpeed" type="checkbox" /> 메뉴 막대 트레이에 실시간 속도 표시</label>
|
||||
<label class="check-row"><input v-model="settingShowDockProgress" type="checkbox" /> 다운로드 진행률 막대 보기</label>
|
||||
</section>
|
||||
|
||||
<section class="settings-group two-col">
|
||||
<section class="settings-group settings-card-section two-col">
|
||||
<div class="group-title full-row">기본 파라미터</div>
|
||||
<label>언어
|
||||
<select v-model="settingLanguage">
|
||||
<option>한국어</option>
|
||||
@@ -664,19 +847,26 @@ onUnmounted(() => {
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="settings-group">
|
||||
<section class="settings-group settings-card-section">
|
||||
<div class="group-title">세션</div>
|
||||
<label class="check-row"><input v-model="settingRunOnLogin" type="checkbox" /> 로그인 시 실행</label>
|
||||
<label class="check-row"><input v-model="settingRememberWindow" type="checkbox" /> 창 크기 및 위치 기억</label>
|
||||
<label class="check-row"><input v-model="settingAutoResume" type="checkbox" /> 완료되지 않은 작업 자동 재개</label>
|
||||
</section>
|
||||
|
||||
<section class="settings-group">
|
||||
<label>기본 폴더
|
||||
<input v-model="downloadDir" type="text" />
|
||||
</label>
|
||||
<section class="settings-group settings-card-section">
|
||||
<div class="group-title">다운로드 디렉터리</div>
|
||||
<label class="field-label">기본 폴더</label>
|
||||
<div class="folder-picker-row">
|
||||
<input v-model="downloadDir" type="text" placeholder="/Users/.../Downloads" />
|
||||
<button type="button" class="ghost folder-browse-btn" @click.stop.prevent="pickDownloadFolder">
|
||||
폴더 선택
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-group two-col">
|
||||
<section class="settings-group settings-card-section two-col">
|
||||
<div class="group-title full-row">대역폭 제한</div>
|
||||
<label>업로드 제한 (KB/s)
|
||||
<input v-model.number="settingUploadLimit" type="number" min="0" />
|
||||
</label>
|
||||
@@ -685,8 +875,8 @@ onUnmounted(() => {
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="settings-group">
|
||||
<h3>BitTorrent</h3>
|
||||
<section class="settings-group settings-card-section">
|
||||
<div class="group-title">BitTorrent</div>
|
||||
<label class="check-row"><input v-model="settingMagnetAsTorrent" type="checkbox" /> 마그넷 링크를 토렌트 파일로 저장</label>
|
||||
<label class="check-row"><input v-model="settingAutoDownloadTorrentMeta" type="checkbox" /> 마그넷 및 토렌트 내용 자동 다운로드</label>
|
||||
<label class="check-row"><input v-model="settingBtForceEncryption" type="checkbox" /> BT 강제 암호화</label>
|
||||
@@ -702,8 +892,9 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="settings-placeholder">
|
||||
<p>{{ settingsTab === 'advanced' ? '고급 설정 화면(포팅 진행 중)' : '실험실 설정 화면(포팅 진행 중)' }}</p>
|
||||
<div v-else class="settings-placeholder card">
|
||||
<h3>{{ settingsTab === 'advanced' ? '고급 설정' : '실험실 설정' }}</h3>
|
||||
<p>{{ settingsTab === 'advanced' ? 'RPC, 프록시, 트래커 등 고급 항목을 Motrix 구조에 맞춰 포팅 중입니다.' : '실험 기능과 실험적 네트워크 옵션을 단계적으로 이식합니다.' }}</p>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
@@ -8,6 +8,13 @@ export interface EngineStatus {
|
||||
startedAt: number | null
|
||||
}
|
||||
|
||||
export interface BinaryProbeResult {
|
||||
found: boolean
|
||||
binaryPath: string | null
|
||||
source: string | null
|
||||
candidates: string[]
|
||||
}
|
||||
|
||||
export interface EngineStartPayload {
|
||||
binaryPath?: string
|
||||
rpcListenPort?: number
|
||||
@@ -30,6 +37,7 @@ export interface Aria2Task {
|
||||
downloadSpeed: string
|
||||
dir: string
|
||||
fileName: string
|
||||
uri: string
|
||||
}
|
||||
|
||||
export interface Aria2TaskSnapshot {
|
||||
@@ -54,6 +62,11 @@ export interface AddTorrentPayload {
|
||||
split?: number
|
||||
}
|
||||
|
||||
export interface TaskCommandPayload {
|
||||
rpc: Aria2RpcConfig
|
||||
gid: string
|
||||
}
|
||||
|
||||
export interface TorrentFilePayload {
|
||||
name: string
|
||||
base64: string
|
||||
@@ -72,6 +85,10 @@ export async function stopEngine(): Promise<EngineStatus> {
|
||||
return invoke<EngineStatus>('engine_stop')
|
||||
}
|
||||
|
||||
export async function detectAria2Binary(binaryPath?: string): Promise<BinaryProbeResult> {
|
||||
return invoke<BinaryProbeResult>('detect_aria2_binary', { binaryPath })
|
||||
}
|
||||
|
||||
export async function listAria2Tasks(config: Aria2RpcConfig): Promise<Aria2TaskSnapshot> {
|
||||
return invoke<Aria2TaskSnapshot>('aria2_list_tasks', { config })
|
||||
}
|
||||
@@ -87,3 +104,31 @@ export async function addAria2Torrent(payload: AddTorrentPayload): Promise<strin
|
||||
export async function loadTorrentFile(path: string): Promise<TorrentFilePayload> {
|
||||
return invoke<TorrentFilePayload>('load_torrent_file', { path })
|
||||
}
|
||||
|
||||
export async function pauseAria2Task(payload: TaskCommandPayload): Promise<string> {
|
||||
return invoke<string>('aria2_pause_task', { request: payload })
|
||||
}
|
||||
|
||||
export async function resumeAria2Task(payload: TaskCommandPayload): Promise<string> {
|
||||
return invoke<string>('aria2_resume_task', { request: payload })
|
||||
}
|
||||
|
||||
export async function removeAria2Task(payload: TaskCommandPayload): Promise<string> {
|
||||
return invoke<string>('aria2_remove_task', { request: payload })
|
||||
}
|
||||
|
||||
export async function removeAria2TaskRecord(payload: TaskCommandPayload): Promise<string> {
|
||||
return invoke<string>('aria2_remove_task_record', { request: payload })
|
||||
}
|
||||
|
||||
export async function pauseAllAria2(config: Aria2RpcConfig): Promise<string> {
|
||||
return invoke<string>('aria2_pause_all', { config })
|
||||
}
|
||||
|
||||
export async function resumeAllAria2(config: Aria2RpcConfig): Promise<string> {
|
||||
return invoke<string>('aria2_resume_all', { config })
|
||||
}
|
||||
|
||||
export async function openPathInFileManager(path: string): Promise<void> {
|
||||
return invoke<void>('open_path_in_file_manager', { path })
|
||||
}
|
||||
|
||||
497
src/style.css
497
src/style.css
@@ -120,6 +120,121 @@ body {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.downloads-view {
|
||||
background: #f3f3f5;
|
||||
display: grid;
|
||||
grid-template-columns: 260px minmax(0, 1fr);
|
||||
gap: 22px;
|
||||
padding: 18px 20px;
|
||||
}
|
||||
|
||||
.downloads-view .card {
|
||||
background: #f3f3f5;
|
||||
border: 1px solid #dfdfe4;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.downloads-filter {
|
||||
padding: 16px;
|
||||
align-self: start;
|
||||
min-height: calc(100vh - 40px);
|
||||
}
|
||||
|
||||
.downloads-filter h2 {
|
||||
margin: 2px 0 14px;
|
||||
color: #2a2a2e;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.downloads-filter button {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: #3f424a;
|
||||
font-size: 0.98rem;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.downloads-filter button.active {
|
||||
background: #e9e9ee;
|
||||
color: #5b5fe9;
|
||||
}
|
||||
|
||||
.downloads-main .toolbar {
|
||||
border-bottom: 1px solid #d8d8de;
|
||||
padding-bottom: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.downloads-main h1 {
|
||||
color: #2f3136;
|
||||
font-size: 2.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.downloads-main .icon-tool.ghost {
|
||||
background: transparent;
|
||||
color: #5f626b;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.downloads-main .icon-tool.ghost:hover {
|
||||
background: #ebecf1;
|
||||
color: #383b42;
|
||||
}
|
||||
|
||||
.downloads-main .task-pane {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.downloads-main .task-card {
|
||||
border: 1px solid #ceced6;
|
||||
border-radius: 10px;
|
||||
background: #f9f9fb;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.downloads-main .file-cell strong {
|
||||
color: #37393f;
|
||||
font-size: 1.08rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.downloads-main .task-actions-pill {
|
||||
background: #f5f5f8;
|
||||
border: 1px solid #dfdfe6;
|
||||
}
|
||||
|
||||
.downloads-main .icon-pill {
|
||||
color: #727782;
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.downloads-main .icon-pill:hover {
|
||||
background: #e7e8ee;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.downloads-main .bar {
|
||||
height: 8px;
|
||||
background: #e0e1ea;
|
||||
}
|
||||
|
||||
.downloads-main .bar span {
|
||||
background: #6268ee;
|
||||
}
|
||||
|
||||
.downloads-main .task-meta-row {
|
||||
color: #757b87;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -135,6 +250,17 @@ h1 { margin: 0; font-size: 1.16rem; font-weight: 600; }
|
||||
|
||||
.toolbar-right { display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
.icon-tool {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 999px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
font-size: 0.96rem;
|
||||
}
|
||||
|
||||
.switcher {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -170,7 +296,7 @@ h1 { margin: 0; font-size: 1.16rem; font-weight: 600; }
|
||||
|
||||
.main-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 280px;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
@@ -193,27 +319,24 @@ h1 { margin: 0; font-size: 1.16rem; font-weight: 600; }
|
||||
color: #e0e3ff;
|
||||
}
|
||||
|
||||
.task-table-wrap { border: 1px solid var(--line); border-radius: 8px; overflow: hidden; }
|
||||
|
||||
.task-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.84rem;
|
||||
.task-card-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.task-table th,
|
||||
.task-table td {
|
||||
text-align: left;
|
||||
padding: 9px;
|
||||
border-bottom: 1px solid #2b2f37;
|
||||
.task-card {
|
||||
border: 1px solid #363b45;
|
||||
border-radius: 10px;
|
||||
background: #20242c;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.task-table th {
|
||||
font-size: 0.72rem;
|
||||
color: #8f9ab0;
|
||||
background: #1a1c22;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
.task-card-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.file-cell { display: flex; flex-direction: column; gap: 2px; }
|
||||
@@ -240,8 +363,8 @@ h1 { margin: 0; font-size: 1.16rem; font-weight: 600; }
|
||||
|
||||
.progress-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 8px;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@@ -259,35 +382,45 @@ h1 { margin: 0; font-size: 1.16rem; font-weight: 600; }
|
||||
background: linear-gradient(90deg, #5b86ff, #5860f0);
|
||||
}
|
||||
|
||||
.progress-row small { color: #a8b2c4; font-size: 0.72rem; }
|
||||
|
||||
.empty { text-align: center; color: var(--text-sub); }
|
||||
|
||||
.right-pane {
|
||||
padding: 10px;
|
||||
.task-actions-pill {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
gap: 6px;
|
||||
justify-content: flex-start;
|
||||
background: #262b33;
|
||||
border: 1px solid #3a404c;
|
||||
border-radius: 999px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.right-pane h2 { margin: 0; font-size: 0.98rem; }
|
||||
.right-pane h3 { margin: 6px 0 2px; font-size: 0.86rem; }
|
||||
|
||||
.runtime { margin: 0; font-weight: 700; }
|
||||
.runtime.ok { color: var(--success); }
|
||||
.runtime.off { color: #949db0; }
|
||||
|
||||
.right-pane p { margin: 0; color: #9aa3b8; font-size: 0.8rem; }
|
||||
|
||||
.right-pane label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
font-size: 0.75rem;
|
||||
color: #a3acbe;
|
||||
.icon-pill {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 999px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.task-meta-row {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
color: #a5afc1;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.task-meta-right {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.right-pane input,
|
||||
.add-modal input,
|
||||
.add-modal textarea {
|
||||
height: 33px;
|
||||
@@ -305,7 +438,6 @@ h1 { margin: 0; font-size: 1.16rem; font-weight: 600; }
|
||||
min-height: 92px;
|
||||
}
|
||||
|
||||
.right-pane input:focus,
|
||||
.add-modal input:focus,
|
||||
.add-modal textarea:focus {
|
||||
outline: none;
|
||||
@@ -338,11 +470,19 @@ button.ghost:hover {
|
||||
}
|
||||
|
||||
button.small { padding: 4px 8px; font-size: 0.74rem; }
|
||||
button.tiny { padding: 3px 7px; font-size: 0.72rem; }
|
||||
button.ghost.danger {
|
||||
color: #ffbec8;
|
||||
border-color: #66414a;
|
||||
background: #4b2e35;
|
||||
}
|
||||
button.ghost.danger:hover {
|
||||
background: #5a3640;
|
||||
border-color: #7a4a55;
|
||||
}
|
||||
|
||||
button:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
|
||||
.engine-actions { display: flex; gap: 7px; margin: 4px 0 6px; }
|
||||
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -487,11 +627,278 @@ button:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
padding: 16px 18px;
|
||||
}
|
||||
|
||||
.settings-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 208px minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.settings-nav {
|
||||
padding: 12px;
|
||||
position: sticky;
|
||||
top: 8px;
|
||||
}
|
||||
|
||||
.settings-nav h2 {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.settings-nav-sub {
|
||||
margin: 8px 0 14px;
|
||||
color: #97a2b9;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.settings-nav button {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 9px;
|
||||
margin-bottom: 8px;
|
||||
padding: 9px 10px;
|
||||
background: #23262d;
|
||||
color: #c9d1e3;
|
||||
}
|
||||
|
||||
.settings-nav button span {
|
||||
display: block;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.settings-nav button small {
|
||||
display: block;
|
||||
margin-top: 1px;
|
||||
color: #919db4;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.settings-nav button.active {
|
||||
border-color: #575ced;
|
||||
background: #2f345e;
|
||||
color: #eef1ff;
|
||||
}
|
||||
|
||||
.settings-nav button.active small {
|
||||
color: #c9d2ff;
|
||||
}
|
||||
|
||||
.settings-panel {
|
||||
padding: 18px 20px;
|
||||
min-height: 640px;
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.settings-header h1 {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.settings-header p {
|
||||
margin: 6px 0 0;
|
||||
color: #98a2b8;
|
||||
font-size: 0.81rem;
|
||||
}
|
||||
|
||||
.settings-form {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
max-width: 760px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.settings-group {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.settings-card-section {
|
||||
border: 1px solid #3a404c;
|
||||
border-radius: 10px;
|
||||
padding: 14px;
|
||||
background: #252a33;
|
||||
}
|
||||
|
||||
.group-title {
|
||||
color: #e5e9f7;
|
||||
font-size: 0.84rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.group-title.full-row {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
color: #9aa6bf;
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.theme-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.theme-tile {
|
||||
background: #2d313a;
|
||||
border: 1px solid #464d5b;
|
||||
color: #cdd4e6;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.theme-tile.active {
|
||||
background: #3a3f73;
|
||||
border-color: #646dff;
|
||||
color: #f0f3ff;
|
||||
}
|
||||
|
||||
.settings-group label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
color: #b1b9cb;
|
||||
font-size: 0.79rem;
|
||||
}
|
||||
|
||||
.settings-group input,
|
||||
.settings-group select {
|
||||
height: 33px;
|
||||
border: 1px solid #434955;
|
||||
border-radius: 4px;
|
||||
padding: 7px 10px;
|
||||
font-size: 0.81rem;
|
||||
color: var(--text-main);
|
||||
background: #1b1f27;
|
||||
}
|
||||
|
||||
.settings-group input:focus,
|
||||
.settings-group select:focus {
|
||||
outline: none;
|
||||
border-color: #656cf5;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.settings-group select {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
background-image:
|
||||
linear-gradient(45deg, transparent 50%, #8e97aa 50%),
|
||||
linear-gradient(135deg, #8e97aa 50%, transparent 50%);
|
||||
background-position:
|
||||
calc(100% - 14px) calc(50% - 2px),
|
||||
calc(100% - 9px) calc(50% - 2px);
|
||||
background-size: 5px 5px, 5px 5px;
|
||||
background-repeat: no-repeat;
|
||||
padding-right: 28px;
|
||||
}
|
||||
|
||||
.settings-group.two-col {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.check-row {
|
||||
display: flex !important;
|
||||
flex-direction: row !important;
|
||||
align-items: center;
|
||||
gap: 6px !important;
|
||||
color: #c1c8d9 !important;
|
||||
}
|
||||
|
||||
.check-row input {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.settings-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid #373d49;
|
||||
}
|
||||
|
||||
.folder-picker-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.folder-browse-btn {
|
||||
white-space: nowrap;
|
||||
padding: 7px 12px;
|
||||
min-height: 33px;
|
||||
}
|
||||
|
||||
.settings-placeholder {
|
||||
padding: 20px;
|
||||
background: #242831;
|
||||
border: 1px solid #3a404c;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.settings-placeholder h3 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 0.96rem;
|
||||
}
|
||||
|
||||
.settings-placeholder p {
|
||||
margin: 0;
|
||||
color: #9aa6bf;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.main-grid { grid-template-columns: 1fr; }
|
||||
.settings-layout { grid-template-columns: 1fr; }
|
||||
.settings-nav {
|
||||
position: static;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.settings-nav h2,
|
||||
.settings-nav-sub {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.settings-nav button {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.downloads-view {
|
||||
grid-template-columns: 1fr;
|
||||
min-height: auto;
|
||||
}
|
||||
.downloads-filter {
|
||||
min-height: auto;
|
||||
}
|
||||
.app-shell { grid-template-columns: 1fr; }
|
||||
.sidebar { display: none; }
|
||||
.settings-group.two-col {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.theme-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user