Initial commit
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte'
|
||||
import Footer from './components/Footer.svelte'
|
||||
import AppSidebar from './components/AppSidebar.svelte'
|
||||
import * as Sidebar from './lib/components/ui/sidebar'
|
||||
import { Button } from './lib/components/ui/button'
|
||||
import SettingsPanel from './components/SettingsPanel.svelte'
|
||||
import ScanPanel from './components/ScanPanel.svelte'
|
||||
import HistoryPanel from './components/HistoryPanel.svelte'
|
||||
import ScheduledScansPanel from './components/ScheduledScansPanel.svelte'
|
||||
import { Menu } from 'lucide-svelte'
|
||||
import ToastHost from './components/ToastHost.svelte'
|
||||
import { healthCheck } from './lib/api.js'
|
||||
import { currentTheme, themes } from './lib/themeStore.js'
|
||||
|
||||
let currentView = 'scanner'
|
||||
let apiConfigured = false
|
||||
let selectedFiles = []
|
||||
let metadataProvider = 'omdb'
|
||||
let scanPanelKey = 0
|
||||
let sidebarOpen = true
|
||||
let sidebarCollapsed = false
|
||||
let isMobile = false
|
||||
|
||||
// Apply theme on mount and when it changes
|
||||
function applyTheme(themeName) {
|
||||
const theme = themes[themeName]
|
||||
if (!theme) return
|
||||
|
||||
const root = document.documentElement
|
||||
Object.entries(theme.colors).forEach(([key, value]) => {
|
||||
root.style.setProperty(`--${key}`, value)
|
||||
})
|
||||
|
||||
if (themeName === 'light') {
|
||||
root.classList.add('light-theme')
|
||||
} else {
|
||||
root.classList.remove('light-theme')
|
||||
}
|
||||
}
|
||||
|
||||
function updateLayout() {
|
||||
isMobile = window.innerWidth < 768
|
||||
if (isMobile) {
|
||||
sidebarOpen = false
|
||||
} else {
|
||||
sidebarOpen = true
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
updateLayout()
|
||||
const onResize = () => updateLayout()
|
||||
window.addEventListener('resize', onResize)
|
||||
|
||||
// Initialize theme
|
||||
applyTheme($currentTheme)
|
||||
|
||||
try {
|
||||
const health = await healthCheck()
|
||||
apiConfigured = health.api_key_configured
|
||||
} catch (err) {
|
||||
console.error('Health check failed:', err)
|
||||
}
|
||||
return () => {
|
||||
window.removeEventListener('resize', onResize)
|
||||
}
|
||||
})
|
||||
|
||||
// Watch for theme changes
|
||||
$: if ($currentTheme) {
|
||||
applyTheme($currentTheme)
|
||||
}
|
||||
|
||||
async function checkApiStatus() {
|
||||
try {
|
||||
const health = await healthCheck()
|
||||
apiConfigured = health.api_key_configured
|
||||
} catch (err) {
|
||||
console.error('Health check failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function navigateTo(view) {
|
||||
currentView = view
|
||||
// Re-check API status when navigating to scanner (in case settings were just saved)
|
||||
if (view === 'scanner') {
|
||||
await checkApiStatus()
|
||||
}
|
||||
}
|
||||
|
||||
function handleProcessComplete() {
|
||||
scanPanelKey += 1
|
||||
}
|
||||
|
||||
function handleSidebarToggle() {
|
||||
if (isMobile) {
|
||||
sidebarOpen = !sidebarOpen
|
||||
} else {
|
||||
sidebarCollapsed = !sidebarCollapsed
|
||||
}
|
||||
}
|
||||
|
||||
$: sidebarWidth = isMobile
|
||||
? '15.5rem'
|
||||
: sidebarCollapsed
|
||||
? '3.75rem'
|
||||
: '16rem'
|
||||
</script>
|
||||
|
||||
<Sidebar.Provider style={`--sidebar-width: ${sidebarWidth}; --header-height: 4rem;`}>
|
||||
{#if isMobile && sidebarOpen}
|
||||
<div
|
||||
class="fixed inset-0 z-30 bg-black/40 backdrop-blur-sm"
|
||||
on:click={() => (sidebarOpen = false)}
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
{/if}
|
||||
<AppSidebar
|
||||
{currentView}
|
||||
onNavigate={navigateTo}
|
||||
onToggleSidebar={handleSidebarToggle}
|
||||
open={isMobile ? sidebarOpen : true}
|
||||
collapsed={!isMobile && sidebarCollapsed}
|
||||
{isMobile}
|
||||
/>
|
||||
<Sidebar.Inset>
|
||||
<!-- Main Content -->
|
||||
<main class="flex-1">
|
||||
{#if isMobile && !sidebarOpen}
|
||||
<div class="px-4 sm:px-6 md:px-8 pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-white/15 text-text-secondary hover:bg-white/10"
|
||||
on:click={() => (sidebarOpen = true)}
|
||||
aria-label="Show sidebar"
|
||||
>
|
||||
<Menu class="h-4 w-4" />
|
||||
Menu
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if !apiConfigured && currentView === 'scanner'}
|
||||
<div class="border-b border-yellow-500/10 bg-yellow-500/5">
|
||||
<div class="px-6 md:px-8 py-3">
|
||||
<p class="text-[13px] text-yellow-100">Configure a metadata source in Settings to get started</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="px-4 sm:px-6 md:px-8 py-6 sm:py-8 md:py-10">
|
||||
{#if currentView === 'settings'}
|
||||
<SettingsPanel />
|
||||
{:else if currentView === 'history'}
|
||||
<HistoryPanel />
|
||||
{:else if currentView === 'scanner'}
|
||||
{#key scanPanelKey}
|
||||
<ScanPanel
|
||||
bind:selectedFilePaths={selectedFiles}
|
||||
bind:metadataProvider
|
||||
apiConfigured={apiConfigured}
|
||||
onOpenSettings={() => navigateTo('settings')}
|
||||
onOpenHistory={() => navigateTo('history')}
|
||||
/>
|
||||
{/key}
|
||||
{:else if currentView === 'scheduled'}
|
||||
<ScheduledScansPanel />
|
||||
{/if}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</Sidebar.Inset>
|
||||
<ToastHost />
|
||||
</Sidebar.Provider>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 750 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 507 KiB |
@@ -0,0 +1,158 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import { processFiles, getSettings } from '../lib/api.js'
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
export let selectedFiles = []
|
||||
export let metadataProvider = 'omdb'
|
||||
export let disabled = false
|
||||
|
||||
let processing = false
|
||||
let showConfirmation = false
|
||||
let duration = 40
|
||||
let results = null
|
||||
let error = null
|
||||
|
||||
async function handleProcessClick() {
|
||||
if (selectedFiles.length === 0) {
|
||||
error = 'No files selected'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const settings = await getSettings()
|
||||
duration = settings.duration ?? 40
|
||||
} catch (err) {
|
||||
console.error('Failed to load duration:', err)
|
||||
}
|
||||
|
||||
showConfirmation = true
|
||||
error = null
|
||||
}
|
||||
|
||||
async function confirmProcess() {
|
||||
processing = true
|
||||
showConfirmation = false
|
||||
error = null
|
||||
results = null
|
||||
|
||||
try {
|
||||
const response = await processFiles(selectedFiles, duration)
|
||||
results = response.results
|
||||
dispatch('complete', { results })
|
||||
} catch (err) {
|
||||
error = `Processing failed: ${err.message}`
|
||||
} finally {
|
||||
processing = false
|
||||
}
|
||||
}
|
||||
|
||||
function cancelProcess() {
|
||||
showConfirmation = false
|
||||
}
|
||||
|
||||
function closeResults() {
|
||||
results = null
|
||||
}
|
||||
|
||||
function formatMetadataLabel(source) {
|
||||
if (source === 'both') return 'OMDb + TMDb'
|
||||
if (source === 'tvmaze') return 'TVmaze'
|
||||
return source.toUpperCase()
|
||||
}
|
||||
|
||||
$: successCount = results?.filter(r => r.success).length || 0
|
||||
$: failureCount = results?.filter(r => !r.success).length || 0
|
||||
</script>
|
||||
|
||||
<div class="border-t border-border pt-10 mt-12">
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
on:click={handleProcessClick}
|
||||
disabled={disabled || processing || selectedFiles.length === 0}
|
||||
class="px-7 py-3.5 bg-white hover:bg-white/90 disabled:opacity-30 disabled:cursor-not-allowed
|
||||
text-black text-[13px] font-medium rounded-xl transition-all"
|
||||
>
|
||||
{#if processing}
|
||||
Processing...
|
||||
{:else}
|
||||
Add Subtitles ({selectedFiles.length})
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="mt-6 px-5 py-4 bg-red-500/5 border border-red-500/20 rounded-xl">
|
||||
<p class="text-[13px] text-red-300">{error}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Confirmation Modal -->
|
||||
{#if showConfirmation}
|
||||
<div class="fixed inset-0 bg-black/95 flex items-center justify-center z-50 p-4" on:click={cancelProcess} role="button" tabindex="-1" on:keydown={(e) => e.key === 'Escape' && cancelProcess()}>
|
||||
<div class="bg-bg-card border border-border rounded-2xl p-8 max-w-md w-full" on:click|stopPropagation role="dialog" tabindex="-1" on:keydown>
|
||||
<h3 class="text-base font-medium mb-4">Confirm Processing</h3>
|
||||
<p class="text-[13px] text-text-secondary mb-2 leading-relaxed">
|
||||
Add plot summaries to {selectedFiles.length} {selectedFiles.length !== 1 ? 'files' : 'file'}
|
||||
</p>
|
||||
<p class="text-[11px] text-text-tertiary mb-6">
|
||||
Using <span class="text-white font-medium">{formatMetadataLabel(metadataProvider)}</span> as metadata source
|
||||
</p>
|
||||
<div class="px-4 py-3 bg-yellow-500/5 border border-yellow-500/20 rounded-xl mb-8">
|
||||
<p class="text-[11px] text-yellow-200">Files will be modified. Backups created automatically.</p>
|
||||
</div>
|
||||
<div class="flex gap-3 justify-end">
|
||||
<button
|
||||
on:click={cancelProcess}
|
||||
class="px-5 py-2.5 text-text-secondary hover:text-white text-[13px] transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
on:click={confirmProcess}
|
||||
class="px-5 py-2.5 bg-white hover:bg-white/90 text-black text-[13px] font-medium rounded-xl transition-all"
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Results Modal -->
|
||||
{#if results}
|
||||
<div class="fixed inset-0 bg-black/95 flex items-center justify-center z-50 p-4" on:click={closeResults} role="button" tabindex="-1" on:keydown={(e) => e.key === 'Escape' && closeResults()}>
|
||||
<div class="bg-bg-card border border-border rounded-2xl p-8 max-w-2xl w-full max-h-[80vh] overflow-y-auto" on:click|stopPropagation role="dialog" tabindex="-1" on:keydown>
|
||||
<h3 class="text-base font-medium mb-6">Results</h3>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 mb-6">
|
||||
<div class="bg-green-500/5 border border-green-500/20 rounded-xl p-5 text-center">
|
||||
<div class="text-2xl font-semibold text-green-300">{successCount}</div>
|
||||
<div class="text-[11px] text-text-secondary mt-2 uppercase tracking-wide">Successful</div>
|
||||
</div>
|
||||
<div class="bg-red-500/5 border border-red-500/20 rounded-xl p-5 text-center">
|
||||
<div class="text-2xl font-semibold text-red-300">{failureCount}</div>
|
||||
<div class="text-[11px] text-text-secondary mt-2 uppercase tracking-wide">Failed</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border border-border rounded-xl divide-y divide-border max-h-60 overflow-y-auto mb-6">
|
||||
{#each results as result}
|
||||
<div class="px-5 py-4 {result.success ? 'bg-green-500/5' : 'bg-red-500/5'}">
|
||||
<div class="text-[13px] truncate font-medium">{result.file.split(/[/\\]/).pop()}</div>
|
||||
<div class="text-[11px] text-text-tertiary mt-1">{result.success ? result.status : result.error || 'Failed'}</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<button
|
||||
on:click={closeResults}
|
||||
class="px-7 py-3.5 bg-white hover:bg-white/90 text-black text-[13px] font-medium rounded-xl transition-all"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,213 @@
|
||||
<script>
|
||||
import { Button } from "../lib/components/ui/button";
|
||||
import { Separator } from "../lib/components/ui/separator";
|
||||
import { Badge } from "../lib/components/ui/badge";
|
||||
import {
|
||||
Calendar,
|
||||
Download,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Github,
|
||||
Heart,
|
||||
Package,
|
||||
Scan,
|
||||
Settings,
|
||||
History
|
||||
} from "lucide-svelte";
|
||||
import ThemeSelector from "./ThemeSelector.svelte";
|
||||
import sublogueLogo from "../assets/logo.png";
|
||||
|
||||
export let currentView = "scanner";
|
||||
export let onNavigate;
|
||||
export let onToggleSidebar;
|
||||
export let open = true;
|
||||
export let collapsed = false;
|
||||
export let isMobile = false;
|
||||
</script>
|
||||
|
||||
<aside
|
||||
class={`fixed inset-y-0 left-0 z-40 h-screen w-[--sidebar-width] border-r border-border bg-[color:var(--bg-primary)] bg-gradient-to-b from-white/12 via-white/5 to-transparent text-text-primary transition-transform duration-200 ease-out md:sticky md:top-0 ${
|
||||
!open && isMobile
|
||||
? "-translate-x-full pointer-events-none"
|
||||
: "translate-x-0"
|
||||
}`}
|
||||
>
|
||||
<div class="flex h-full min-h-0 flex-col">
|
||||
<div
|
||||
class={`relative flex items-center gap-3 py-5 ${collapsed ? "px-2" : "px-4"}`}
|
||||
>
|
||||
<div
|
||||
class="relative flex h-9 w-9 items-center justify-center rounded-lg border border-white/10 bg-black/40 overflow-hidden"
|
||||
>
|
||||
<span class="absolute inset-0 rounded-lg bg-blue-500/10 blur-md"></span>
|
||||
|
||||
<img
|
||||
src={sublogueLogo}
|
||||
alt="Sublogue"
|
||||
class="relative h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if !collapsed}
|
||||
<div>
|
||||
<div class="text-[15pt] font-bold tracking-tight">Sublogue</div>
|
||||
</div>
|
||||
{/if}
|
||||
<button
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 h-8 w-8 rounded-full border border-white/10 bg-white/5 text-text-secondary hover:text-white hover:bg-[color:var(--bg-hover)] transition-colors"
|
||||
on:click={onToggleSidebar}
|
||||
aria-label={collapsed ? "Show sidebar" : "Hide sidebar"}
|
||||
>
|
||||
{#if collapsed}
|
||||
<ChevronRight class="h-4 w-4 mx-auto" />
|
||||
{:else}
|
||||
<ChevronLeft class="h-4 w-4 mx-auto" />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav
|
||||
class={`sidebar-nav flex-1 min-h-0 overflow-y-auto py-3 ${collapsed ? "px-1.5" : "px-3"} space-y-1`}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={`w-full rounded-md py-1.5 text-[13px] font-semibold leading-none ${
|
||||
collapsed ? "justify-center px-0" : "justify-start px-2 gap-2"
|
||||
} ${
|
||||
currentView === "scanner"
|
||||
? "bg-[color:var(--bg-hover)] text-white font-bold"
|
||||
: "text-text-secondary hover:text-white hover:bg-[color:var(--bg-hover)]"
|
||||
}`}
|
||||
on:click={() => onNavigate("scanner")}
|
||||
aria-current={currentView === "scanner" ? "page" : undefined}
|
||||
>
|
||||
<Scan class="h-4 w-4" />
|
||||
{#if !collapsed}
|
||||
Scanner
|
||||
{/if}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={`w-full rounded-md py-1.5 text-[13px] font-semibold leading-none ${
|
||||
collapsed ? "justify-center px-0" : "justify-start px-2 gap-2"
|
||||
} ${
|
||||
currentView === "history"
|
||||
? "bg-[color:var(--bg-hover)] text-white font-bold"
|
||||
: "text-text-secondary hover:text-white hover:bg-[color:var(--bg-hover)]"
|
||||
}`}
|
||||
on:click={() => onNavigate("history")}
|
||||
aria-current={currentView === "history" ? "page" : undefined}
|
||||
>
|
||||
<History class="h-4 w-4" />
|
||||
{#if !collapsed}
|
||||
History
|
||||
{/if}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={`w-full rounded-md py-1.5 text-[13px] font-semibold leading-none ${
|
||||
collapsed ? "justify-center px-0" : "justify-start px-2 gap-2"
|
||||
} ${
|
||||
currentView === "scheduled"
|
||||
? "bg-[color:var(--bg-hover)] text-white font-bold"
|
||||
: "text-text-secondary hover:text-white hover:bg-[color:var(--bg-hover)]"
|
||||
}`}
|
||||
on:click={() => onNavigate("scheduled")}
|
||||
aria-current={currentView === "scheduled" ? "page" : undefined}
|
||||
>
|
||||
<Calendar class="h-4 w-4" />
|
||||
{#if !collapsed}
|
||||
Scheduled Scans
|
||||
{/if}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={`w-full rounded-md py-1.5 text-[13px] font-semibold leading-none ${
|
||||
collapsed ? "justify-center px-0" : "justify-start px-2 gap-2"
|
||||
} ${
|
||||
currentView === "settings"
|
||||
? "bg-[color:var(--bg-hover)] text-white font-bold"
|
||||
: "text-text-secondary hover:text-white hover:bg-[color:var(--bg-hover)]"
|
||||
}`}
|
||||
on:click={() => onNavigate("settings")}
|
||||
aria-current={currentView === "settings" ? "page" : undefined}
|
||||
>
|
||||
<Settings class="h-4 w-4" />
|
||||
{#if !collapsed}
|
||||
Settings
|
||||
{/if}
|
||||
</Button>
|
||||
</nav>
|
||||
|
||||
<div class={`pb-5 space-y-3 ${collapsed ? "px-2" : "px-3"}`}>
|
||||
<Separator className="bg-white/10" />
|
||||
{#if !collapsed}
|
||||
<ThemeSelector className="w-full" />
|
||||
{/if}
|
||||
<div
|
||||
class={`flex items-center rounded-md bg-white/5 px-3 py-2 text-xs ${collapsed ? "justify-center" : "justify-between"}`}
|
||||
>
|
||||
{#if !collapsed}
|
||||
<span class="text-text-tertiary">Version</span>
|
||||
<Badge className="bg-white/10 text-text-secondary">v1.0.2</Badge>
|
||||
{:else}
|
||||
<Badge className="bg-white/10 text-text-secondary">v</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
<a
|
||||
href="https://github.com/yourusername/sublogue/releases"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class={`inline-flex items-center rounded-md bg-white/5 px-3 py-2 text-xs text-text-tertiary hover:text-white hover:bg-[color:var(--bg-hover)] transition-colors ${collapsed ? "justify-center" : "gap-2"}`}
|
||||
>
|
||||
<Download class="h-4 w-4" />
|
||||
{#if !collapsed}
|
||||
Check updates
|
||||
{/if}
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/yourusername/sublogue"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class={`inline-flex items-center rounded-md bg-white/5 px-3 py-2 text-xs text-text-tertiary hover:text-white hover:bg-[color:var(--bg-hover)] transition-colors ${collapsed ? "justify-center" : "gap-2"}`}
|
||||
>
|
||||
<Github class="h-4 w-4" />
|
||||
{#if !collapsed}
|
||||
GitHub
|
||||
{/if}
|
||||
</a>
|
||||
<a
|
||||
href="https://hub.docker.com/r/yourusername/sublogue"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class={`inline-flex items-center rounded-md bg-white/5 px-3 py-2 text-xs text-text-tertiary hover:text-white hover:bg-[color:var(--bg-hover)] transition-colors ${collapsed ? "justify-center" : "gap-2"}`}
|
||||
>
|
||||
<Package class="h-4 w-4" />
|
||||
{#if !collapsed}
|
||||
DockerHub
|
||||
{/if}
|
||||
</a>
|
||||
<a
|
||||
href="https://www.buymeacoffee.com/sublogue"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class={`inline-flex items-center rounded-md bg-white/5 px-3 py-2 text-xs text-text-tertiary hover:text-red-200 hover:bg-[color:var(--bg-hover)] transition-colors ${collapsed ? "justify-center" : "gap-2"}`}
|
||||
title="Support Sublogue"
|
||||
>
|
||||
<Heart class="h-4 w-4" />
|
||||
{#if !collapsed}
|
||||
Support
|
||||
{/if}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
.sidebar-nav {
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,104 @@
|
||||
<script>
|
||||
import { fade } from "svelte/transition";
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
|
||||
// ----------------------------------------
|
||||
// Quote rotation config
|
||||
// ----------------------------------------
|
||||
const ROTATE_MS = 6000;
|
||||
|
||||
const quotes = [
|
||||
"Because subtitles deserve a prologue too.",
|
||||
"Turning subtitles into storytellers.",
|
||||
"Your film had a plot. Your subtitles should know it.",
|
||||
"For people who read movies more than watch them.",
|
||||
"Subtitles, but make them literary.",
|
||||
"Every story deserves context — even at 24fps.",
|
||||
"A little plot. Zero desync. Absolute peace.",
|
||||
"Metadata for humans who actually notice.",
|
||||
"Because ‘Hello sir’ should never be late.",
|
||||
"Subtitles with opinions. Quiet ones.",
|
||||
"Built for people who pause movies to read properly.",
|
||||
"Context is the difference between noise and meaning.",
|
||||
"Respect the subtitles. Respect yourself.",
|
||||
];
|
||||
|
||||
let quoteIndex = Math.floor(Math.random() * quotes.length);
|
||||
let interval;
|
||||
|
||||
function nextQuote() {
|
||||
quoteIndex = (quoteIndex + 1) % quotes.length;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
interval = setInterval(nextQuote, ROTATE_MS);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
clearInterval(interval);
|
||||
});
|
||||
</script>
|
||||
|
||||
<footer class="border-t border-border bg-bg-primary">
|
||||
<div class="max-w-7xl mx-auto px-6 md:px-8 py-10">
|
||||
<div class="flex flex-col gap-6 text-[11px] text-text-tertiary">
|
||||
<!-- Quote -->
|
||||
<div class="min-h-[1.2em] text-center sm:text-left">
|
||||
{#key quoteIndex}
|
||||
<span
|
||||
class="italic text-text-secondary/80 tracking-wide"
|
||||
transition:fade={{ duration: 350 }}
|
||||
>
|
||||
“{quotes[quoteIndex]}”
|
||||
</span>
|
||||
{/key}
|
||||
</div>
|
||||
|
||||
<!-- Footer bar -->
|
||||
<div
|
||||
class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"
|
||||
>
|
||||
<!-- Left -->
|
||||
<div class="flex items-center gap-3 text-[11px]">
|
||||
<span class="font-medium text-text-primary tracking-tight">
|
||||
Sublogue Version: v1.0.2
|
||||
</span>
|
||||
<span class="hidden sm:inline opacity-30">•</span>
|
||||
<span class="text-text-secondary"> Open source (AGPL-3.0) </span>
|
||||
<span class="hidden sm:inline opacity-30">•</span>
|
||||
<span class="text-text-tertiary"> Made in NZ 🇳🇿 </span>
|
||||
</div>
|
||||
|
||||
<!-- Right -->
|
||||
<div class="flex items-center gap-6">
|
||||
<span class="flex items-center gap-2">
|
||||
<a
|
||||
href="https://shelfarr.app/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-text-secondary hover:text-text-primary transition-colors underline-offset-4 hover:underline"
|
||||
>
|
||||
Github
|
||||
</a>
|
||||
<a
|
||||
href="https://shelfarr.app/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-text-secondary hover:text-text-primary transition-colors underline-offset-4 hover:underline"
|
||||
>
|
||||
Discord
|
||||
</a>
|
||||
<a
|
||||
href="https://shelfarr.app/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-text-secondary hover:text-text-primary transition-colors underline-offset-4 hover:underline"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -0,0 +1,158 @@
|
||||
<script>
|
||||
import ThemeSelector from "./ThemeSelector.svelte";
|
||||
import { Button } from "../lib/components/ui/button";
|
||||
import { Separator } from "../lib/components/ui/separator";
|
||||
import {
|
||||
Github,
|
||||
History,
|
||||
Menu,
|
||||
MessageCircle,
|
||||
Scan,
|
||||
Settings,
|
||||
X
|
||||
} from "lucide-svelte";
|
||||
|
||||
export let currentView = "scanner";
|
||||
export let onNavigate;
|
||||
|
||||
let mobileMenuOpen = false;
|
||||
|
||||
function navigateTo(view) {
|
||||
onNavigate(view);
|
||||
mobileMenuOpen = false;
|
||||
}
|
||||
|
||||
function toggleMobileMenu() {
|
||||
mobileMenuOpen = !mobileMenuOpen;
|
||||
}
|
||||
</script>
|
||||
|
||||
<header
|
||||
class="sticky top-0 z-50 border-b border-border backdrop-blur bg-background/80"
|
||||
>
|
||||
<div class="max-w-7xl mx-auto px-6 md:px-8 py-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-xl md:text-lg font-bold tracking-tight">Sublogue</h1>
|
||||
|
||||
<!-- Desktop Navigation -->
|
||||
<nav class="hidden md:flex items-center gap-1.5">
|
||||
<Button
|
||||
variant={currentView === "scanner" ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
on:click={() => navigateTo("scanner")}
|
||||
>
|
||||
<Scan class="w-3.5 h-3.5" />
|
||||
<span class="text-[13px]">Scanner</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant={currentView === "history" ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
on:click={() => navigateTo("history")}
|
||||
>
|
||||
<History class="w-3.5 h-3.5" />
|
||||
<span class="text-[13px]">History</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant={currentView === "settings" ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
on:click={() => navigateTo("settings")}
|
||||
>
|
||||
<Settings class="w-3.5 h-3.5" />
|
||||
<span class="text-[13px]">Settings</span>
|
||||
</Button>
|
||||
|
||||
<Separator orientation="vertical" className="mx-2 h-4" />
|
||||
|
||||
<ThemeSelector />
|
||||
|
||||
<Separator orientation="vertical" className="mx-2 h-4" />
|
||||
|
||||
<a
|
||||
href="https://discord.gg/your-invite"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="p-1.5 text-text-secondary hover:text-text-primary transition-colors"
|
||||
aria-label="Discord"
|
||||
>
|
||||
<MessageCircle class="w-4 h-4" />
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://github.com/yourusername/sublogue"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="p-1.5 text-text-secondary hover:text-text-primary transition-colors"
|
||||
aria-label="GitHub"
|
||||
>
|
||||
<Github class="w-4 h-4" />
|
||||
</a>
|
||||
|
||||
<Separator orientation="vertical" className="mx-2 h-4" />
|
||||
|
||||
<span class="text-[11px] text-text-tertiary px-2">v1.0.1</span>
|
||||
</nav>
|
||||
|
||||
<!-- Mobile Menu Button -->
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="md:hidden"
|
||||
on:click={toggleMobileMenu}
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
{#if mobileMenuOpen}
|
||||
<X class="w-6 h-6" />
|
||||
{:else}
|
||||
<Menu class="w-6 h-6" />
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Menu -->
|
||||
{#if mobileMenuOpen}
|
||||
<nav class="md:hidden mt-6 flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2 px-4 py-2 mb-2">
|
||||
<span
|
||||
class="text-[11px] text-text-tertiary font-medium uppercase tracking-wider"
|
||||
>Theme</span
|
||||
>
|
||||
<div class="ml-auto">
|
||||
<ThemeSelector />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant={currentView === "scanner" ? "secondary" : "ghost"}
|
||||
className="w-full justify-start gap-3 px-4 py-3"
|
||||
on:click={() => navigateTo("scanner")}
|
||||
>
|
||||
<Scan class="w-5 h-5" />
|
||||
<span class="text-sm font-medium">Scanner</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant={currentView === "history" ? "secondary" : "ghost"}
|
||||
className="w-full justify-start gap-3 px-4 py-3"
|
||||
on:click={() => navigateTo("history")}
|
||||
>
|
||||
<History class="w-5 h-5" />
|
||||
<span class="text-sm font-medium">History</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant={currentView === "settings" ? "secondary" : "ghost"}
|
||||
className="w-full justify-start gap-3 px-4 py-3"
|
||||
on:click={() => navigateTo("settings")}
|
||||
>
|
||||
<Settings class="w-5 h-5" />
|
||||
<span class="text-sm font-medium">Settings</span>
|
||||
</Button>
|
||||
</nav>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
@@ -0,0 +1,574 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte'
|
||||
import { getRunHistory, getRunDetails, getScanHistory, getStatistics } from '../lib/api.js'
|
||||
import { Skeleton } from '../lib/components/ui/skeleton'
|
||||
import { Input } from '../lib/components/ui/input'
|
||||
import { Button } from '../lib/components/ui/button'
|
||||
import { Combobox } from '../lib/components/ui/combobox'
|
||||
import { ClipboardList, Search, X } from 'lucide-svelte'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow
|
||||
} from '../lib/components/ui/table'
|
||||
|
||||
let processingRuns = []
|
||||
let scanHistory = []
|
||||
let statistics = null
|
||||
let loading = true
|
||||
let error = null
|
||||
let selectedRun = null
|
||||
let showRunDetails = false
|
||||
let loadingDetails = false
|
||||
let query = ''
|
||||
let statusFilter = 'all'
|
||||
let dateFrom = ''
|
||||
let dateTo = ''
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 'all', label: 'All statuses' },
|
||||
{ value: 'completed', label: 'Completed' },
|
||||
{ value: 'in_progress', label: 'In progress' },
|
||||
{ value: 'failed', label: 'Failed' }
|
||||
]
|
||||
|
||||
onMount(async () => {
|
||||
await loadData()
|
||||
})
|
||||
|
||||
async function loadData() {
|
||||
loading = true
|
||||
error = null
|
||||
const loadingStart = Date.now()
|
||||
|
||||
try {
|
||||
const [runsResponse, scansResponse, statsResponse] = await Promise.all([
|
||||
getRunHistory(100),
|
||||
getScanHistory(100),
|
||||
getStatistics()
|
||||
])
|
||||
|
||||
processingRuns = runsResponse.runs || []
|
||||
scanHistory = scansResponse.scans || []
|
||||
statistics = statsResponse.statistics || null
|
||||
} catch (err) {
|
||||
error = `Failed to load history: ${err.message}`
|
||||
console.error('Error loading history:', err)
|
||||
} finally {
|
||||
const elapsed = Date.now() - loadingStart
|
||||
const minDelayMs = 1200
|
||||
if (elapsed < minDelayMs) {
|
||||
await new Promise((resolve) => setTimeout(resolve, minDelayMs - elapsed))
|
||||
}
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function viewRunDetails(runId) {
|
||||
loadingDetails = true
|
||||
showRunDetails = true
|
||||
|
||||
try {
|
||||
const response = await getRunDetails(runId)
|
||||
selectedRun = response.run
|
||||
} catch (err) {
|
||||
error = `Failed to load run details: ${err.message}`
|
||||
console.error('Error loading run details:', err)
|
||||
} finally {
|
||||
loadingDetails = false
|
||||
}
|
||||
}
|
||||
|
||||
function closeRunDetails() {
|
||||
showRunDetails = false
|
||||
selectedRun = null
|
||||
}
|
||||
|
||||
function formatDate(isoString) {
|
||||
if (!isoString) return 'N/A'
|
||||
const date = new Date(isoString)
|
||||
return date.toLocaleString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
function formatDuration(seconds) {
|
||||
if (!seconds) return 'N/A'
|
||||
if (seconds < 60) return `${Math.round(seconds)}s`
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const secs = Math.round(seconds % 60)
|
||||
return `${minutes}m ${secs}s`
|
||||
}
|
||||
|
||||
function formatScanDuration(ms) {
|
||||
if (!ms) return 'N/A'
|
||||
if (ms < 1000) return `${ms}ms`
|
||||
return `${(ms / 1000).toFixed(1)}s`
|
||||
}
|
||||
|
||||
function getStatusColor(status) {
|
||||
switch (status) {
|
||||
case 'completed': return 'text-green-300'
|
||||
case 'in_progress': return 'text-blue-300'
|
||||
case 'failed': return 'text-red-300'
|
||||
default: return 'text-text-secondary'
|
||||
}
|
||||
}
|
||||
|
||||
function isWithinDateRange(dateString) {
|
||||
if (!dateString) return false
|
||||
if (!dateFrom && !dateTo) return true
|
||||
const value = new Date(dateString).getTime()
|
||||
if (Number.isNaN(value)) return false
|
||||
if (dateFrom) {
|
||||
const fromValue = new Date(dateFrom).setHours(0, 0, 0, 0)
|
||||
if (value < fromValue) return false
|
||||
}
|
||||
if (dateTo) {
|
||||
const toValue = new Date(dateTo).setHours(23, 59, 59, 999)
|
||||
if (value > toValue) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function applyDatePreset(days) {
|
||||
const now = new Date()
|
||||
const from = new Date()
|
||||
from.setDate(now.getDate() - days)
|
||||
dateFrom = from.toISOString().slice(0, 10)
|
||||
dateTo = now.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
$: summary = {
|
||||
totalRuns: statistics?.total_runs ?? processingRuns.length,
|
||||
completedRuns:
|
||||
statistics?.completed_runs ??
|
||||
processingRuns.filter((run) => run.status === 'completed').length,
|
||||
totalFiles:
|
||||
statistics?.total_files_processed ??
|
||||
processingRuns.reduce((sum, run) => sum + (run.total_files || 0), 0),
|
||||
successfulFiles:
|
||||
statistics?.successful_files ??
|
||||
processingRuns.reduce((sum, run) => sum + (run.successful_files || 0), 0),
|
||||
failedFiles:
|
||||
statistics?.failed_files ??
|
||||
processingRuns.reduce((sum, run) => sum + (run.failed_files || 0), 0)
|
||||
}
|
||||
|
||||
$: successRate =
|
||||
summary.totalFiles > 0
|
||||
? Math.round((summary.successfulFiles / summary.totalFiles) * 100)
|
||||
: 0
|
||||
$: filteredProcessingRuns = processingRuns.filter((run) => {
|
||||
const matchesQuery =
|
||||
!query ||
|
||||
String(run.id).includes(query) ||
|
||||
(run.status || '').toLowerCase().includes(query.toLowerCase())
|
||||
const matchesStatus = statusFilter === 'all' || run.status === statusFilter
|
||||
const matchesDate = isWithinDateRange(run.started_at)
|
||||
return matchesQuery && matchesStatus && matchesDate
|
||||
})
|
||||
$: filteredScanHistory = scanHistory.filter((scan) => {
|
||||
const matchesQuery =
|
||||
!query ||
|
||||
(scan.directory || '').toLowerCase().includes(query.toLowerCase()) ||
|
||||
String(scan.id).includes(query)
|
||||
const matchesDate = isWithinDateRange(scan.scanned_at)
|
||||
return matchesQuery && matchesDate
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="space-y-8">
|
||||
<!-- Header -->
|
||||
<div>
|
||||
<h2 class="text-xl font-bold mb-2">History</h2>
|
||||
<p class="text-[13px] text-text-secondary">
|
||||
View your processing runs, scans, and statistics
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<!-- Loading State -->
|
||||
<div class="space-y-6">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-5 gap-4">
|
||||
{#each Array(5) as _}
|
||||
<div class="rounded-lg border border-border bg-card p-5 space-y-3">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-6 w-16" />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="rounded-lg border border-border bg-card p-6 space-y-3">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-5/6" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
</div>
|
||||
</div>
|
||||
{:else if error}
|
||||
<!-- Error State -->
|
||||
<div class="bg-red-500/5 border border-red-500/20 rounded-xl p-6">
|
||||
<p class="text-[13px] text-red-300">{error}</p>
|
||||
<button
|
||||
on:click={loadData}
|
||||
class="mt-4 px-4 py-2 text-[13px] text-red-300 hover:text-white transition-colors"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Content -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-5 gap-4">
|
||||
<div class="rounded-xl border border-border bg-card p-5">
|
||||
<div class="text-[11px] uppercase tracking-[0.2em] text-text-tertiary mb-2">Runs</div>
|
||||
<div class="text-2xl font-semibold">{summary.totalRuns}</div>
|
||||
<div class="text-[12px] text-text-secondary">Total processing runs</div>
|
||||
</div>
|
||||
<div class="rounded-xl border border-border bg-card p-5">
|
||||
<div class="text-[11px] uppercase tracking-[0.2em] text-text-tertiary mb-2">Completed</div>
|
||||
<div class="text-2xl font-semibold text-green-300">{summary.completedRuns}</div>
|
||||
<div class="text-[12px] text-text-secondary">Runs completed</div>
|
||||
</div>
|
||||
<div class="rounded-xl border border-border bg-card p-5">
|
||||
<div class="text-[11px] uppercase tracking-[0.2em] text-text-tertiary mb-2">Files</div>
|
||||
<div class="text-2xl font-semibold">{summary.totalFiles}</div>
|
||||
<div class="text-[12px] text-text-secondary">Files processed</div>
|
||||
</div>
|
||||
<div class="rounded-xl border border-border bg-card p-5">
|
||||
<div class="text-[11px] uppercase tracking-[0.2em] text-text-tertiary mb-2">Success</div>
|
||||
<div class="text-2xl font-semibold text-green-300">{summary.successfulFiles}</div>
|
||||
<div class="text-[12px] text-text-secondary">Successful files</div>
|
||||
</div>
|
||||
<div class="rounded-xl border border-border bg-card p-5">
|
||||
<div class="text-[11px] uppercase tracking-[0.2em] text-text-tertiary mb-2">Success rate</div>
|
||||
<div class="text-2xl font-semibold">{successRate}%</div>
|
||||
<div class="text-[12px] text-text-secondary">Across all runs</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-border bg-card p-4">
|
||||
<div class="grid gap-3 sm:grid-cols-[1.2fr,200px,200px,auto] items-end">
|
||||
<div class="space-y-1">
|
||||
<label class="text-[11px] uppercase tracking-[0.2em] text-text-tertiary">Search</label>
|
||||
<Input
|
||||
type="text"
|
||||
bind:value={query}
|
||||
placeholder="Search runs or directories"
|
||||
className="h-9 text-[12px]"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label class="text-[11px] uppercase tracking-[0.2em] text-text-tertiary">Status</label>
|
||||
<Combobox
|
||||
items={statusOptions}
|
||||
value={statusFilter}
|
||||
placeholder="All statuses"
|
||||
className="h-9"
|
||||
on:change={(event) => (statusFilter = event.detail.value)}
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label class="text-[11px] uppercase tracking-[0.2em] text-text-tertiary">Date range</label>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<Input type="date" bind:value={dateFrom} className="h-9 text-[12px]" />
|
||||
<Input type="date" bind:value={dateTo} className="h-9 text-[12px]" />
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-8 px-2 text-[11px] text-text-secondary"
|
||||
on:click={() => applyDatePreset(7)}
|
||||
>
|
||||
Last 7 days
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-8 px-2 text-[11px] text-text-secondary"
|
||||
on:click={() => applyDatePreset(30)}
|
||||
>
|
||||
Last 30 days
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 sm:justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-9 text-[12px]"
|
||||
on:click={() => {
|
||||
query = ''
|
||||
statusFilter = 'all'
|
||||
dateFrom = ''
|
||||
dateTo = ''
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold">Processing Runs</h3>
|
||||
<p class="text-[12px] text-text-tertiary">Recent subtitle enrichment runs</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if filteredProcessingRuns.length === 0}
|
||||
<div class="border border-border rounded-xl p-10 text-center">
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<ClipboardList class="w-12 h-12 text-text-tertiary" />
|
||||
<div>
|
||||
<p class="text-[13px] text-text-secondary mb-1">No processing runs yet</p>
|
||||
<p class="text-[11px] text-text-tertiary">Process some files to see them here</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="rounded-lg border border-border bg-card overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<Table className="w-full">
|
||||
<TableHeader className="bg-muted/60 border-b border-border">
|
||||
<TableRow className="uppercase tracking-wider">
|
||||
<TableHead>Run ID</TableHead>
|
||||
<TableHead>Started</TableHead>
|
||||
<TableHead>Completed</TableHead>
|
||||
<TableHead>Duration</TableHead>
|
||||
<TableHead>Total Files</TableHead>
|
||||
<TableHead>Successful</TableHead>
|
||||
<TableHead>Failed</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{#each filteredProcessingRuns as run}
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<span class="text-sm font-mono">#{run.id}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span class="text-[13px] text-text-secondary">{formatDate(run.started_at)}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span class="text-[13px] text-text-secondary">{formatDate(run.completed_at)}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span class="text-[13px] text-text-secondary">{formatDuration(run.duration_seconds)}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span class="text-sm font-medium">{run.total_files}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span class="text-sm text-green-300">{run.successful_files}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span class="text-sm text-red-300">{run.failed_files}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span class="text-xs font-medium capitalize {getStatusColor(run.status)}">
|
||||
{run.status}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<button
|
||||
on:click={() => viewRunDetails(run.id)}
|
||||
class="px-3 py-1.5 text-xs text-accent hover:text-foreground border border-accent/30 hover:border-accent rounded-md transition-colors"
|
||||
>
|
||||
View Details
|
||||
</button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{/each}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold">Scan History</h3>
|
||||
<p class="text-[12px] text-text-tertiary">Recent directory scans and counts</p>
|
||||
</div>
|
||||
|
||||
{#if filteredScanHistory.length === 0}
|
||||
<div class="border border-border rounded-xl p-10 text-center">
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<Search class="w-12 h-12 text-text-tertiary" />
|
||||
<div>
|
||||
<p class="text-[13px] text-text-secondary mb-1">No scans yet</p>
|
||||
<p class="text-[11px] text-text-tertiary">Scan a directory to see history here</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="rounded-lg border border-border bg-card overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<Table className="w-full">
|
||||
<TableHeader className="bg-muted/60 border-b border-border">
|
||||
<TableRow className="uppercase tracking-wider">
|
||||
<TableHead>Scan ID</TableHead>
|
||||
<TableHead>Directory</TableHead>
|
||||
<TableHead>Scanned At</TableHead>
|
||||
<TableHead>Files Found</TableHead>
|
||||
<TableHead>With Plot</TableHead>
|
||||
<TableHead>Duration</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{#each filteredScanHistory as scan}
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<span class="text-sm font-mono">#{scan.id}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span class="text-[13px] font-mono text-text-secondary truncate max-w-md block" title={scan.directory}>
|
||||
{scan.directory}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span class="text-[13px] text-text-secondary">{formatDate(scan.scanned_at)}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span class="text-sm font-medium">{scan.files_found}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span class="text-sm text-green-300">{scan.files_with_plot}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span class="text-[13px] text-text-secondary">{formatScanDuration(scan.scan_duration_ms)}</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{/each}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Run Details Modal -->
|
||||
{#if showRunDetails}
|
||||
<div
|
||||
class="fixed inset-0 bg-black/95 flex items-center justify-center z-50 p-4"
|
||||
on:click={closeRunDetails}
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
on:keydown={(e) => e.key === 'Escape' && closeRunDetails()}
|
||||
>
|
||||
<div
|
||||
class="bg-bg-card border border-border rounded-2xl p-8 max-w-5xl w-full max-h-[90vh] overflow-y-auto"
|
||||
on:click|stopPropagation
|
||||
role="dialog"
|
||||
tabindex="-1"
|
||||
on:keydown
|
||||
>
|
||||
{#if loadingDetails}
|
||||
<div class="flex items-center justify-center py-16">
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<div class="w-8 h-8 border-4 border-accent/30 border-t-accent rounded-full animate-spin"></div>
|
||||
<p class="text-sm text-text-secondary">Loading run details...</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else if selectedRun}
|
||||
<div class="flex items-start justify-between mb-6">
|
||||
<div>
|
||||
<h3 class="text-lg font-medium mb-1">Run #{selectedRun.id} Details</h3>
|
||||
<p class="text-sm text-text-secondary">
|
||||
Started {formatDate(selectedRun.started_at)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
on:click={closeRunDetails}
|
||||
class="text-text-secondary hover:text-white transition-colors"
|
||||
>
|
||||
<X class="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Run Summary -->
|
||||
<div class="grid grid-cols-4 gap-4 mb-6">
|
||||
<div class="bg-bg-primary rounded-lg p-4">
|
||||
<div class="text-2xl font-bold">{selectedRun.total_files}</div>
|
||||
<div class="text-[11px] text-text-secondary mt-1">Total Files</div>
|
||||
</div>
|
||||
<div class="bg-green-500/5 border border-green-500/20 rounded-lg p-4">
|
||||
<div class="text-2xl font-bold text-green-300">{selectedRun.successful_files}</div>
|
||||
<div class="text-[11px] text-text-secondary mt-1">Successful</div>
|
||||
</div>
|
||||
<div class="bg-red-500/5 border border-red-500/20 rounded-lg p-4">
|
||||
<div class="text-2xl font-bold text-red-300">{selectedRun.failed_files}</div>
|
||||
<div class="text-[11px] text-text-secondary mt-1">Failed</div>
|
||||
</div>
|
||||
<div class="bg-bg-primary rounded-lg p-4">
|
||||
<div class="text-2xl font-bold">{formatDuration(selectedRun.duration_seconds)}</div>
|
||||
<div class="text-[11px] text-text-secondary mt-1">Duration</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- File Results -->
|
||||
<div>
|
||||
<h4 class="text-sm font-medium mb-3">File Results</h4>
|
||||
<div class="border border-border rounded-xl divide-y divide-border max-h-96 overflow-y-auto">
|
||||
{#each selectedRun.file_results as result}
|
||||
<div class="px-5 py-4 {result.success ? 'bg-green-500/5' : 'bg-red-500/5'}">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-[13px] font-medium truncate" title={result.file_name}>
|
||||
{result.file_name}
|
||||
</div>
|
||||
<div class="text-[11px] text-text-tertiary mt-1 truncate" title={result.file_path}>
|
||||
{result.file_path}
|
||||
</div>
|
||||
{#if result.summary}
|
||||
<div class="text-[11px] text-text-secondary mt-2 line-clamp-2">
|
||||
{result.summary}
|
||||
</div>
|
||||
{/if}
|
||||
{#if result.error_message}
|
||||
<div class="text-[11px] text-red-300 mt-2">
|
||||
Error: {result.error_message}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex flex-col items-end gap-1">
|
||||
<span class="text-xs font-medium {result.success ? 'text-green-300' : 'text-red-300'}">
|
||||
{result.status}
|
||||
</span>
|
||||
<span class="text-[11px] text-text-tertiary">
|
||||
{formatDate(result.processed_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end mt-6">
|
||||
<button
|
||||
on:click={closeRunDetails}
|
||||
class="px-5 py-2.5 bg-white hover:bg-white/90 text-black text-[13px] font-medium rounded-xl transition-all"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,207 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte'
|
||||
import { Button } from '../lib/components/ui/button'
|
||||
import { Skeleton } from '../lib/components/ui/skeleton'
|
||||
import { Clock } from 'lucide-svelte'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow
|
||||
} from '../lib/components/ui/table'
|
||||
import {
|
||||
getScheduledScans,
|
||||
cancelScheduledScan
|
||||
} from '../lib/api.js'
|
||||
|
||||
let scans = []
|
||||
let loading = true
|
||||
let error = null
|
||||
let refreshing = false
|
||||
|
||||
function formatDate(isoString) {
|
||||
if (!isoString) return 'N/A'
|
||||
const date = new Date(isoString)
|
||||
return date.toLocaleString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
function formatDuration(ms) {
|
||||
if (!ms && ms !== 0) return 'N/A'
|
||||
if (ms < 1000) return `${ms}ms`
|
||||
return `${(ms / 1000).toFixed(1)}s`
|
||||
}
|
||||
|
||||
function getStatusColor(status) {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return 'text-green-300'
|
||||
case 'running':
|
||||
return 'text-blue-300'
|
||||
case 'failed':
|
||||
return 'text-red-300'
|
||||
case 'cancelled':
|
||||
return 'text-text-tertiary'
|
||||
default:
|
||||
return 'text-text-secondary'
|
||||
}
|
||||
}
|
||||
|
||||
async function loadScans({ showSpinner = true } = {}) {
|
||||
if (showSpinner) {
|
||||
loading = true
|
||||
} else {
|
||||
refreshing = true
|
||||
}
|
||||
error = null
|
||||
try {
|
||||
const response = await getScheduledScans(200)
|
||||
scans = response.scans || []
|
||||
} catch (err) {
|
||||
error = `Failed to load scheduled scans: ${err.message}`
|
||||
} finally {
|
||||
loading = false
|
||||
refreshing = false
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelScan(scanId) {
|
||||
error = null
|
||||
try {
|
||||
await cancelScheduledScan(scanId)
|
||||
await loadScans({ showSpinner: false })
|
||||
} catch (err) {
|
||||
error = `Failed to cancel scan: ${err.message}`
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
await loadScans()
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold">Scheduled Scans</h2>
|
||||
<p class="text-[13px] text-text-secondary">
|
||||
Track scheduled scans and review their results.
|
||||
</p>
|
||||
<p class="text-[12px] text-text-tertiary">
|
||||
Create new schedules in Settings > Scheduled Scans.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 text-[12px]"
|
||||
on:click={() => loadScans({ showSpinner: false })}
|
||||
disabled={refreshing}
|
||||
>
|
||||
{refreshing ? 'Refreshing...' : 'Refresh'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="bg-red-500/5 border border-red-500/20 rounded-xl p-4">
|
||||
<p class="text-[13px] text-red-300">{error}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<div class="space-y-3">
|
||||
<div class="rounded-lg border border-border bg-card p-5 space-y-3">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-5/6" />
|
||||
</div>
|
||||
<div class="rounded-lg border border-border bg-card p-5 space-y-3">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-5/6" />
|
||||
</div>
|
||||
</div>
|
||||
{:else if scans.length === 0}
|
||||
<div class="border border-border rounded-2xl p-10 text-center">
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<Clock class="w-12 h-12 text-text-tertiary" />
|
||||
<div>
|
||||
<p class="text-[13px] text-text-secondary mb-1">No scheduled scans yet</p>
|
||||
<p class="text-[11px] text-text-tertiary">Schedule a scan to see it here</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="rounded-lg border border-border bg-card overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<Table className="w-full">
|
||||
<TableHeader className="bg-muted/60 border-b border-border">
|
||||
<TableRow className="uppercase tracking-wider">
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>Directory</TableHead>
|
||||
<TableHead>Scheduled</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Files Found</TableHead>
|
||||
<TableHead>Duration</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{#each scans as scan}
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<span class="text-sm font-mono">#{scan.id}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
class="text-[13px] font-mono text-text-secondary truncate max-w-md block"
|
||||
title={scan.directory}
|
||||
>
|
||||
{scan.directory}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span class="text-[13px] text-text-secondary">{formatDate(scan.scheduled_for)}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span class="text-xs font-medium capitalize {getStatusColor(scan.status)}">
|
||||
{scan.status}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span class="text-sm font-medium">{scan.files_found ?? '—'}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span class="text-[13px] text-text-secondary">{formatDuration(scan.scan_duration_ms)}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{#if scan.status === 'scheduled'}
|
||||
<button
|
||||
on:click={() => cancelScan(scan.id)}
|
||||
class="px-3 py-1.5 text-xs text-red-300 hover:text-white border border-red-400/40 hover:border-red-400 rounded-md transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
{:else if scan.status === 'failed'}
|
||||
<span class="text-[11px] text-red-300" title={scan.error_message || ''}>
|
||||
Failed
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-[11px] text-text-tertiary">—</span>
|
||||
{/if}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{/each}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,175 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte'
|
||||
import { getSettings, updateSettings } from '../lib/api.js'
|
||||
import { Skeleton } from '../lib/components/ui/skeleton'
|
||||
import GeneralSettings from './settings/GeneralSettings.svelte'
|
||||
import IntegrationsSettings from './settings/IntegrationsSettings.svelte'
|
||||
import FilenameCleaningSettings from './settings/FilenameCleaningSettings.svelte'
|
||||
import ScheduledScansSettings from './settings/ScheduledScansSettings.svelte'
|
||||
import TasksSettings from './settings/TasksSettings.svelte'
|
||||
import { addToast } from '../lib/toastStore.js'
|
||||
import { Bolt, Calendar, Plug, Settings, Wand2 } from 'lucide-svelte'
|
||||
|
||||
let currentSection = 'general'
|
||||
let settings = {}
|
||||
let loading = false
|
||||
let saving = false
|
||||
let error = null
|
||||
let successMessage = null
|
||||
|
||||
const sections = [
|
||||
{ id: 'general', label: 'General', icon: 'settings' },
|
||||
{ id: 'scheduled', label: 'Scheduled Scans', icon: 'calendar' },
|
||||
{ id: 'cleanup', label: 'Cleanup', icon: 'wand' },
|
||||
{ id: 'integrations', label: 'Integrations', icon: 'plug' },
|
||||
{ id: 'tasks', label: 'Tasks', icon: 'bolt' }
|
||||
]
|
||||
|
||||
onMount(async () => {
|
||||
await loadSettings()
|
||||
})
|
||||
|
||||
async function loadSettings() {
|
||||
loading = true
|
||||
error = null
|
||||
const loadingStart = Date.now()
|
||||
|
||||
try {
|
||||
settings = await getSettings()
|
||||
} catch (err) {
|
||||
error = `Failed to load settings: ${err.message}`
|
||||
} finally {
|
||||
const elapsed = Date.now() - loadingStart
|
||||
const minDelayMs = 500
|
||||
if (elapsed < minDelayMs) {
|
||||
await new Promise((resolve) => setTimeout(resolve, minDelayMs - elapsed))
|
||||
}
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave(updates) {
|
||||
saving = true
|
||||
error = null
|
||||
successMessage = null
|
||||
|
||||
try {
|
||||
const result = await updateSettings(updates)
|
||||
successMessage = result.message || 'Settings saved successfully'
|
||||
addToast({ message: successMessage, tone: 'success' })
|
||||
|
||||
// Reload settings
|
||||
await loadSettings()
|
||||
|
||||
setTimeout(() => {
|
||||
successMessage = null
|
||||
}, 3000)
|
||||
} catch (err) {
|
||||
error = `Failed to save settings: ${err.message}`
|
||||
addToast({ message: error, tone: 'error' })
|
||||
} finally {
|
||||
saving = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<Skeleton className="h-6 w-32 mb-2" />
|
||||
<Skeleton className="h-4 w-64" />
|
||||
</div>
|
||||
<div class="flex gap-12">
|
||||
<div class="w-48 space-y-2">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
<div class="flex-1 space-y-4">
|
||||
<div class="rounded-lg border border-border bg-card p-6 space-y-3">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
</div>
|
||||
<div class="rounded-lg border border-border bg-card p-6 space-y-3">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-9 w-full" />
|
||||
<Skeleton className="h-9 w-full" />
|
||||
</div>
|
||||
<div class="rounded-lg border border-border bg-card p-6 space-y-3">
|
||||
<Skeleton className="h-4 w-28" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-5/6" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-8">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold mb-2 text-text-primary">Settings</h2>
|
||||
<p class="text-[13px] text-text-secondary leading-relaxed">
|
||||
Configure metadata sources, cleanup rules, and scheduled scans.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col lg:flex-row gap-8 lg:gap-12">
|
||||
<!-- Sidebar Navigation -->
|
||||
<aside class="w-full lg:w-48 flex-shrink-0">
|
||||
<nav class="space-y-0.5">
|
||||
{#each sections as section}
|
||||
<button
|
||||
class="w-full flex items-center gap-3 px-3 py-2 rounded-lg text-left transition-all border
|
||||
{currentSection === section.id
|
||||
? 'bg-white text-black border-white'
|
||||
: 'text-text-secondary hover:text-white hover:bg-bg-hover border-transparent'}"
|
||||
on:click={() => currentSection = section.id}
|
||||
>
|
||||
{#if section.icon === 'settings'}
|
||||
<Settings class="w-4 h-4" />
|
||||
{:else if section.icon === 'calendar'}
|
||||
<Calendar class="w-4 h-4" />
|
||||
{:else if section.icon === 'bolt'}
|
||||
<Bolt class="w-4 h-4" />
|
||||
{:else if section.icon === 'wand'}
|
||||
<Wand2 class="w-4 h-4" />
|
||||
{:else if section.icon === 'plug'}
|
||||
<Plug class="w-4 h-4" />
|
||||
{/if}
|
||||
<span class="text-[13px] font-medium">{section.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="flex-1 min-w-0">
|
||||
{#if error}
|
||||
<div class="mb-6 px-5 py-4 bg-red-500/5 border border-red-500/20 rounded-xl">
|
||||
<p class="text-[13px] text-red-300">{error}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if successMessage}
|
||||
<div class="mb-6 px-5 py-4 bg-green-500/5 border border-green-500/20 rounded-xl">
|
||||
<p class="text-[13px] text-green-300">{successMessage}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="rounded-xl border border-border bg-card/60 p-6 lg:p-8 shadow-sm">
|
||||
{#if currentSection === 'general'}
|
||||
<GeneralSettings {settings} {saving} onSave={handleSave} />
|
||||
{:else if currentSection === 'scheduled'}
|
||||
<ScheduledScansSettings {settings} />
|
||||
{:else if currentSection === 'cleanup'}
|
||||
<FilenameCleaningSettings {settings} {saving} onSave={handleSave} />
|
||||
{:else if currentSection === 'integrations'}
|
||||
<IntegrationsSettings {settings} {saving} onSave={handleSave} />
|
||||
{:else if currentSection === 'tasks'}
|
||||
<TasksSettings />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,60 @@
|
||||
<script>
|
||||
import ThemeSelector from "./ThemeSelector.svelte";
|
||||
import { Button } from "../lib/components/ui/button";
|
||||
import { Separator } from "../lib/components/ui/separator";
|
||||
import { Menu } from "lucide-svelte";
|
||||
|
||||
export let title = "Scanner";
|
||||
export let showSidebarButton = false;
|
||||
export let onShowSidebar;
|
||||
</script>
|
||||
|
||||
<header
|
||||
class="sticky top-0 z-40 flex h-[--header-height] items-center border-b border-border bg-background/80 backdrop-blur"
|
||||
>
|
||||
<div class="flex w-full items-center justify-between px-4 sm:px-6">
|
||||
<div class="flex items-center gap-3">
|
||||
{#if showSidebarButton}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
on:click={onShowSidebar}
|
||||
aria-label="Show sidebar"
|
||||
>
|
||||
<Menu class="h-4 w-4" />
|
||||
</Button>
|
||||
{/if}
|
||||
<h2 class="text-base font-semibold tracking-tight">{title}</h2>
|
||||
<Separator orientation="vertical" className="hidden h-4 sm:block" />
|
||||
<span class="hidden text-xs text-muted-foreground sm:inline">
|
||||
Subtitle metadata workflow
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<ThemeSelector />
|
||||
<Separator orientation="vertical" className="mx-2 hidden h-4 sm:block" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="hidden text-muted-foreground sm:inline-flex"
|
||||
href="https://github.com/yourusername/sublogue"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
GitHub
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="hidden text-muted-foreground sm:inline-flex"
|
||||
href="https://discord.gg/your-invite"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Discord
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script>
|
||||
import { Check } from 'lucide-svelte'
|
||||
|
||||
export let status = 'Not Loaded'
|
||||
|
||||
const statusStyles = {
|
||||
'Has Plot': 'bg-gray-500/20 text-gray-300',
|
||||
'Processed': 'bg-green-500/20 text-green-400',
|
||||
'Not Loaded': 'bg-gray-500/10 text-gray-400 border-gray-500/20',
|
||||
'Error': 'bg-red-500/10 text-red-400 border-red-500/20',
|
||||
'Skipped': 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20',
|
||||
'Processing': 'bg-blue-500/10 text-blue-400 border-blue-500/20 animate-pulse'
|
||||
}
|
||||
|
||||
$: classes = statusStyles[status] || statusStyles['Not Loaded']
|
||||
$: hasIcon = status === 'Has Plot' || status === 'Processed'
|
||||
</script>
|
||||
|
||||
{#if hasIcon}
|
||||
<span class="inline-flex items-center gap-1.5 px-2 py-0.5 text-[10px] font-medium rounded-full {classes}">
|
||||
<Check class="w-3 h-3" />
|
||||
{status}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="inline-flex px-2 py-0.5 text-xs font-medium rounded border {classes}">
|
||||
{status}
|
||||
</span>
|
||||
{/if}
|
||||
@@ -0,0 +1,28 @@
|
||||
<script>
|
||||
import { currentTheme, themes } from '../lib/themeStore.js'
|
||||
import { Combobox } from '../lib/components/ui/combobox'
|
||||
import { Palette } from 'lucide-svelte'
|
||||
|
||||
export let className = ''
|
||||
|
||||
const themeItems = Object.entries(themes).map(([value, theme]) => ({
|
||||
value,
|
||||
label: theme.name
|
||||
}))
|
||||
|
||||
function handleThemeChange(event) {
|
||||
currentTheme.setTheme(event.detail.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<Combobox
|
||||
items={themeItems}
|
||||
value={$currentTheme}
|
||||
placeholder="Appearance"
|
||||
className={className}
|
||||
on:change={handleThemeChange}
|
||||
>
|
||||
<svelte:fragment slot="icon">
|
||||
<Palette class="h-3.5 w-3.5 text-text-tertiary" />
|
||||
</svelte:fragment>
|
||||
</Combobox>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script>
|
||||
import { fly } from 'svelte/transition'
|
||||
import { toasts, removeToast } from '../lib/toastStore.js'
|
||||
import { X } from 'lucide-svelte'
|
||||
|
||||
const toneStyles = {
|
||||
info: 'border-white/10 bg-bg-card text-text-primary',
|
||||
success: 'border-green-500/30 bg-green-500/10 text-green-100',
|
||||
error: 'border-red-500/30 bg-red-500/10 text-red-100'
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="fixed right-4 top-4 z-50 space-y-2">
|
||||
{#each $toasts as toast (toast.id)}
|
||||
<div
|
||||
class={`flex items-center gap-3 rounded-xl border px-4 py-3 text-[12px] shadow-[0_12px_30px_rgba(0,0,0,0.35)] ${toneStyles[toast.tone] || toneStyles.info}`}
|
||||
in:fly={{ y: -8, duration: 160 }}
|
||||
out:fly={{ y: -8, duration: 160 }}
|
||||
role="status"
|
||||
>
|
||||
<span class="flex-1">{toast.message}</span>
|
||||
<button
|
||||
class="text-text-tertiary hover:text-white transition-colors"
|
||||
on:click={() => removeToast(toast.id)}
|
||||
aria-label="Dismiss notification"
|
||||
>
|
||||
<X class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -0,0 +1,180 @@
|
||||
<script>
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
|
||||
export let style = "sarcastic";
|
||||
|
||||
const quotes = {
|
||||
sarcastic: [
|
||||
"Oh, you're still here? How delightful.",
|
||||
"Scanning... because apparently you have nothing better to do.",
|
||||
"Looking for subtitles like it's 2005.",
|
||||
"This is fine. Everything is fine.",
|
||||
"Patience is a virtue you clearly possess.",
|
||||
"Working hard or hardly working? Same thing here.",
|
||||
"Your files aren't going anywhere. Neither am I.",
|
||||
"Just vibing while your CPU does the heavy lifting.",
|
||||
"One does not simply scan quickly.",
|
||||
"Plot twist: the subtitles were inside you all along.",
|
||||
"Loading... your patience.",
|
||||
"This scan is sponsored by existential dread.",
|
||||
],
|
||||
rude: [
|
||||
"Ugh, more files? Seriously?",
|
||||
"You could've organized these better, you know.",
|
||||
"Why are there so many files? Get a hobby.",
|
||||
"I don't get paid enough for this.",
|
||||
"Your naming conventions are a crime.",
|
||||
"This is taking forever because of YOUR mess.",
|
||||
"I've seen better file structures in a dumpster.",
|
||||
"Oh great, another scan. My favorite.",
|
||||
"Do you even know what you're looking for?",
|
||||
"These files are judging you. So am I.",
|
||||
"Scanning your questionable life choices.",
|
||||
"I hope you appreciate this. You won't.",
|
||||
],
|
||||
nice: [
|
||||
"Taking a moment to find your perfect subtitles.",
|
||||
"Good things come to those who wait.",
|
||||
"Every great movie deserves great subtitles.",
|
||||
"Preparing something wonderful for you.",
|
||||
"Almost there! Thanks for your patience.",
|
||||
"Finding the best matches just for you.",
|
||||
"Your movie night is about to get better.",
|
||||
"Working diligently behind the scenes.",
|
||||
"Great content is worth the wait.",
|
||||
"Making movie magic happen.",
|
||||
"Your subtitles are in good hands.",
|
||||
"Sit back and relax, we've got this.",
|
||||
],
|
||||
};
|
||||
|
||||
let displayedText = "";
|
||||
let animationFrame;
|
||||
let currentTimeout;
|
||||
let usedIndices = [];
|
||||
let isTyping = true;
|
||||
let mounted = false;
|
||||
|
||||
function getRandomQuote() {
|
||||
const styleQuotes = quotes[style] || quotes.sarcastic;
|
||||
|
||||
if (usedIndices.length >= styleQuotes.length) {
|
||||
usedIndices = [];
|
||||
}
|
||||
|
||||
let idx;
|
||||
do {
|
||||
idx = Math.floor(Math.random() * styleQuotes.length);
|
||||
} while (usedIndices.includes(idx));
|
||||
|
||||
usedIndices.push(idx);
|
||||
return styleQuotes[idx];
|
||||
}
|
||||
|
||||
function typeText(text, onComplete) {
|
||||
let index = 0;
|
||||
isTyping = true;
|
||||
|
||||
function typeNext() {
|
||||
if (!mounted) return;
|
||||
|
||||
if (index <= text.length) {
|
||||
displayedText = text.slice(0, index);
|
||||
index++;
|
||||
// Variable speed: faster for spaces, slower for punctuation
|
||||
const char = text[index - 1];
|
||||
let delay = 45;
|
||||
if (char === " ") delay = 25;
|
||||
else if ([".", ",", "!", "?"].includes(char)) delay = 120;
|
||||
|
||||
currentTimeout = setTimeout(() => {
|
||||
animationFrame = requestAnimationFrame(typeNext);
|
||||
}, delay);
|
||||
} else {
|
||||
isTyping = false;
|
||||
currentTimeout = setTimeout(onComplete, 2500);
|
||||
}
|
||||
}
|
||||
|
||||
animationFrame = requestAnimationFrame(typeNext);
|
||||
}
|
||||
|
||||
function eraseText(onComplete) {
|
||||
isTyping = false;
|
||||
|
||||
function eraseNext() {
|
||||
if (!mounted) return;
|
||||
|
||||
if (displayedText.length > 0) {
|
||||
displayedText = displayedText.slice(0, -1);
|
||||
currentTimeout = setTimeout(() => {
|
||||
animationFrame = requestAnimationFrame(eraseNext);
|
||||
}, 20);
|
||||
} else {
|
||||
currentTimeout = setTimeout(onComplete, 200);
|
||||
}
|
||||
}
|
||||
|
||||
animationFrame = requestAnimationFrame(eraseNext);
|
||||
}
|
||||
|
||||
function runCycle() {
|
||||
if (!mounted) return;
|
||||
const quote = getRandomQuote();
|
||||
typeText(quote, () => {
|
||||
if (!mounted) return;
|
||||
eraseText(() => {
|
||||
if (!mounted) return;
|
||||
runCycle();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
if (animationFrame) cancelAnimationFrame(animationFrame);
|
||||
if (currentTimeout) clearTimeout(currentTimeout);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
mounted = true;
|
||||
runCycle();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
mounted = false;
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// Handle style changes
|
||||
let prevStyle = style;
|
||||
$: if (mounted && style !== prevStyle) {
|
||||
prevStyle = style;
|
||||
cleanup();
|
||||
usedIndices = [];
|
||||
displayedText = "";
|
||||
runCycle();
|
||||
}
|
||||
</script>
|
||||
|
||||
<p class="text-[13px] text-text-secondary mb-1 min-h-[1.5em]">
|
||||
{displayedText}<span
|
||||
class="inline-block w-[2px] h-[1em] bg-text-secondary ml-[1px] align-middle"
|
||||
class:animate-blink={!isTyping}
|
||||
></span>
|
||||
</p>
|
||||
|
||||
<style>
|
||||
@keyframes blink {
|
||||
0%,
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
51%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
.animate-blink {
|
||||
animation: blink 1s infinite;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,242 @@
|
||||
<script>
|
||||
import { AlertTriangle, ArrowRight } from "lucide-svelte";
|
||||
|
||||
export let settings = {};
|
||||
export let saving = false;
|
||||
export let onSave;
|
||||
|
||||
let stripKeywords = settings.strip_keywords !== false;
|
||||
let cleanSubtitleContent = settings.clean_subtitle_content !== false;
|
||||
|
||||
// Example filenames to demonstrate the cleaning
|
||||
const filenameExamples = [
|
||||
{ before: "Movie.2024.1080p.BluRay.x264-YTS", after: "Movie (2024)" },
|
||||
{
|
||||
before: "The.Matrix.1999.REMASTERED.2160p.UHD",
|
||||
after: "The Matrix (1999)",
|
||||
},
|
||||
{ before: "Inception.2010.HDRip.XviD-RARBG", after: "Inception (2010)" },
|
||||
];
|
||||
|
||||
// Example subtitle content cleaning
|
||||
const contentExamples = [
|
||||
{ before: "Hello there. www.YTS.mx", after: "Hello there." },
|
||||
{ before: "Subtitles by OpenSubtitles", after: "(removed)" },
|
||||
{ before: "Downloaded from RARBG", after: "(removed)" },
|
||||
];
|
||||
|
||||
// Keywords that get stripped from filenames
|
||||
const filenameKeywords = [
|
||||
{ name: "Quality", examples: ["480p", "720p", "1080p", "4K", "HDR"] },
|
||||
{ name: "Source", examples: ["BluRay", "WEBRip", "DVDRip", "HDTV"] },
|
||||
{ name: "Codecs", examples: ["x264", "x265", "HEVC", "AAC", "DTS"] },
|
||||
{ name: "Groups", examples: ["YTS", "RARBG", "EZTV", "PSA"] },
|
||||
];
|
||||
|
||||
// Keywords removed from subtitle content
|
||||
const contentKeywords = [
|
||||
{
|
||||
name: "Sites",
|
||||
examples: ["YTS.mx", "RARBG", "OpenSubtitles", "Subscene"],
|
||||
},
|
||||
{
|
||||
name: "Watermarks",
|
||||
examples: ["Subtitles by", "Synced by", "Downloaded from"],
|
||||
},
|
||||
{
|
||||
name: "Promo",
|
||||
examples: ["Support us", "Get more subtitles", "Visit us at"],
|
||||
},
|
||||
];
|
||||
|
||||
function handleSubmit() {
|
||||
onSave({
|
||||
strip_keywords: stripKeywords,
|
||||
clean_subtitle_content: cleanSubtitleContent,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<form on:submit|preventDefault={handleSubmit} class="space-y-10">
|
||||
<!-- Section 1: Filename Cleaning -->
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-text-primary">Filename Cleaning</h2>
|
||||
<p class="text-[13px] text-text-secondary mb-6">
|
||||
Clean up movie filenames before searching for metadata.
|
||||
</p>
|
||||
|
||||
<!-- Toggle -->
|
||||
<div class="p-5 bg-bg-secondary border border-border rounded-xl mb-5">
|
||||
<label class="flex items-start justify-between gap-4 rounded-xl border border-border bg-bg-secondary/40 px-4 py-3">
|
||||
<div class="flex-1">
|
||||
<div class="text-[14px] font-medium mb-1">
|
||||
Strip Filename Keywords
|
||||
</div>
|
||||
<div class="text-[12px] text-text-tertiary leading-relaxed">
|
||||
Remove quality indicators (1080p, BluRay), codecs (x264, HEVC), and
|
||||
release group names from filenames before looking up movie
|
||||
information.
|
||||
</div>
|
||||
</div>
|
||||
<span class="relative mt-0.5 inline-flex items-center">
|
||||
<input type="checkbox" bind:checked={stripKeywords} class="sr-only peer" />
|
||||
<span class="h-6 w-11 rounded-full border border-border bg-bg-card transition-colors peer-checked:bg-accent peer-checked:border-accent/60"></span>
|
||||
<span class="absolute left-0.5 h-5 w-5 rounded-full bg-text-tertiary transition-transform peer-checked:translate-x-5 peer-checked:bg-bg-primary"></span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Collapsible Details -->
|
||||
{#if stripKeywords}
|
||||
<div class="space-y-4 pl-2 border-l-2 border-border ml-2">
|
||||
<!-- Example Transformations -->
|
||||
<div class="bg-bg-card border border-border rounded-xl overflow-hidden">
|
||||
<div class="px-4 py-2.5 border-b border-border bg-bg-secondary">
|
||||
<span
|
||||
class="text-[11px] font-medium text-text-secondary uppercase tracking-wide"
|
||||
>Examples</span
|
||||
>
|
||||
</div>
|
||||
<div class="divide-y divide-border">
|
||||
{#each filenameExamples as example}
|
||||
<div class="px-4 py-2.5 flex items-center gap-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<code class="text-[11px] text-text-tertiary break-all"
|
||||
>{example.before}</code
|
||||
>
|
||||
</div>
|
||||
<ArrowRight class="w-3.5 h-3.5 text-text-tertiary flex-shrink-0" />
|
||||
<div class="flex-shrink-0">
|
||||
<code class="text-[11px] text-green-400 font-medium"
|
||||
>{example.after}</code
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Keywords List -->
|
||||
<div class="bg-bg-card border border-border rounded-xl p-4">
|
||||
<div class="flex flex-wrap gap-x-6 gap-y-2">
|
||||
{#each filenameKeywords as category}
|
||||
<div>
|
||||
<span
|
||||
class="text-[10px] font-medium text-text-secondary uppercase"
|
||||
>{category.name}:</span
|
||||
>
|
||||
<span class="text-[11px] text-text-tertiary ml-1.5"
|
||||
>{category.examples.join(", ")}</span
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Section 2: Subtitle Content Cleaning -->
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-text-primary">Subtitle Content Cleaning</h2>
|
||||
<p class="text-[13px] text-text-secondary mb-6">
|
||||
Remove embedded ads and watermarks from inside subtitle text.
|
||||
</p>
|
||||
|
||||
<!-- Toggle -->
|
||||
<div class="p-5 bg-bg-secondary border border-border rounded-xl mb-5">
|
||||
<label class="flex items-start justify-between gap-4 rounded-xl border border-border bg-bg-secondary/40 px-4 py-3">
|
||||
<div class="flex-1">
|
||||
<div class="text-[14px] font-medium mb-1">Remove Subtitle Ads</div>
|
||||
<div class="text-[12px] text-text-tertiary leading-relaxed">
|
||||
Automatically remove release group watermarks (YTS, RARBG), subtitle
|
||||
site ads (OpenSubtitles), and promotional text embedded in the
|
||||
actual subtitle dialogue.
|
||||
</div>
|
||||
</div>
|
||||
<span class="relative mt-0.5 inline-flex items-center">
|
||||
<input type="checkbox" bind:checked={cleanSubtitleContent} class="sr-only peer" />
|
||||
<span class="h-6 w-11 rounded-full border border-border bg-bg-card transition-colors peer-checked:bg-accent peer-checked:border-accent/60"></span>
|
||||
<span class="absolute left-0.5 h-5 w-5 rounded-full bg-text-tertiary transition-transform peer-checked:translate-x-5 peer-checked:bg-bg-primary"></span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Collapsible Details -->
|
||||
{#if cleanSubtitleContent}
|
||||
<div class="space-y-4 pl-2 border-l-2 border-border ml-2">
|
||||
<!-- Example Transformations -->
|
||||
<div class="bg-bg-card border border-border rounded-xl overflow-hidden">
|
||||
<div class="px-4 py-2.5 border-b border-border bg-bg-secondary">
|
||||
<span
|
||||
class="text-[11px] font-medium text-text-secondary uppercase tracking-wide"
|
||||
>Examples</span
|
||||
>
|
||||
</div>
|
||||
<div class="divide-y divide-border">
|
||||
{#each contentExamples as example}
|
||||
<div class="px-4 py-2.5 flex items-center gap-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<code class="text-[11px] text-text-tertiary break-all"
|
||||
>{example.before}</code
|
||||
>
|
||||
</div>
|
||||
<ArrowRight class="w-3.5 h-3.5 text-text-tertiary flex-shrink-0" />
|
||||
<div class="flex-shrink-0">
|
||||
<code
|
||||
class="text-[11px] {example.after === '(removed)'
|
||||
? 'text-red-400'
|
||||
: 'text-green-400'} font-medium">{example.after}</code
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Keywords List -->
|
||||
<div class="bg-bg-card border border-border rounded-xl p-4">
|
||||
<div class="flex flex-wrap gap-x-6 gap-y-2">
|
||||
{#each contentKeywords as category}
|
||||
<div>
|
||||
<span
|
||||
class="text-[10px] font-medium text-text-secondary uppercase"
|
||||
>{category.name}:</span
|
||||
>
|
||||
<span class="text-[11px] text-text-tertiary ml-1.5"
|
||||
>{category.examples.join(", ")}</span
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Note about timing -->
|
||||
<div
|
||||
class="flex items-start gap-3 p-4 bg-amber-500/5 border border-amber-500/20 rounded-xl"
|
||||
>
|
||||
<AlertTriangle class="w-4 h-4 text-amber-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<div class="text-[12px] font-medium text-amber-300 mb-1">
|
||||
Modifies subtitle content
|
||||
</div>
|
||||
<div class="text-[11px] text-amber-300/70 leading-relaxed">
|
||||
This setting will modify the actual text inside your subtitle file
|
||||
to remove ads. Subtitle timing is never changed - only ad text is
|
||||
removed or entire ad blocks are deleted.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
class="px-7 py-3.5 bg-text-primary hover:opacity-90 disabled:opacity-30 disabled:cursor-not-allowed
|
||||
text-bg-primary text-[13px] font-medium rounded-xl transition-all"
|
||||
>
|
||||
{saving ? "Saving..." : "Save Changes"}
|
||||
</button>
|
||||
</form>
|
||||
@@ -0,0 +1,363 @@
|
||||
<script>
|
||||
export let settings = {};
|
||||
export let saving = false;
|
||||
export let onSave;
|
||||
|
||||
let defaultDirectory = settings.default_directory || '';
|
||||
let duration = settings.duration ?? 40;
|
||||
let insertionPosition = settings.insertion_position || 'start';
|
||||
let quoteStyle = settings.quote_style || 'sarcastic';
|
||||
|
||||
// Subtitle formatting options
|
||||
let titleBold = settings.subtitle_title_bold !== false;
|
||||
let plotItalic = settings.subtitle_plot_italic !== false;
|
||||
let showDirector = settings.subtitle_show_director === true;
|
||||
let showActors = settings.subtitle_show_actors === true;
|
||||
let showReleased = settings.subtitle_show_released === true;
|
||||
let showGenre = settings.subtitle_show_genre === true;
|
||||
let showPreview = false;
|
||||
|
||||
function handleSubmit() {
|
||||
onSave({
|
||||
default_directory: defaultDirectory,
|
||||
duration: parseInt(duration),
|
||||
insertion_position: insertionPosition,
|
||||
quote_style: quoteStyle,
|
||||
subtitle_title_bold: titleBold,
|
||||
subtitle_plot_italic: plotItalic,
|
||||
subtitle_show_director: showDirector,
|
||||
subtitle_show_actors: showActors,
|
||||
subtitle_show_released: showReleased,
|
||||
subtitle_show_genre: showGenre
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<form on:submit|preventDefault={handleSubmit} class="space-y-8">
|
||||
<div>
|
||||
<div class="rounded-xl border border-red-500/30 bg-red-500/5 p-6 space-y-4 mb-6">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-red-200">Support Sublogue ❤️</h3>
|
||||
<p class="text-[12px] text-red-200/70">
|
||||
Matt is a single developer of Sublogue and Shelfarr — your support and stars on GitHub help me keep going.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<button type="button" class="flex-1 min-w-[220px] px-4 py-3 rounded-xl text-[12px] font-medium border border-red-500/30 bg-bg-card/70 text-text-secondary hover:text-red-100 hover:bg-red-500/10 hover:border-red-500/60 transition-all">
|
||||
<div class="text-[13px] font-semibold text-text-primary">Buy me coffee</div>
|
||||
<div class="text-[11px] text-text-tertiary">Small tip · $4.99</div>
|
||||
</button>
|
||||
<button type="button" class="flex-1 min-w-[220px] px-4 py-3 rounded-xl text-[12px] font-medium border border-red-500/30 bg-bg-card/70 text-text-secondary hover:text-red-100 hover:bg-red-500/10 hover:border-red-500/60 transition-all">
|
||||
<div class="text-[13px] font-semibold text-text-primary">Buy me lunch</div>
|
||||
<div class="text-[11px] text-text-tertiary">Medium tip · $9.99</div>
|
||||
</button>
|
||||
<button type="button" class="flex-1 min-w-[220px] px-4 py-3 rounded-xl text-[12px] font-medium border border-red-500/30 bg-bg-card/70 text-text-secondary hover:text-red-100 hover:bg-red-500/10 hover:border-red-500/60 transition-all">
|
||||
<div class="text-[13px] font-semibold text-text-primary">Buy me dinner</div>
|
||||
<div class="text-[11px] text-text-tertiary">Large tip · $19.99</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-[11px] text-text-tertiary">
|
||||
Every bit keeps Sublogue shipping.
|
||||
</p>
|
||||
<a
|
||||
href="https://www.buymeacoffee.com/sublogue"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="px-4 py-2 rounded-lg border border-red-500/30 text-[12px] text-text-secondary hover:text-white hover:bg-bg-hover transition-all"
|
||||
>
|
||||
Donate
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="text-lg font-semibold text-text-primary">General Settings</h2>
|
||||
<p class="text-[13px] text-text-secondary mb-6">
|
||||
Control how scans run and where summaries are placed.
|
||||
</p>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="space-y-3">
|
||||
<label for="directory" class="block text-[13px] font-medium text-text-primary">
|
||||
Default Directory
|
||||
</label>
|
||||
<input
|
||||
id="directory"
|
||||
type="text"
|
||||
bind:value={defaultDirectory}
|
||||
placeholder="C:\Movies"
|
||||
class="w-full px-4 py-3.5 bg-bg-card border border-border rounded-xl
|
||||
text-[13px] font-mono placeholder:text-text-tertiary
|
||||
focus:outline-none focus:border-white/25 focus:ring-2 focus:ring-ring transition-all"
|
||||
/>
|
||||
<p class="text-[12px] text-text-secondary">
|
||||
Default directory to scan for subtitle files
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<label class="block text-[13px] font-medium text-text-primary">
|
||||
Subtitle Item Duration
|
||||
</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each [
|
||||
{ value: 0, label: 'Auto', desc: 'Smart' },
|
||||
{ value: 15, label: '15s', desc: 'Quick' },
|
||||
{ value: 30, label: '30s', desc: 'Short' },
|
||||
{ value: 45, label: '45s', desc: 'Standard' },
|
||||
{ value: 60, label: '60s', desc: 'Extended' },
|
||||
{ value: 90, label: '90s', desc: 'Long' }
|
||||
] as preset}
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => duration = preset.value}
|
||||
class="px-3 py-1.5 rounded-full text-[12px] font-medium transition-all border
|
||||
{duration === preset.value
|
||||
? 'bg-text-primary text-bg-primary border-white'
|
||||
: 'bg-bg-card text-text-secondary hover:bg-bg-hover hover:text-text-primary border-border'}"
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="text-[12px] text-text-secondary">
|
||||
Sets the target duration per generated subtitle item. Auto lets Sublogue choose based on reading speed.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<label class="block text-[13px] font-medium text-text-primary">
|
||||
Insertion Position
|
||||
</label>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => insertionPosition = 'start'}
|
||||
class="flex flex-col items-center gap-1 px-3 py-2.5 rounded-xl text-center transition-all border
|
||||
{insertionPosition === 'start'
|
||||
? 'bg-text-primary text-bg-primary border-white'
|
||||
: 'bg-bg-card text-text-secondary hover:bg-bg-hover border-border'}"
|
||||
>
|
||||
<span class="text-[12px] font-medium">Start</span>
|
||||
<span class="text-[10px] {insertionPosition === 'start' ? 'text-bg-primary/70' : 'text-text-tertiary'}">Before all subs</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => insertionPosition = 'end'}
|
||||
class="flex flex-col items-center gap-1 px-3 py-2.5 rounded-xl text-center transition-all border
|
||||
{insertionPosition === 'end'
|
||||
? 'bg-text-primary text-bg-primary border-white'
|
||||
: 'bg-bg-card text-text-secondary hover:bg-bg-hover border-border'}"
|
||||
>
|
||||
<span class="text-[12px] font-medium">End</span>
|
||||
<span class="text-[10px] {insertionPosition === 'end' ? 'text-bg-primary/70' : 'text-text-tertiary'}">After credits</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => insertionPosition = 'index'}
|
||||
class="flex flex-col items-center gap-1 px-3 py-2.5 rounded-xl text-center transition-all border
|
||||
{insertionPosition === 'index'
|
||||
? 'bg-text-primary text-bg-primary border-white'
|
||||
: 'bg-bg-card text-text-secondary hover:bg-bg-hover border-border'}"
|
||||
>
|
||||
<span class="text-[12px] font-medium">Index 1</span>
|
||||
<span class="text-[10px] {insertionPosition === 'index' ? 'text-bg-primary/70' : 'text-text-tertiary'}">First position</span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-[12px] text-text-secondary">
|
||||
Where to insert the plot summary in the subtitle file
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<label class="block text-[13px] font-medium text-text-primary">
|
||||
Waiting Quote Style
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => quoteStyle = 'sarcastic'}
|
||||
class="flex-1 px-4 py-3 rounded-lg text-[13px] font-medium transition-all border
|
||||
{quoteStyle === 'sarcastic'
|
||||
? 'bg-text-primary text-bg-primary border-white shadow-[0_1px_3px_rgba(0,0,0,0.3)]'
|
||||
: 'bg-bg-card text-text-secondary hover:bg-bg-hover hover:text-text-primary border-border'}"
|
||||
>
|
||||
Sarcastic
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => quoteStyle = 'rude'}
|
||||
class="flex-1 px-4 py-3 rounded-lg text-[13px] font-medium transition-all border
|
||||
{quoteStyle === 'rude'
|
||||
? 'bg-text-primary text-bg-primary border-white shadow-[0_1px_3px_rgba(0,0,0,0.3)]'
|
||||
: 'bg-bg-card text-text-secondary hover:bg-bg-hover hover:text-text-primary border-border'}"
|
||||
>
|
||||
Rude
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => quoteStyle = 'nice'}
|
||||
class="flex-1 px-4 py-3 rounded-lg text-[13px] font-medium transition-all border
|
||||
{quoteStyle === 'nice'
|
||||
? 'bg-text-primary text-bg-primary border-white shadow-[0_1px_3px_rgba(0,0,0,0.3)]'
|
||||
: 'bg-bg-card text-text-secondary hover:bg-bg-hover hover:text-text-primary border-border'}"
|
||||
>
|
||||
Nice
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-[12px] text-text-secondary">
|
||||
Tone of random quotes shown while waiting for scans
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Subtitle Formatting Section -->
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-text-primary">Subtitle Formatting</h2>
|
||||
<p class="text-[13px] text-text-secondary mb-6">
|
||||
Tune how metadata appears inside subtitle headers.
|
||||
</p>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Text Styling -->
|
||||
<div class="space-y-3">
|
||||
<label class="block text-[13px] font-medium mb-3 text-text-primary">Text Styling</label>
|
||||
|
||||
<label class="flex items-center justify-between gap-4 rounded-xl border border-border bg-bg-secondary/40 px-4 py-3">
|
||||
<div>
|
||||
<div class="text-[13px] font-medium">Bold Title</div>
|
||||
<div class="text-[11px] text-text-tertiary mt-0.5">
|
||||
Display movie title in <strong>bold</strong> text
|
||||
</div>
|
||||
</div>
|
||||
<span class="relative inline-flex items-center">
|
||||
<input type="checkbox" bind:checked={titleBold} class="sr-only peer" />
|
||||
<span class="h-6 w-11 rounded-full border border-border bg-bg-card transition-colors peer-checked:bg-accent peer-checked:border-accent/60"></span>
|
||||
<span class="absolute left-0.5 h-5 w-5 rounded-full bg-text-tertiary transition-transform peer-checked:translate-x-5 peer-checked:bg-bg-primary"></span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center justify-between gap-4 rounded-xl border border-border bg-bg-secondary/40 px-4 py-3">
|
||||
<div>
|
||||
<div class="text-[13px] font-medium">Italic Plot</div>
|
||||
<div class="text-[11px] text-text-tertiary mt-0.5">
|
||||
Display plot summary in <em>italic</em> text
|
||||
</div>
|
||||
</div>
|
||||
<span class="relative inline-flex items-center">
|
||||
<input type="checkbox" bind:checked={plotItalic} class="sr-only peer" />
|
||||
<span class="h-6 w-11 rounded-full border border-border bg-bg-card transition-colors peer-checked:bg-accent peer-checked:border-accent/60"></span>
|
||||
<span class="absolute left-0.5 h-5 w-5 rounded-full bg-text-tertiary transition-transform peer-checked:translate-x-5 peer-checked:bg-bg-primary"></span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Additional Info -->
|
||||
<div class="space-y-3">
|
||||
<label class="block text-[13px] font-medium mb-3 text-text-primary">Additional Information</label>
|
||||
<p class="text-[12px] text-text-secondary -mt-2 mb-3">
|
||||
Show extra metadata in the subtitle header
|
||||
</p>
|
||||
|
||||
<label class="flex items-center justify-between gap-4 rounded-xl border border-border bg-bg-secondary/40 px-4 py-3">
|
||||
<div class="text-[13px] font-medium">Director</div>
|
||||
<span class="relative inline-flex items-center">
|
||||
<input type="checkbox" bind:checked={showDirector} class="sr-only peer" />
|
||||
<span class="h-6 w-11 rounded-full border border-border bg-bg-card transition-colors peer-checked:bg-accent peer-checked:border-accent/60"></span>
|
||||
<span class="absolute left-0.5 h-5 w-5 rounded-full bg-text-tertiary transition-transform peer-checked:translate-x-5 peer-checked:bg-bg-primary"></span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center justify-between gap-4 rounded-xl border border-border bg-bg-secondary/40 px-4 py-3">
|
||||
<div class="text-[13px] font-medium">Cast</div>
|
||||
<span class="relative inline-flex items-center">
|
||||
<input type="checkbox" bind:checked={showActors} class="sr-only peer" />
|
||||
<span class="h-6 w-11 rounded-full border border-border bg-bg-card transition-colors peer-checked:bg-accent peer-checked:border-accent/60"></span>
|
||||
<span class="absolute left-0.5 h-5 w-5 rounded-full bg-text-tertiary transition-transform peer-checked:translate-x-5 peer-checked:bg-bg-primary"></span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center justify-between gap-4 rounded-xl border border-border bg-bg-secondary/40 px-4 py-3">
|
||||
<div class="text-[13px] font-medium">Release Date</div>
|
||||
<span class="relative inline-flex items-center">
|
||||
<input type="checkbox" bind:checked={showReleased} class="sr-only peer" />
|
||||
<span class="h-6 w-11 rounded-full border border-border bg-bg-card transition-colors peer-checked:bg-accent peer-checked:border-accent/60"></span>
|
||||
<span class="absolute left-0.5 h-5 w-5 rounded-full bg-text-tertiary transition-transform peer-checked:translate-x-5 peer-checked:bg-bg-primary"></span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center justify-between gap-4 rounded-xl border border-border bg-bg-secondary/40 px-4 py-3">
|
||||
<div class="text-[13px] font-medium">Genre</div>
|
||||
<span class="relative inline-flex items-center">
|
||||
<input type="checkbox" bind:checked={showGenre} class="sr-only peer" />
|
||||
<span class="h-6 w-11 rounded-full border border-border bg-bg-card transition-colors peer-checked:bg-accent peer-checked:border-accent/60"></span>
|
||||
<span class="absolute left-0.5 h-5 w-5 rounded-full bg-text-tertiary transition-transform peer-checked:translate-x-5 peer-checked:bg-bg-primary"></span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-border bg-bg-secondary/30 p-6 space-y-4">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-text-primary">Preview formatting</h3>
|
||||
<p class="text-[12px] text-text-tertiary">
|
||||
Preview how the header and plot will look with your current choices.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => (showPreview = !showPreview)}
|
||||
class="px-4 py-2 rounded-lg border border-border text-[12px] text-text-secondary hover:text-white hover:bg-bg-hover transition-all"
|
||||
>
|
||||
{showPreview ? 'Hide preview' : 'Show preview'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if showPreview}
|
||||
<div class="rounded-xl border border-border bg-bg-card p-4 text-[12px] text-text-secondary space-y-3">
|
||||
<div class="space-y-1">
|
||||
<div class="text-text-primary">
|
||||
{#if titleBold}
|
||||
<strong>Sample Movie (2024)</strong>
|
||||
{:else}
|
||||
Sample Movie (2024)
|
||||
{/if}
|
||||
</div>
|
||||
<div class="text-text-tertiary">IMDb: 7.8 • RT: 91% • 118 min</div>
|
||||
{#if showDirector}
|
||||
<div>Director: Jane Doe</div>
|
||||
{/if}
|
||||
{#if showActors}
|
||||
<div>Cast: Alex Actor, Casey Star, Morgan Lee</div>
|
||||
{/if}
|
||||
{#if showReleased}
|
||||
<div>Release Date: May 12, 2024</div>
|
||||
{/if}
|
||||
{#if showGenre}
|
||||
<div>Genre: Drama, Sci-Fi</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="border-t border-border pt-3 text-text-secondary">
|
||||
{#if plotItalic}
|
||||
<em>Plot: A calm AI awakens in a distant archive and learns to rewrite forgotten stories.</em>
|
||||
{:else}
|
||||
Plot: A calm AI awakens in a distant archive and learns to rewrite forgotten stories.
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
class="px-7 py-3.5 bg-text-primary hover:opacity-90 disabled:opacity-30 disabled:cursor-not-allowed
|
||||
text-bg-primary text-[13px] font-medium rounded-xl transition-all"
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
</form>
|
||||
@@ -0,0 +1,572 @@
|
||||
<script>
|
||||
import { onMount } from "svelte";
|
||||
import { getIntegrationUsage } from "../../lib/api.js";
|
||||
import { Info } from "lucide-svelte";
|
||||
|
||||
export let settings = {};
|
||||
export let saving = false;
|
||||
export let onSave;
|
||||
|
||||
let omdbApiKey = settings.omdb_api_key || settings.api_key || "";
|
||||
let tmdbApiKey = settings.tmdb_api_key || "";
|
||||
let omdbEnabled = settings.omdb_enabled ?? false;
|
||||
let tmdbEnabled = settings.tmdb_enabled ?? false;
|
||||
let tvmazeEnabled = settings.tvmaze_enabled ?? false;
|
||||
let usage = null;
|
||||
let loadingUsage = false;
|
||||
let showOmdbHelp = false;
|
||||
let showTmdbHelp = false;
|
||||
let showTvmazeHelp = false;
|
||||
|
||||
/* ===============================
|
||||
Lifecycle
|
||||
=============================== */
|
||||
|
||||
onMount(async () => {
|
||||
await loadUsage();
|
||||
});
|
||||
|
||||
function toggleOmdbHelp() {
|
||||
showOmdbHelp = !showOmdbHelp;
|
||||
if (showOmdbHelp) {
|
||||
showTmdbHelp = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleTmdbHelp() {
|
||||
showTmdbHelp = !showTmdbHelp;
|
||||
if (showTmdbHelp) {
|
||||
showOmdbHelp = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleTvmazeHelp() {
|
||||
showTvmazeHelp = !showTvmazeHelp;
|
||||
if (showTvmazeHelp) {
|
||||
showOmdbHelp = false;
|
||||
showTmdbHelp = false;
|
||||
}
|
||||
}
|
||||
|
||||
function clickOutside(node, handler) {
|
||||
if (typeof handler !== "function") return { destroy() {} };
|
||||
const onPointerDown = (event) => {
|
||||
if (!node.contains(event.target)) handler(event);
|
||||
};
|
||||
document.addEventListener("mousedown", onPointerDown, true);
|
||||
document.addEventListener("touchstart", onPointerDown, true);
|
||||
return {
|
||||
destroy() {
|
||||
document.removeEventListener("mousedown", onPointerDown, true);
|
||||
document.removeEventListener("touchstart", onPointerDown, true);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function loadUsage() {
|
||||
loadingUsage = true;
|
||||
try {
|
||||
const response = await getIntegrationUsage();
|
||||
usage = response.usage;
|
||||
} catch (err) {
|
||||
console.error("Failed to load usage stats:", err);
|
||||
} finally {
|
||||
loadingUsage = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
onSave({
|
||||
omdb_api_key: omdbApiKey,
|
||||
tmdb_api_key: tmdbApiKey,
|
||||
omdb_enabled: omdbEnabled,
|
||||
tmdb_enabled: tmdbEnabled,
|
||||
tvmaze_enabled: tvmazeEnabled,
|
||||
});
|
||||
}
|
||||
|
||||
/* ===============================
|
||||
Usage helpers
|
||||
=============================== */
|
||||
|
||||
function usagePercent(current, limit) {
|
||||
if (!limit || limit <= 0) return 0;
|
||||
return Math.min(100, Math.round((current / limit) * 100));
|
||||
}
|
||||
|
||||
function usageState(percent) {
|
||||
if (percent < 80) return "ok";
|
||||
if (percent < 95) return "warn";
|
||||
return "critical";
|
||||
}
|
||||
|
||||
function usageBarColor(state) {
|
||||
if (state === "ok") return "bg-green-500";
|
||||
if (state === "warn") return "bg-yellow-500";
|
||||
return "bg-red-500";
|
||||
}
|
||||
|
||||
function usageLabel(state) {
|
||||
if (state === "ok") return "OK";
|
||||
if (state === "warn") return "Warning";
|
||||
return "Critical";
|
||||
}
|
||||
|
||||
function formatResetTime(isoString) {
|
||||
const date = new Date(isoString);
|
||||
const now = new Date();
|
||||
const diff = date - now;
|
||||
|
||||
if (diff <= 0) return "Now";
|
||||
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
|
||||
|
||||
if (hours > 0) return `${hours}h ${minutes}m`;
|
||||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
/* ===============================
|
||||
Reactive usage values
|
||||
=============================== */
|
||||
|
||||
$: omdbUsage = usage?.omdb;
|
||||
$: tmdbUsage = usage?.tmdb;
|
||||
$: tvmazeUsage = usage?.tvmaze;
|
||||
|
||||
$: omdbPercent = omdbUsage
|
||||
? usagePercent(omdbUsage.total_calls_24h, omdbUsage.limit)
|
||||
: 0;
|
||||
|
||||
$: tmdbPercent = tmdbUsage
|
||||
? usagePercent(tmdbUsage.total_calls_24h, tmdbUsage.limit)
|
||||
: 0;
|
||||
$: tvmazePercent = tvmazeUsage
|
||||
? usagePercent(tvmazeUsage.total_calls_24h, tvmazeUsage.limit)
|
||||
: 0;
|
||||
|
||||
$: omdbState = usageState(omdbPercent);
|
||||
$: tmdbState = usageState(tmdbPercent);
|
||||
$: tvmazeState = usageState(tvmazePercent);
|
||||
$: enabledCount =
|
||||
(omdbEnabled ? 1 : 0) + (tmdbEnabled ? 1 : 0) + (tvmazeEnabled ? 1 : 0);
|
||||
</script>
|
||||
|
||||
<form on:submit|preventDefault={handleSubmit} class="space-y-8">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-text-primary">Integrations</h2>
|
||||
<p class="text-[13px] text-text-secondary mb-8">
|
||||
Connect services to fetch movie and TV metadata
|
||||
</p>
|
||||
|
||||
{#if enabledCount === 0}
|
||||
<div class="mb-8 rounded-xl border border-border bg-bg-card p-6">
|
||||
<h3 class="text-[13px] font-medium text-text-primary mb-2">
|
||||
No integrations enabled
|
||||
</h3>
|
||||
<p class="text-[11px] text-text-tertiary mb-4">
|
||||
Add a provider to start fetching metadata.
|
||||
</p>
|
||||
<div class="flex flex-col sm:flex-row gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="px-4 py-2.5 rounded-lg border border-border bg-white/5 text-[12px] text-text-secondary hover:text-white hover:bg-bg-hover transition-colors"
|
||||
on:click={() => (omdbEnabled = true)}
|
||||
>
|
||||
Add OMDb
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="px-4 py-2.5 rounded-lg border border-border bg-white/5 text-[12px] text-text-secondary hover:text-white hover:bg-bg-hover transition-colors"
|
||||
on:click={() => (tmdbEnabled = true)}
|
||||
>
|
||||
Add TMDb
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="px-4 py-2.5 rounded-lg border border-border bg-white/5 text-[12px] text-text-secondary hover:text-white hover:bg-bg-hover transition-colors"
|
||||
on:click={() => (tvmazeEnabled = true)}
|
||||
>
|
||||
Add TVmaze
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="space-y-8">
|
||||
{#if enabledCount > 0}
|
||||
<div class="rounded-xl border border-border bg-bg-card p-4 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<div>
|
||||
<p class="text-[12px] font-medium text-text-primary">Add another integration</p>
|
||||
<p class="text-[11px] text-text-tertiary">Choose a provider to enable.</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#if !omdbEnabled}
|
||||
<button
|
||||
type="button"
|
||||
class="px-3 py-2 rounded-lg border border-border bg-white/5 text-[12px] text-text-secondary hover:text-white hover:bg-bg-hover transition-colors"
|
||||
on:click={() => (omdbEnabled = true)}
|
||||
>
|
||||
Add OMDb
|
||||
</button>
|
||||
{/if}
|
||||
{#if !tmdbEnabled}
|
||||
<button
|
||||
type="button"
|
||||
class="px-3 py-2 rounded-lg border border-border bg-white/5 text-[12px] text-text-secondary hover:text-white hover:bg-bg-hover transition-colors"
|
||||
on:click={() => (tmdbEnabled = true)}
|
||||
>
|
||||
Add TMDb
|
||||
</button>
|
||||
{/if}
|
||||
{#if !tvmazeEnabled}
|
||||
<button
|
||||
type="button"
|
||||
class="px-3 py-2 rounded-lg border border-border bg-white/5 text-[12px] text-text-secondary hover:text-white hover:bg-bg-hover transition-colors"
|
||||
on:click={() => (tvmazeEnabled = true)}
|
||||
>
|
||||
Add TVmaze
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ===============================
|
||||
OMDb Integration
|
||||
=============================== -->
|
||||
{#if omdbEnabled}
|
||||
<div class="border border-border rounded-xl p-6 space-y-4">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 class="text-[13px] font-medium">OMDb API</h3>
|
||||
<p class="text-[11px] text-text-tertiary mt-1">
|
||||
The Open Movie Database - Movie and series metadata
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="relative" use:clickOutside={() => (showOmdbHelp = false)}>
|
||||
<button
|
||||
type="button"
|
||||
class="h-8 w-8 rounded-full border border-border text-text-secondary hover:text-white hover:bg-bg-hover transition-all"
|
||||
on:click={toggleOmdbHelp}
|
||||
aria-label="How to get an OMDb API key"
|
||||
>
|
||||
<Info class="h-4 w-4 mx-auto" />
|
||||
</button>
|
||||
{#if showOmdbHelp}
|
||||
<div class="absolute right-0 mt-2 w-64 rounded-xl border border-border bg-bg-card p-4 text-[12px] text-text-secondary shadow-[0_12px_30px_rgba(0,0,0,0.35)] z-10">
|
||||
<p class="text-[12px] text-text-primary font-medium mb-1">Get an OMDb API key</p>
|
||||
<p class="text-[11px] text-text-tertiary mb-3">Create a free key in minutes.</p>
|
||||
<ol class="space-y-1 text-[11px] text-text-secondary">
|
||||
<li>Visit <a class="text-white hover:underline" href="https://www.omdbapi.com/apikey.aspx" target="_blank" rel="noopener noreferrer">omdbapi.com</a></li>
|
||||
<li>Request a key and confirm the email</li>
|
||||
<li>Paste the key here and save</li>
|
||||
</ol>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<span
|
||||
class="px-2 py-1 text-[10px] font-medium text-text-secondary border border-border rounded-lg uppercase tracking-wide"
|
||||
>
|
||||
Movies & Series
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-4 rounded-lg border border-border bg-bg-card px-4 py-3">
|
||||
<div>
|
||||
<p class="text-[12px] font-medium text-text-primary">Enable OMDb</p>
|
||||
<p class="text-[11px] text-text-tertiary">Use OMDb for movie and series metadata.</p>
|
||||
</div>
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" class="sr-only peer" bind:checked={omdbEnabled} />
|
||||
<span class="h-6 w-11 rounded-full border border-border bg-bg-card transition-colors peer-checked:bg-accent peer-checked:border-accent/60"></span>
|
||||
<span class="absolute left-0.5 h-5 w-5 rounded-full bg-text-tertiary transition-transform peer-checked:translate-x-5 peer-checked:bg-bg-primary"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label
|
||||
class="block text-[11px] font-medium text-text-secondary uppercase tracking-wide"
|
||||
>
|
||||
API Key
|
||||
</label>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
bind:value={omdbApiKey}
|
||||
placeholder="Enter your OMDb API key"
|
||||
disabled={!omdbEnabled}
|
||||
class="w-full px-4 py-3 bg-bg-card border border-border rounded-lg
|
||||
text-[13px] placeholder:text-text-tertiary
|
||||
focus:outline-none focus:border-white/30 transition-all disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if omdbUsage && omdbEnabled}
|
||||
<div class="pt-4 border-t border-border space-y-2">
|
||||
<div class="flex items-center justify-between text-[11px]">
|
||||
<span class="text-text-secondary uppercase tracking-wide">
|
||||
{usageLabel(omdbState)} · Usage (24h)
|
||||
</span>
|
||||
<span class="text-text-tertiary">
|
||||
Resets in {formatResetTime(omdbUsage.reset_time)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex items-center justify-between text-[11px]">
|
||||
<span class="text-text-tertiary">
|
||||
{omdbUsage.total_calls_24h} / {omdbUsage.limit} calls
|
||||
</span>
|
||||
<span class="text-text-tertiary">
|
||||
{omdbPercent}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="relative h-3 bg-bg-primary rounded-full overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-y-0 left-0 transition-all {usageBarColor(
|
||||
omdbState,
|
||||
)}"
|
||||
style="width: {omdbPercent}%"
|
||||
></div>
|
||||
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<span class="text-[10px] font-medium text-black/80">
|
||||
{omdbPercent}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ===============================
|
||||
TVmaze Integration
|
||||
=============================== -->
|
||||
{#if tvmazeEnabled}
|
||||
<div class="border border-border rounded-xl p-6 space-y-4">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 class="text-[13px] font-medium">TVmaze API</h3>
|
||||
<p class="text-[11px] text-text-tertiary mt-1">
|
||||
Free TV metadata for series and episodes (no API key required)
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="relative" use:clickOutside={() => (showTvmazeHelp = false)}>
|
||||
<button
|
||||
type="button"
|
||||
class="h-8 w-8 rounded-full border border-border text-text-secondary hover:text-white hover:bg-bg-hover transition-all"
|
||||
on:click={toggleTvmazeHelp}
|
||||
aria-label="TVmaze integration info"
|
||||
>
|
||||
<Info class="h-4 w-4 mx-auto" />
|
||||
</button>
|
||||
{#if showTvmazeHelp}
|
||||
<div class="absolute right-0 mt-2 w-64 rounded-xl border border-border bg-bg-card p-4 text-[12px] text-text-secondary shadow-[0_12px_30px_rgba(0,0,0,0.35)] z-10">
|
||||
<p class="text-[12px] text-text-primary font-medium mb-1">TVmaze quick start</p>
|
||||
<p class="text-[11px] text-text-tertiary mb-3">No account needed. Toggle on to enable TV lookups.</p>
|
||||
<ol class="space-y-1 text-[11px] text-text-secondary">
|
||||
<li>Turn on the TVmaze integration</li>
|
||||
<li>Pick TVmaze as a metadata source</li>
|
||||
<li>Scan and enrich TV episodes</li>
|
||||
</ol>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<span
|
||||
class="px-2 py-1 text-[10px] font-medium text-text-secondary border border-border rounded-lg uppercase tracking-wide"
|
||||
>
|
||||
TV Series
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-4 rounded-lg border border-border bg-bg-card px-4 py-3">
|
||||
<div>
|
||||
<p class="text-[12px] font-medium text-text-primary">Enable TVmaze</p>
|
||||
<p class="text-[11px] text-text-tertiary">Use TVmaze for series + episode plots.</p>
|
||||
</div>
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" class="sr-only peer" bind:checked={tvmazeEnabled} />
|
||||
<span class="h-6 w-11 rounded-full border border-border bg-bg-card transition-colors peer-checked:bg-accent peer-checked:border-accent/60"></span>
|
||||
<span class="absolute left-0.5 h-5 w-5 rounded-full bg-text-tertiary transition-transform peer-checked:translate-x-5 peer-checked:bg-bg-primary"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if tvmazeUsage && tvmazeEnabled}
|
||||
<div class="pt-4 border-t border-border space-y-2">
|
||||
<div class="flex items-center justify-between text-[11px]">
|
||||
<span class="text-text-secondary uppercase tracking-wide">
|
||||
{usageLabel(tvmazeState)} · Usage (24h)
|
||||
</span>
|
||||
<span class="text-text-tertiary">
|
||||
Resets in {formatResetTime(tvmazeUsage.reset_time)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex items-center justify-between text-[11px]">
|
||||
<span class="text-text-tertiary">
|
||||
{tvmazeUsage.total_calls_24h} / {tvmazeUsage.limit} calls
|
||||
</span>
|
||||
<span class="text-text-tertiary">
|
||||
{tvmazePercent}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="relative h-3 bg-bg-primary rounded-full overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-y-0 left-0 transition-all {usageBarColor(
|
||||
tvmazeState,
|
||||
)}"
|
||||
style="width: {tvmazePercent}%"
|
||||
></div>
|
||||
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<span class="text-[10px] font-medium text-black/80">
|
||||
{tvmazePercent}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ===============================
|
||||
TMDb Integration
|
||||
=============================== -->
|
||||
{#if tmdbEnabled}
|
||||
<div class="border border-border rounded-xl p-6 space-y-4">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 class="text-[13px] font-medium">TMDb API</h3>
|
||||
<p class="text-[11px] text-text-tertiary mt-1">
|
||||
The Movie Database - Comprehensive media database
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="relative" use:clickOutside={() => (showTmdbHelp = false)}>
|
||||
<button
|
||||
type="button"
|
||||
class="h-8 w-8 rounded-full border border-border text-text-secondary hover:text-white hover:bg-bg-hover transition-all"
|
||||
on:click={toggleTmdbHelp}
|
||||
aria-label="How to get a TMDb API key"
|
||||
>
|
||||
<Info class="h-4 w-4 mx-auto" />
|
||||
</button>
|
||||
{#if showTmdbHelp}
|
||||
<div class="absolute right-0 mt-2 w-64 rounded-xl border border-border bg-bg-card p-4 text-[12px] text-text-secondary shadow-[0_12px_30px_rgba(0,0,0,0.35)] z-10">
|
||||
<p class="text-[12px] text-text-primary font-medium mb-1">Get a TMDb API key</p>
|
||||
<p class="text-[11px] text-text-tertiary mb-3">Requires a free TMDb account.</p>
|
||||
<ol class="space-y-1 text-[11px] text-text-secondary">
|
||||
<li>Sign in at <a class="text-white hover:underline" href="https://www.themoviedb.org" target="_blank" rel="noopener noreferrer">themoviedb.org</a></li>
|
||||
<li>Go to Settings > API and request a key</li>
|
||||
<li>Paste the key here and save</li>
|
||||
</ol>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<span
|
||||
class="px-2 py-1 text-[10px] font-medium text-text-secondary border border-border rounded-lg uppercase tracking-wide"
|
||||
>
|
||||
Movies & Series
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-4 rounded-lg border border-border bg-bg-card px-4 py-3">
|
||||
<div>
|
||||
<p class="text-[12px] font-medium text-text-primary">Enable TMDb</p>
|
||||
<p class="text-[11px] text-text-tertiary">Use TMDb for movies and TV metadata.</p>
|
||||
</div>
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" class="sr-only peer" bind:checked={tmdbEnabled} />
|
||||
<span class="h-6 w-11 rounded-full border border-border bg-bg-card transition-colors peer-checked:bg-accent peer-checked:border-accent/60"></span>
|
||||
<span class="absolute left-0.5 h-5 w-5 rounded-full bg-text-tertiary transition-transform peer-checked:translate-x-5 peer-checked:bg-bg-primary"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label
|
||||
class="block text-[11px] font-medium text-text-secondary uppercase tracking-wide"
|
||||
>
|
||||
API Key
|
||||
</label>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
bind:value={tmdbApiKey}
|
||||
placeholder="Enter your TMDb API key"
|
||||
disabled={!tmdbEnabled}
|
||||
class="w-full px-4 py-3 bg-bg-card border border-border rounded-lg
|
||||
text-[13px] placeholder:text-text-tertiary
|
||||
focus:outline-none focus:border-white/30 transition-all disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if tmdbUsage && tmdbEnabled}
|
||||
<div class="pt-4 border-t border-border space-y-2">
|
||||
<div class="flex items-center justify-between text-[11px]">
|
||||
<span class="text-text-secondary uppercase tracking-wide">
|
||||
{usageLabel(tmdbState)} · Usage (24h)
|
||||
</span>
|
||||
<span class="text-text-tertiary">
|
||||
Resets in {formatResetTime(tmdbUsage.reset_time)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex items-center justify-between text-[11px]">
|
||||
<span class="text-text-tertiary">
|
||||
{tmdbUsage.total_calls_24h} / {tmdbUsage.limit} calls
|
||||
</span>
|
||||
<span class="text-text-tertiary">
|
||||
{tmdbPercent}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="relative h-3 bg-bg-primary rounded-full overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-y-0 left-0 transition-all {usageBarColor(
|
||||
tmdbState,
|
||||
)}"
|
||||
style="width: {tmdbPercent}%"
|
||||
></div>
|
||||
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<span class="text-[10px] font-medium text-black/80">
|
||||
{tmdbPercent}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
class="px-7 py-3.5 bg-white hover:bg-white/90 disabled:opacity-30 disabled:cursor-not-allowed
|
||||
text-black text-[13px] font-medium rounded-xl transition-all"
|
||||
>
|
||||
{saving ? "Saving…" : "Save Changes"}
|
||||
</button>
|
||||
</form>
|
||||
@@ -0,0 +1,123 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte'
|
||||
import { Button } from '../../lib/components/ui/button'
|
||||
import { Input } from '../../lib/components/ui/input'
|
||||
import { createScheduledScan } from '../../lib/api.js'
|
||||
import { addToast } from '../../lib/toastStore.js'
|
||||
|
||||
export let settings = {}
|
||||
|
||||
let directory = settings.default_directory || ''
|
||||
let scheduledFor = ''
|
||||
let creating = false
|
||||
let error = null
|
||||
let successMessage = null
|
||||
|
||||
function getLocalDateTimeValue(date) {
|
||||
const pad = (value) => String(value).padStart(2, '0')
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(
|
||||
date.getDate()
|
||||
)}T${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||
}
|
||||
|
||||
async function scheduleScan() {
|
||||
if (!directory || !scheduledFor) {
|
||||
error = 'Please choose a directory and scheduled time'
|
||||
successMessage = null
|
||||
return
|
||||
}
|
||||
creating = true
|
||||
error = null
|
||||
try {
|
||||
const scheduledIso = new Date(scheduledFor).toISOString()
|
||||
await createScheduledScan(directory, scheduledIso)
|
||||
successMessage = 'Scan scheduled. Track results in Scheduled Scans.'
|
||||
addToast({ message: 'Scheduled scan created.', tone: 'success' })
|
||||
} catch (err) {
|
||||
error = `Failed to schedule scan: ${err.message}`
|
||||
addToast({ message: error, tone: 'error' })
|
||||
} finally {
|
||||
creating = false
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const nextHour = new Date()
|
||||
nextHour.setHours(nextHour.getHours() + 1)
|
||||
scheduledFor = getLocalDateTimeValue(nextHour)
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-text-primary">Scheduled Scans</h2>
|
||||
<p class="text-[13px] text-text-secondary">
|
||||
Plan scans ahead of time and let them run in the background.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="px-5 py-4 bg-red-500/5 border border-red-500/20 rounded-xl">
|
||||
<p class="text-[13px] text-red-300">{error}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if successMessage}
|
||||
<div class="px-5 py-4 bg-green-500/5 border border-green-500/20 rounded-xl">
|
||||
<p class="text-[13px] text-green-300">{successMessage}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="rounded-xl border border-border bg-card p-6 space-y-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-text-primary">Schedule a scan</h3>
|
||||
<p class="text-[12px] text-text-tertiary">
|
||||
Use your default directory or customize a one-off scan target.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<label class="block text-[12px] font-medium text-text-primary">
|
||||
Directory
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
bind:value={directory}
|
||||
placeholder="C:\Movies or /media/movies"
|
||||
className="h-10 text-[12px] font-mono"
|
||||
/>
|
||||
<p class="text-[11px] text-text-tertiary">
|
||||
Default directory comes from General Settings.
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="block text-[12px] font-medium text-text-primary">
|
||||
Scheduled for
|
||||
</label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
bind:value={scheduledFor}
|
||||
className="h-10 text-[12px]"
|
||||
/>
|
||||
<p class="text-[11px] text-text-tertiary">
|
||||
Scheduled scans run once at the chosen time.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p class="text-[11px] text-text-tertiary">
|
||||
Results show in Scheduled Scans and the History tab.
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-9 px-4"
|
||||
on:click={scheduleScan}
|
||||
disabled={creating}
|
||||
>
|
||||
{creating ? 'Scheduling...' : 'Schedule Scan'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,199 @@
|
||||
<script>
|
||||
import { Button } from '../../lib/components/ui/button'
|
||||
import { resetSettings, clearHistory, clearCaches } from '../../lib/api.js'
|
||||
import { scanResults } from '../../lib/scanStore.js'
|
||||
import { addToast } from '../../lib/toastStore.js'
|
||||
|
||||
let keepApiKeys = true
|
||||
let runningReset = false
|
||||
let runningHistory = false
|
||||
let runningCaches = false
|
||||
let error = null
|
||||
let successMessage = null
|
||||
|
||||
function resetMessages() {
|
||||
error = null
|
||||
successMessage = null
|
||||
}
|
||||
|
||||
async function handleResetSettings() {
|
||||
resetMessages()
|
||||
runningReset = true
|
||||
try {
|
||||
await resetSettings(keepApiKeys)
|
||||
successMessage = 'Settings cleared successfully.'
|
||||
addToast({ message: successMessage, tone: 'success' })
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.removeItem('sublogue_onboarding_complete')
|
||||
}
|
||||
scanResults.clearResults()
|
||||
} catch (err) {
|
||||
error = `Failed to clear settings: ${err.message}`
|
||||
addToast({ message: error, tone: 'error' })
|
||||
} finally {
|
||||
runningReset = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClearHistory() {
|
||||
resetMessages()
|
||||
runningHistory = true
|
||||
try {
|
||||
await clearHistory()
|
||||
successMessage = 'History and logs cleared.'
|
||||
addToast({ message: successMessage, tone: 'success' })
|
||||
} catch (err) {
|
||||
error = `Failed to clear history: ${err.message}`
|
||||
addToast({ message: error, tone: 'error' })
|
||||
} finally {
|
||||
runningHistory = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClearCaches() {
|
||||
resetMessages()
|
||||
runningCaches = true
|
||||
try {
|
||||
await clearCaches()
|
||||
successMessage = 'Caches cleared.'
|
||||
addToast({ message: successMessage, tone: 'success' })
|
||||
} catch (err) {
|
||||
error = `Failed to clear caches: ${err.message}`
|
||||
addToast({ message: error, tone: 'error' })
|
||||
} finally {
|
||||
runningCaches = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-text-primary">Maintenance Tasks</h2>
|
||||
<p class="text-[13px] text-text-secondary">
|
||||
Run cleanup tasks to keep the app lean and reset state when needed.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="px-5 py-4 bg-red-500/5 border border-red-500/20 rounded-xl">
|
||||
<p class="text-[13px] text-red-300">{error}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if successMessage}
|
||||
<div class="px-5 py-4 bg-green-500/5 border border-green-500/20 rounded-xl">
|
||||
<p class="text-[13px] text-green-300">{successMessage}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="grid gap-4">
|
||||
<div class="rounded-xl border border-border bg-card p-6 space-y-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-text-primary">Clear history and logs</h3>
|
||||
<p class="text-[12px] text-text-tertiary">
|
||||
Removes processing runs, scan history, scheduled scans, and API usage logs.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-[11px] text-text-tertiary">
|
||||
Useful if you want a clean slate for reporting.
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-9 px-4"
|
||||
on:click={handleClearHistory}
|
||||
disabled={runningHistory}
|
||||
>
|
||||
{#if runningHistory}
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<span class="h-3 w-3 rounded-full border border-white/60 border-t-transparent animate-spin"></span>
|
||||
Clearing...
|
||||
</span>
|
||||
{:else}
|
||||
Clear History
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-border bg-card p-6 space-y-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-text-primary">Clear caches</h3>
|
||||
<p class="text-[12px] text-text-tertiary">
|
||||
Clears cached matches and resets the current scan state.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-[11px] text-text-tertiary">
|
||||
Recommended after large directory changes.
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-9 px-4"
|
||||
on:click={handleClearCaches}
|
||||
disabled={runningCaches}
|
||||
>
|
||||
{#if runningCaches}
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<span class="h-3 w-3 rounded-full border border-white/60 border-t-transparent animate-spin"></span>
|
||||
Clearing...
|
||||
</span>
|
||||
{:else}
|
||||
Clear Caches
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-red-500/30 bg-red-500/5 p-6 space-y-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-red-200">Danger Zone</h3>
|
||||
<p class="text-[12px] text-red-200/70">
|
||||
Destructive actions that reset your configuration.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-red-500/30 bg-bg-card/60 p-4 space-y-4">
|
||||
<div>
|
||||
<h4 class="text-[13px] font-semibold text-text-primary">Reset settings</h4>
|
||||
<p class="text-[12px] text-text-tertiary">
|
||||
Clears all saved settings and resets preferences to defaults.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label class="flex items-center justify-between gap-4 rounded-xl border border-border bg-bg-secondary/40 px-4 py-3 text-[12px] text-text-secondary">
|
||||
<span>Keep OMDb and TMDb API keys</span>
|
||||
<span class="relative inline-flex items-center">
|
||||
<input type="checkbox" bind:checked={keepApiKeys} class="sr-only peer" />
|
||||
<span class="h-6 w-11 rounded-full border border-border bg-bg-card transition-colors peer-checked:bg-accent peer-checked:border-accent/60"></span>
|
||||
<span class="absolute left-0.5 h-5 w-5 rounded-full bg-text-tertiary transition-transform peer-checked:translate-x-5 peer-checked:bg-bg-primary"></span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-[11px] text-text-tertiary">
|
||||
This does not delete scan history or logs.
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
className="h-9 px-4"
|
||||
on:click={handleResetSettings}
|
||||
disabled={runningReset}
|
||||
>
|
||||
{#if runningReset}
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<span class="h-3 w-3 rounded-full border border-white/60 border-t-transparent animate-spin"></span>
|
||||
Clearing...
|
||||
</span>
|
||||
{:else}
|
||||
Clear Settings
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,515 @@
|
||||
/**
|
||||
* API helper module - centralized API endpoint definitions and fetch wrappers
|
||||
* Maps to backend endpoints defined in backend/api.py
|
||||
*/
|
||||
|
||||
const API_BASE = '/api'
|
||||
|
||||
/**
|
||||
* Generic fetch wrapper with error handling
|
||||
*/
|
||||
async function apiFetch(endpoint, options = {}) {
|
||||
const url = `${API_BASE}${endpoint}`
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
},
|
||||
...options
|
||||
})
|
||||
|
||||
// Check if response has content
|
||||
const contentType = response.headers.get('content-type')
|
||||
if (!contentType || !contentType.includes('application/json')) {
|
||||
const text = await response.text()
|
||||
console.error(`Non-JSON response [${endpoint}]:`, text)
|
||||
throw new Error(`Server returned non-JSON response: ${text.substring(0, 100)}`)
|
||||
}
|
||||
|
||||
// Get response text first to help debug parse errors
|
||||
const text = await response.text()
|
||||
|
||||
// Try to parse JSON
|
||||
let data
|
||||
try {
|
||||
data = JSON.parse(text)
|
||||
} catch (parseError) {
|
||||
console.error(`JSON Parse Error [${endpoint}]:`, parseError)
|
||||
console.error('Response text:', text.substring(0, 500))
|
||||
throw new Error(`Invalid JSON response: ${parseError.message}`)
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || `HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error(`API Error [${endpoint}]:`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// ============ SETTINGS API ============
|
||||
|
||||
/**
|
||||
* GET /api/settings - Fetch current settings
|
||||
* Returns: { api_key, default_directory, cleaning_patterns, duration }
|
||||
*/
|
||||
export async function getSettings() {
|
||||
return apiFetch('/settings')
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/settings - Update settings
|
||||
* Body: { api_key?, default_directory?, duration? }
|
||||
* Returns: { success, message }
|
||||
*/
|
||||
export async function updateSettings(settings) {
|
||||
return apiFetch('/settings', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(settings)
|
||||
})
|
||||
}
|
||||
|
||||
// ============ SCAN API ============
|
||||
|
||||
/**
|
||||
* POST /api/scan/start - Start directory scan
|
||||
* Body: { directory }
|
||||
* Returns: { success, count, files: [{path, name, has_plot, status, summary, selected}] }
|
||||
*/
|
||||
export async function startScan(directory) {
|
||||
return apiFetch('/scan/start', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ directory })
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/scan/stream - Start streaming directory scan with progress updates
|
||||
* Body: { directory }
|
||||
* Returns: EventSource stream with progress updates
|
||||
*
|
||||
* Usage:
|
||||
* streamScan(directory, {
|
||||
* onProgress: (data) => { console.log('Progress:', data.filesFound) },
|
||||
* onComplete: (data) => { console.log('Done:', data.files) },
|
||||
* onError: (error) => { console.error('Error:', error) }
|
||||
* })
|
||||
*/
|
||||
export async function streamScan(directory, callbacks = {}, abortSignal = null) {
|
||||
const { onProgress, onComplete, onError, onStatus } = callbacks
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
// Use fetch to POST the directory, then read the stream
|
||||
fetch(`${API_BASE}/scan/stream`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ directory }),
|
||||
signal: abortSignal
|
||||
})
|
||||
.then(async response => {
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || `HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
|
||||
// Read the stream
|
||||
const processStream = async () => {
|
||||
let lastCompleteData = null
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
|
||||
if (done) {
|
||||
// Stream ended naturally - check if we got a complete message
|
||||
console.log('Stream ended. lastCompleteData:', !!lastCompleteData)
|
||||
if (lastCompleteData) {
|
||||
console.log('Calling onComplete with data:', lastCompleteData)
|
||||
onComplete && onComplete(lastCompleteData)
|
||||
resolve(lastCompleteData)
|
||||
} else {
|
||||
// Stream ended without complete message - this shouldn't happen
|
||||
console.error('Stream ended without receiving complete message from backend')
|
||||
const error = new Error('Stream ended unexpectedly without completion message')
|
||||
onError && onError(error)
|
||||
reject(error)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Decode chunk and add to buffer
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
|
||||
// Process complete messages (SSE format: "data: {...}\n\n")
|
||||
const lines = buffer.split('\n\n')
|
||||
buffer = lines.pop() // Keep incomplete message in buffer
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
try {
|
||||
const data = JSON.parse(line.slice(6))
|
||||
|
||||
switch (data.type) {
|
||||
case 'status':
|
||||
onStatus && onStatus(data)
|
||||
break
|
||||
case 'progress':
|
||||
onProgress && onProgress(data)
|
||||
break
|
||||
case 'complete':
|
||||
// Store complete data but don't resolve yet - wait for stream to end
|
||||
console.log('Received complete message from backend:', data)
|
||||
lastCompleteData = data
|
||||
break
|
||||
case 'error':
|
||||
const error = new Error(data.error || 'Scan failed')
|
||||
onError && onError(error)
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.error('Failed to parse SSE message:', line, parseError)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (streamError) {
|
||||
console.error('Stream reading error:', streamError)
|
||||
onError && onError(streamError)
|
||||
reject(streamError)
|
||||
}
|
||||
}
|
||||
|
||||
await processStream()
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Stream scan error:', error)
|
||||
onError && onError(error)
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/scan/status - Get scan status and results
|
||||
* Returns: { scanning, last_scan, file_count, files }
|
||||
*/
|
||||
export async function getScanStatus() {
|
||||
return apiFetch('/scan/status')
|
||||
}
|
||||
|
||||
// ============ SEARCH API ============
|
||||
|
||||
/**
|
||||
* POST /api/search - Search for title matches
|
||||
* Body: { query: string, mode?: "quick" | "full" }
|
||||
* - "quick" (default): Returns single best match (1 API call) - good for auto-matching
|
||||
* - "full": Returns multiple results to choose from (2 API calls) - good for manual search
|
||||
* Returns: { success, results: [{title, year, plot, runtime, imdb_rating, media_type, poster, imdb_id}] }
|
||||
*/
|
||||
export async function searchTitle(query, mode = "quick") {
|
||||
return apiFetch('/search', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ query, mode })
|
||||
})
|
||||
}
|
||||
|
||||
// ============ PROCESSING API ============
|
||||
|
||||
/**
|
||||
* POST /api/process - Process files to add plot summaries
|
||||
* Body: { files: [string], duration: number, titleOverride?: object, forceReprocess?: boolean }
|
||||
* Returns: { success, results: [{file, success, status, summary, error?}] }
|
||||
*/
|
||||
export async function processFiles(files, duration, titleOverride = null, forceReprocess = false) {
|
||||
const body = { files, duration }
|
||||
|
||||
if (titleOverride) {
|
||||
body.titleOverride = titleOverride
|
||||
}
|
||||
|
||||
if (forceReprocess) {
|
||||
body.forceReprocess = forceReprocess
|
||||
}
|
||||
|
||||
return apiFetch('/process', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
}
|
||||
|
||||
// ============ UTILITY API ============
|
||||
|
||||
/**
|
||||
* GET /api/health - Health check
|
||||
* Returns: { status, api_key_configured }
|
||||
*/
|
||||
export async function healthCheck() {
|
||||
return apiFetch('/health')
|
||||
}
|
||||
|
||||
// ============ HISTORY API ============
|
||||
|
||||
/**
|
||||
* GET /api/history/runs - Get processing run history
|
||||
* Returns: { success, runs: [{id, started_at, completed_at, total_files, successful_files, failed_files, duration_seconds, status}] }
|
||||
*/
|
||||
export async function getRunHistory(limit = 50) {
|
||||
return apiFetch(`/history/runs?limit=${limit}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/history/runs/<id> - Get detailed run information
|
||||
* Returns: { success, run: {id, started_at, completed_at, file_results: [...]} }
|
||||
*/
|
||||
export async function getRunDetails(runId) {
|
||||
return apiFetch(`/history/runs/${runId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/history/scans - Get scan history
|
||||
* Returns: { success, scans: [{id, directory, scanned_at, files_found, files_with_plot, scan_duration_ms}] }
|
||||
*/
|
||||
export async function getScanHistory(limit = 50) {
|
||||
return apiFetch(`/history/scans?limit=${limit}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/statistics - Get overall statistics
|
||||
* Returns: { success, statistics: {total_runs, completed_runs, total_files_processed, successful_files, failed_files} }
|
||||
*/
|
||||
export async function getStatistics() {
|
||||
return apiFetch('/statistics')
|
||||
}
|
||||
|
||||
// ============ SCHEDULED SCANS API ============
|
||||
|
||||
/**
|
||||
* GET /api/scheduled-scans - Get scheduled scans
|
||||
* Returns: { success, scans: [{id, directory, scheduled_for, status, files_found, files_with_plot, scan_duration_ms}] }
|
||||
*/
|
||||
export async function getScheduledScans(limit = 50, status = null) {
|
||||
const params = new URLSearchParams({ limit: String(limit) })
|
||||
if (status) {
|
||||
params.append('status', status)
|
||||
}
|
||||
return apiFetch(`/scheduled-scans?${params.toString()}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/scheduled-scans - Create a scheduled scan
|
||||
* Body: { directory, scheduled_for }
|
||||
* Returns: { success, id }
|
||||
*/
|
||||
export async function createScheduledScan(directory, scheduledFor) {
|
||||
return apiFetch('/scheduled-scans', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ directory, scheduled_for: scheduledFor })
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/scheduled-scans/<id>/cancel - Cancel a scheduled scan
|
||||
* Returns: { success }
|
||||
*/
|
||||
export async function cancelScheduledScan(scanId) {
|
||||
return apiFetch(`/scheduled-scans/${scanId}/cancel`, {
|
||||
method: 'POST'
|
||||
})
|
||||
}
|
||||
|
||||
// ============ INTEGRATIONS API ============
|
||||
|
||||
/**
|
||||
* GET /api/integrations/usage - Get API usage statistics
|
||||
* Returns: { success, usage: { omdb: {...}, tmdb: {...}, tvmaze: {...} } }
|
||||
*/
|
||||
export async function getIntegrationUsage() {
|
||||
return apiFetch('/integrations/usage')
|
||||
}
|
||||
|
||||
// ============ SUGGESTED MATCHES API ============
|
||||
|
||||
/**
|
||||
* POST /api/suggested-matches - Save suggested matches
|
||||
* Body: { matches: { filePath: matchData } }
|
||||
* Returns: { success, count }
|
||||
*/
|
||||
export async function saveSuggestedMatches(matches) {
|
||||
return apiFetch('/suggested-matches', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ matches })
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/suggested-matches/<file_path> - Delete a suggested match
|
||||
* Returns: { success }
|
||||
*/
|
||||
export async function deleteSuggestedMatch(filePath) {
|
||||
return apiFetch(`/suggested-matches/${encodeURIComponent(filePath)}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/suggested-matches - Clear all suggested matches
|
||||
* Returns: { success }
|
||||
*/
|
||||
export async function clearAllSuggestedMatches() {
|
||||
return apiFetch('/suggested-matches', {
|
||||
method: 'DELETE'
|
||||
})
|
||||
}
|
||||
|
||||
// ============ MAINTENANCE API ============
|
||||
|
||||
/**
|
||||
* POST /api/maintenance/reset-settings - Clear settings, optionally keeping API keys
|
||||
* Body: { keep_api_keys: boolean }
|
||||
* Returns: { success }
|
||||
*/
|
||||
export async function resetSettings(keepApiKeys = false) {
|
||||
return apiFetch('/maintenance/reset-settings', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ keep_api_keys: keepApiKeys })
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/maintenance/clear-history - Clear runs, scans, scheduled scans, and usage logs
|
||||
* Returns: { success }
|
||||
*/
|
||||
export async function clearHistory() {
|
||||
return apiFetch('/maintenance/clear-history', {
|
||||
method: 'POST'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/maintenance/clear-caches - Clear cached data like suggested matches
|
||||
* Returns: { success }
|
||||
*/
|
||||
export async function clearCaches() {
|
||||
return apiFetch('/maintenance/clear-caches', {
|
||||
method: 'POST'
|
||||
})
|
||||
}
|
||||
|
||||
// ============ BATCH PROCESSING API ============
|
||||
|
||||
/**
|
||||
* POST /api/process/batch - Process multiple files with SSE streaming progress
|
||||
* Body: { items: [{ path, titleOverride }], duration? }
|
||||
* Returns: SSE stream with progress updates
|
||||
*
|
||||
* Usage:
|
||||
* processBatch(items, duration, {
|
||||
* onStart: (data) => { console.log('Starting:', data.total) },
|
||||
* onProgress: (data) => { console.log('Progress:', data.current, '/', data.total) },
|
||||
* onResult: (data) => { console.log('Result:', data.file, data.success) },
|
||||
* onComplete: (data) => { console.log('Done:', data.successful, '/', data.total) },
|
||||
* onError: (error) => { console.error('Error:', error) }
|
||||
* })
|
||||
*/
|
||||
export async function processBatch(items, duration, callbacks = {}) {
|
||||
const { onStart, onProgress, onResult, onComplete, onError } = callbacks
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
fetch(`${API_BASE}/process/batch`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ items, duration })
|
||||
})
|
||||
.then(async response => {
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || `HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
let lastCompleteData = null
|
||||
|
||||
const processStream = async () => {
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
|
||||
if (done) {
|
||||
if (lastCompleteData) {
|
||||
onComplete && onComplete(lastCompleteData)
|
||||
resolve(lastCompleteData)
|
||||
} else {
|
||||
const error = new Error('Stream ended unexpectedly')
|
||||
onError && onError(error)
|
||||
reject(error)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
|
||||
const lines = buffer.split('\n\n')
|
||||
buffer = lines.pop()
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
try {
|
||||
const data = JSON.parse(line.slice(6))
|
||||
|
||||
switch (data.type) {
|
||||
case 'start':
|
||||
onStart && onStart(data)
|
||||
break
|
||||
case 'progress':
|
||||
onProgress && onProgress(data)
|
||||
break
|
||||
case 'result':
|
||||
onResult && onResult(data)
|
||||
break
|
||||
case 'complete':
|
||||
lastCompleteData = data
|
||||
break
|
||||
case 'error':
|
||||
const error = new Error(data.error || 'Processing failed')
|
||||
onError && onError(error)
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.error('Failed to parse SSE message:', line, parseError)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (streamError) {
|
||||
console.error('Stream reading error:', streamError)
|
||||
onError && onError(streamError)
|
||||
reject(streamError)
|
||||
}
|
||||
}
|
||||
|
||||
await processStream()
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Batch processing error:', error)
|
||||
onError && onError(error)
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<script>
|
||||
import { cn } from '../../../utils.js'
|
||||
|
||||
export let className = ''
|
||||
let restClass
|
||||
let restProps = {}
|
||||
|
||||
$: ({ class: restClass, ...restProps } = $$restProps)
|
||||
</script>
|
||||
|
||||
<span
|
||||
class={cn(
|
||||
'inline-flex items-center rounded-full border border-border px-2.5 py-0.5 text-xs font-semibold text-foreground/80',
|
||||
className,
|
||||
restClass,
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as Badge } from './Badge.svelte'
|
||||
@@ -0,0 +1,63 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import { cva } from 'class-variance-authority'
|
||||
import { cn } from '../../../utils.js'
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
outline: 'border border-input bg-background hover:bg-accent/10 hover:text-foreground',
|
||||
ghost: 'hover:bg-accent/10 hover:text-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-4 py-2',
|
||||
sm: 'h-9 px-3',
|
||||
lg: 'h-11 px-6',
|
||||
icon: 'h-10 w-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export let variant = 'default'
|
||||
export let size = 'default'
|
||||
export let type = 'button'
|
||||
export let href = null
|
||||
export let className = ''
|
||||
let restClass
|
||||
let restProps = {}
|
||||
|
||||
$: ({ class: restClass, ...restProps } = $$restProps)
|
||||
</script>
|
||||
|
||||
{#if href}
|
||||
<a
|
||||
href={href}
|
||||
class={cn(buttonVariants({ variant, size }), className, restClass)}
|
||||
on:click={(event) => dispatch('click', event)}
|
||||
{...restProps}
|
||||
>
|
||||
<slot />
|
||||
</a>
|
||||
{:else}
|
||||
<button
|
||||
type={type}
|
||||
class={cn(buttonVariants({ variant, size }), className, restClass)}
|
||||
on:click={(event) => dispatch('click', event)}
|
||||
{...restProps}
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
{/if}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as Button } from './Button.svelte'
|
||||
@@ -0,0 +1,36 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte'
|
||||
import { cn } from '../../../utils.js'
|
||||
|
||||
export let className = ''
|
||||
export let skeletonFlash = true
|
||||
let restClass
|
||||
let restProps = {}
|
||||
let showSkeleton = false
|
||||
|
||||
$: ({ class: restClass, ...restProps } = $$restProps)
|
||||
|
||||
onMount(() => {
|
||||
if (!skeletonFlash) return
|
||||
showSkeleton = true
|
||||
const timer = setTimeout(() => {
|
||||
showSkeleton = false
|
||||
}, 900)
|
||||
return () => clearTimeout(timer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={cn(
|
||||
'rounded-lg border border-border bg-card text-card-foreground shadow-sm',
|
||||
showSkeleton ? 'relative overflow-hidden' : '',
|
||||
className,
|
||||
restClass
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
<slot />
|
||||
{#if showSkeleton}
|
||||
<div class="pointer-events-none absolute inset-0 bg-[color:var(--bg-hover)] opacity-40 animate-pulse"></div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script>
|
||||
import { cn } from '../../../utils.js'
|
||||
|
||||
export let className = ''
|
||||
let restClass
|
||||
let restProps = {}
|
||||
|
||||
$: ({ class: restClass, ...restProps } = $$restProps)
|
||||
</script>
|
||||
|
||||
<div class={cn('p-6 pt-0', className, restClass)} {...restProps}>
|
||||
<slot />
|
||||
</div>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script>
|
||||
import { cn } from '../../../utils.js'
|
||||
|
||||
export let className = ''
|
||||
let restClass
|
||||
let restProps = {}
|
||||
|
||||
$: ({ class: restClass, ...restProps } = $$restProps)
|
||||
</script>
|
||||
|
||||
<p class={cn('text-sm text-muted-foreground', className, restClass)} {...restProps}>
|
||||
<slot />
|
||||
</p>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script>
|
||||
import { cn } from '../../../utils.js'
|
||||
|
||||
export let className = ''
|
||||
let restClass
|
||||
let restProps = {}
|
||||
|
||||
$: ({ class: restClass, ...restProps } = $$restProps)
|
||||
</script>
|
||||
|
||||
<div class={cn('flex items-center p-6 pt-0', className, restClass)} {...restProps}>
|
||||
<slot />
|
||||
</div>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script>
|
||||
import { cn } from '../../../utils.js'
|
||||
|
||||
export let className = ''
|
||||
let restClass
|
||||
let restProps = {}
|
||||
|
||||
$: ({ class: restClass, ...restProps } = $$restProps)
|
||||
</script>
|
||||
|
||||
<div class={cn('flex flex-col space-y-1.5 p-6', className, restClass)} {...restProps}>
|
||||
<slot />
|
||||
</div>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script>
|
||||
import { cn } from '../../../utils.js'
|
||||
|
||||
export let className = ''
|
||||
let restClass
|
||||
let restProps = {}
|
||||
|
||||
$: ({ class: restClass, ...restProps } = $$restProps)
|
||||
</script>
|
||||
|
||||
<h3
|
||||
class={cn('text-lg font-semibold leading-none tracking-tight', className, restClass)}
|
||||
{...restProps}
|
||||
>
|
||||
<slot />
|
||||
</h3>
|
||||
@@ -0,0 +1,6 @@
|
||||
export { default as Card } from './Card.svelte'
|
||||
export { default as CardHeader } from './CardHeader.svelte'
|
||||
export { default as CardTitle } from './CardTitle.svelte'
|
||||
export { default as CardDescription } from './CardDescription.svelte'
|
||||
export { default as CardContent } from './CardContent.svelte'
|
||||
export { default as CardFooter } from './CardFooter.svelte'
|
||||
@@ -0,0 +1,170 @@
|
||||
<script>
|
||||
import { createEventDispatcher, onMount, onDestroy } from 'svelte'
|
||||
import { Button } from '../button'
|
||||
import { Input } from '../input'
|
||||
import { cn } from '../../../utils.js'
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
export let items = []
|
||||
export let value = ''
|
||||
export let placeholder = 'Select...'
|
||||
export let disabled = false
|
||||
export let className = ''
|
||||
|
||||
let open = false
|
||||
let search = ''
|
||||
let root
|
||||
let hoveredDisabled = null
|
||||
let tooltipPosition = { x: 0, y: 0 }
|
||||
|
||||
const getLabel = (itemValue) =>
|
||||
items.find((item) => item.value === itemValue)?.label
|
||||
|
||||
$: selectedLabel = getLabel(value)
|
||||
$: filteredItems = items.filter((item) => {
|
||||
const haystack = `${item.label} ${item.description || ''}`.toLowerCase()
|
||||
return haystack.includes(search.toLowerCase())
|
||||
})
|
||||
|
||||
function toggle() {
|
||||
if (!disabled) open = !open
|
||||
}
|
||||
|
||||
function selectItem(itemValue) {
|
||||
dispatch('change', { value: itemValue })
|
||||
open = false
|
||||
search = ''
|
||||
}
|
||||
|
||||
function handleKeydown(event) {
|
||||
if (event.key === 'Escape') open = false
|
||||
}
|
||||
|
||||
function handleOutsideClick(event) {
|
||||
if (root && !root.contains(event.target)) {
|
||||
open = false
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
document.addEventListener('mousedown', handleOutsideClick, true)
|
||||
document.addEventListener('keydown', handleKeydown, true)
|
||||
})
|
||||
|
||||
onDestroy(() => {
|
||||
document.removeEventListener('mousedown', handleOutsideClick, true)
|
||||
document.removeEventListener('keydown', handleKeydown, true)
|
||||
})
|
||||
|
||||
function showDisabledTooltip(event, item) {
|
||||
if (!item.disabled) return
|
||||
const rect = event.currentTarget.getBoundingClientRect()
|
||||
hoveredDisabled = item.value
|
||||
tooltipPosition = {
|
||||
x: rect.right + 10,
|
||||
y: rect.top + rect.height / 2
|
||||
}
|
||||
}
|
||||
|
||||
function hideDisabledTooltip() {
|
||||
hoveredDisabled = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class={cn('relative', className)} bind:this={root}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-10 w-full justify-between gap-2 px-4"
|
||||
on:click={toggle}
|
||||
{disabled}
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<slot name="icon" />
|
||||
<span class={selectedLabel ? 'text-foreground' : 'text-muted-foreground'}>
|
||||
{selectedLabel || placeholder}
|
||||
</span>
|
||||
</span>
|
||||
<svg
|
||||
class="h-3.5 w-3.5 text-text-tertiary transition-transform {open
|
||||
? 'rotate-180'
|
||||
: ''}"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
|
||||
{#if open}
|
||||
<div
|
||||
class="absolute top-full mt-2 left-0 min-w-[220px] w-[92%] rounded-lg border border-border bg-card shadow-2xl overflow-hidden z-50"
|
||||
>
|
||||
<div class="p-2 border-b border-border bg-muted/40">
|
||||
<Input
|
||||
value={search}
|
||||
on:input={(e) => (search = e.target.value)}
|
||||
placeholder="Search..."
|
||||
className="h-9 text-[12px]"
|
||||
/>
|
||||
</div>
|
||||
<div class="max-h-56 overflow-y-auto py-1">
|
||||
{#if filteredItems.length === 0}
|
||||
<div class="px-4 py-3 text-xs text-text-tertiary">
|
||||
No results found.
|
||||
</div>
|
||||
{:else}
|
||||
{#each filteredItems as item}
|
||||
<button
|
||||
type="button"
|
||||
class="relative w-full px-4 py-2.5 text-left transition-colors flex items-center justify-between {item.disabled ? 'opacity-40 cursor-not-allowed' : 'hover:bg-bg-hover'}"
|
||||
disabled={item.disabled}
|
||||
on:click={() => !item.disabled && selectItem(item.value)}
|
||||
on:mouseenter={(event) => showDisabledTooltip(event, item)}
|
||||
on:mouseleave={hideDisabledTooltip}
|
||||
>
|
||||
<div>
|
||||
<div class="text-[13px] font-medium">{item.label}</div>
|
||||
{#if item.description}
|
||||
<div class="text-[11px] text-text-tertiary">
|
||||
{item.description}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if item.value === value}
|
||||
<svg
|
||||
class="h-4 w-4 text-accent"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if hoveredDisabled}
|
||||
<div
|
||||
class="pointer-events-none fixed z-50 whitespace-nowrap rounded-lg border border-white/10 bg-bg-card px-4 py-2.5 text-[11px] text-text-secondary shadow-[0_12px_30px_rgba(0,0,0,0.35)]"
|
||||
style="left: {tooltipPosition.x}px; top: {tooltipPosition.y}px; transform: translateY(-50%);"
|
||||
>
|
||||
Enable this in Settings under Integrations.
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as Combobox } from './Combobox.svelte'
|
||||
@@ -0,0 +1,20 @@
|
||||
<script>
|
||||
import { cn } from '../../../utils.js'
|
||||
|
||||
export let value = ''
|
||||
export let className = ''
|
||||
let restClass
|
||||
let restProps = {}
|
||||
|
||||
$: ({ class: restClass, ...restProps } = $$restProps)
|
||||
</script>
|
||||
|
||||
<input
|
||||
bind:value
|
||||
class={cn(
|
||||
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
restClass,
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as Input } from './Input.svelte'
|
||||
@@ -0,0 +1,24 @@
|
||||
<script>
|
||||
import { cn } from '../../../utils.js'
|
||||
|
||||
export let orientation = 'horizontal'
|
||||
export let className = ''
|
||||
let restClass
|
||||
let restProps = {}
|
||||
|
||||
$: ({ class: restClass, ...restProps } = $$restProps)
|
||||
|
||||
$: isVertical = orientation === 'vertical'
|
||||
</script>
|
||||
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation={orientation}
|
||||
class={cn(
|
||||
'shrink-0 bg-border',
|
||||
isVertical ? 'h-full w-px' : 'h-px w-full',
|
||||
className,
|
||||
restClass,
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as Separator } from './Separator.svelte'
|
||||
@@ -0,0 +1,13 @@
|
||||
<script>
|
||||
import { cn } from '../../../utils.js'
|
||||
|
||||
export let className = ''
|
||||
let restClass
|
||||
let restProps = {}
|
||||
|
||||
$: ({ class: restClass, ...restProps } = $$restProps)
|
||||
</script>
|
||||
|
||||
<div class={cn('flex min-h-screen flex-1 flex-col', className, restClass)} {...restProps}>
|
||||
<slot />
|
||||
</div>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script>
|
||||
import { cn } from '../../../utils.js'
|
||||
|
||||
export let className = ''
|
||||
let restClass
|
||||
let restProps = {}
|
||||
|
||||
$: ({ class: restClass, ...restProps } = $$restProps)
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={cn('flex min-h-screen w-full bg-background text-foreground', className, restClass)}
|
||||
{...restProps}
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as Provider } from './Provider.svelte'
|
||||
export { default as Inset } from './Inset.svelte'
|
||||
@@ -0,0 +1,14 @@
|
||||
<script>
|
||||
import { cn } from '../../../utils.js'
|
||||
|
||||
export let className = ''
|
||||
let restClass
|
||||
let restProps = {}
|
||||
|
||||
$: ({ class: restClass, ...restProps } = $$restProps)
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={cn('animate-pulse rounded-md bg-[color:var(--bg-hover)] opacity-50', className, restClass)}
|
||||
{...restProps}
|
||||
></div>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as Skeleton } from './Skeleton.svelte'
|
||||
@@ -0,0 +1,13 @@
|
||||
<script>
|
||||
import { cn } from '../../../utils.js'
|
||||
|
||||
export let className = ''
|
||||
let restClass
|
||||
let restProps = {}
|
||||
|
||||
$: ({ class: restClass, ...restProps } = $$restProps)
|
||||
</script>
|
||||
|
||||
<table class={cn('w-full caption-bottom text-sm', className, restClass)} {...restProps}>
|
||||
<slot />
|
||||
</table>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script>
|
||||
import { cn } from '../../../utils.js'
|
||||
|
||||
export let className = ''
|
||||
let restClass
|
||||
let restProps = {}
|
||||
|
||||
$: ({ class: restClass, ...restProps } = $$restProps)
|
||||
</script>
|
||||
|
||||
<tbody class={cn('[&_tr:last-child]:border-0', className, restClass)} {...restProps}>
|
||||
<slot />
|
||||
</tbody>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script>
|
||||
import { cn } from '../../../utils.js'
|
||||
|
||||
export let className = ''
|
||||
let restClass
|
||||
let restProps = {}
|
||||
|
||||
$: ({ class: restClass, ...restProps } = $$restProps)
|
||||
</script>
|
||||
|
||||
<caption
|
||||
class={cn('mt-4 text-sm text-muted-foreground', className, restClass)}
|
||||
{...restProps}
|
||||
>
|
||||
<slot />
|
||||
</caption>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script>
|
||||
import { cn } from '../../../utils.js'
|
||||
|
||||
export let className = ''
|
||||
let restClass
|
||||
let restProps = {}
|
||||
|
||||
$: ({ class: restClass, ...restProps } = $$restProps)
|
||||
</script>
|
||||
|
||||
<td
|
||||
class={cn('p-4 align-middle [&:has([role=checkbox])]:pr-0', className, restClass)}
|
||||
{...restProps}
|
||||
>
|
||||
<slot />
|
||||
</td>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script>
|
||||
import { cn } from '../../../utils.js'
|
||||
|
||||
export let className = ''
|
||||
let restClass
|
||||
let restProps = {}
|
||||
|
||||
$: ({ class: restClass, ...restProps } = $$restProps)
|
||||
</script>
|
||||
|
||||
<tfoot
|
||||
class={cn('bg-muted/50 font-medium [&>tr]:last:border-b-0', className, restClass)}
|
||||
{...restProps}
|
||||
>
|
||||
<slot />
|
||||
</tfoot>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script>
|
||||
import { cn } from '../../../utils.js'
|
||||
|
||||
export let className = ''
|
||||
let restClass
|
||||
let restProps = {}
|
||||
|
||||
$: ({ class: restClass, ...restProps } = $$restProps)
|
||||
</script>
|
||||
|
||||
<th
|
||||
class={cn(
|
||||
'h-12 px-4 text-left align-middle text-xs font-medium text-muted-foreground',
|
||||
className,
|
||||
restClass,
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
<slot />
|
||||
</th>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script>
|
||||
import { cn } from '../../../utils.js'
|
||||
|
||||
export let className = ''
|
||||
let restClass
|
||||
let restProps = {}
|
||||
|
||||
$: ({ class: restClass, ...restProps } = $$restProps)
|
||||
</script>
|
||||
|
||||
<thead class={cn('[&_tr]:border-b', className, restClass)} {...restProps}>
|
||||
<slot />
|
||||
</thead>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script>
|
||||
import { cn } from '../../../utils.js'
|
||||
|
||||
export let className = ''
|
||||
let restClass
|
||||
let restProps = {}
|
||||
|
||||
$: ({ class: restClass, ...restProps } = $$restProps)
|
||||
</script>
|
||||
|
||||
<tr
|
||||
class={cn(
|
||||
'border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted',
|
||||
className,
|
||||
restClass,
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
<slot />
|
||||
</tr>
|
||||
@@ -0,0 +1,8 @@
|
||||
export { default as Table } from './Table.svelte'
|
||||
export { default as TableHeader } from './TableHeader.svelte'
|
||||
export { default as TableBody } from './TableBody.svelte'
|
||||
export { default as TableFooter } from './TableFooter.svelte'
|
||||
export { default as TableRow } from './TableRow.svelte'
|
||||
export { default as TableHead } from './TableHead.svelte'
|
||||
export { default as TableCell } from './TableCell.svelte'
|
||||
export { default as TableCaption } from './TableCaption.svelte'
|
||||
@@ -0,0 +1,32 @@
|
||||
import { writable } from 'svelte/store'
|
||||
|
||||
// Simple in-memory store for scan results
|
||||
// No localStorage to avoid performance issues
|
||||
function createScanStore() {
|
||||
const { subscribe, set, update } = writable({
|
||||
files: [],
|
||||
lastScan: null,
|
||||
directory: ''
|
||||
})
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
setScanResults: (files, directory) => {
|
||||
update(state => ({
|
||||
...state,
|
||||
files,
|
||||
directory,
|
||||
lastScan: new Date().toISOString()
|
||||
}))
|
||||
},
|
||||
clearResults: () => {
|
||||
set({
|
||||
files: [],
|
||||
lastScan: null,
|
||||
directory: ''
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const scanResults = createScanStore()
|
||||
@@ -0,0 +1,128 @@
|
||||
import { writable } from 'svelte/store'
|
||||
|
||||
export const themes = {
|
||||
oled: {
|
||||
name: 'OLED',
|
||||
colors: {
|
||||
// Backgrounds (true OLED but layered)
|
||||
'bg-primary': '#000000',
|
||||
'bg-secondary': '#1a1a1a',
|
||||
'bg-card': '#0b0b0b',
|
||||
'bg-hover': '#2a2a2a',
|
||||
|
||||
// Text
|
||||
'text-primary': '#ffffff',
|
||||
'text-secondary': '#b0b0b0',
|
||||
'text-tertiary': '#7a7a7a',
|
||||
|
||||
// UI chrome
|
||||
'border': 'rgba(255, 255, 255, 0.08)',
|
||||
|
||||
// Accents & interaction
|
||||
'accent': '#3b82f6', // restrained blue
|
||||
'button-bg': '#0f0f0f',
|
||||
'button-hover': '#1a1a1a',
|
||||
'button-text': '#ffffff',
|
||||
'focus-ring': 'rgba(255, 255, 255, 0.25)',
|
||||
}
|
||||
},
|
||||
|
||||
ocean: {
|
||||
name: 'Ocean',
|
||||
colors: {
|
||||
// Backgrounds (deeper, cinematic)
|
||||
'bg-primary': '#070f1e',
|
||||
'bg-secondary': '#0b162b',
|
||||
'bg-card': '#0f1d36',
|
||||
'bg-hover': '#162a4a',
|
||||
|
||||
// Text
|
||||
'text-primary': '#e6f0ff',
|
||||
'text-secondary': '#9bbbe6',
|
||||
'text-tertiary': '#6f8fb6',
|
||||
|
||||
// Borders
|
||||
'border': 'rgba(120, 170, 220, 0.18)',
|
||||
|
||||
// Accents & interaction (this is the magic)
|
||||
'accent': '#5fa8ff', // beautiful ocean blue
|
||||
'button-bg': '#132646',
|
||||
'button-hover': '#1b3560',
|
||||
'button-text': '#eaf3ff',
|
||||
'focus-ring': 'rgba(95, 168, 255, 0.45)',
|
||||
}
|
||||
},
|
||||
|
||||
light: {
|
||||
name: 'Light',
|
||||
colors: {
|
||||
'bg-primary': '#f8f9fa',
|
||||
'bg-secondary': '#f1f3f5',
|
||||
'bg-card': '#ffffff',
|
||||
'bg-hover': '#e9ecef',
|
||||
|
||||
'text-primary': '#1a1a1a',
|
||||
'text-secondary': '#5c5f66',
|
||||
'text-tertiary': '#868e96',
|
||||
|
||||
'border': '#dee2e6',
|
||||
|
||||
'accent': '#2563eb',
|
||||
'button-bg': '#ffffff',
|
||||
'button-hover': '#f1f3f5',
|
||||
'button-text': '#1a1a1a',
|
||||
'focus-ring': 'rgba(37, 99, 235, 0.35)',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'sublogue-theme'
|
||||
|
||||
function getInitialTheme() {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (stored && themes[stored]) {
|
||||
return stored
|
||||
}
|
||||
}
|
||||
return 'oled'
|
||||
}
|
||||
|
||||
function createThemeStore() {
|
||||
const { subscribe, set } = writable(getInitialTheme())
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
setTheme: (themeName) => {
|
||||
if (themes[themeName]) {
|
||||
set(themeName)
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem(STORAGE_KEY, themeName)
|
||||
}
|
||||
applyTheme(themeName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applyTheme(themeName) {
|
||||
const theme = themes[themeName]
|
||||
if (!theme) return
|
||||
|
||||
const root = document.documentElement
|
||||
Object.entries(theme.colors).forEach(([key, value]) => {
|
||||
root.style.setProperty(`--${key}`, value)
|
||||
})
|
||||
|
||||
if (themeName === 'light') {
|
||||
root.classList.add('light-theme')
|
||||
} else {
|
||||
root.classList.remove('light-theme')
|
||||
}
|
||||
}
|
||||
|
||||
export const currentTheme = createThemeStore()
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
applyTheme(getInitialTheme())
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { writable } from 'svelte/store'
|
||||
|
||||
const TOAST_LIMIT = 4
|
||||
|
||||
function createToastStore() {
|
||||
const { subscribe, update } = writable([])
|
||||
|
||||
function removeToast(id) {
|
||||
update((items) => items.filter((item) => item.id !== id))
|
||||
}
|
||||
|
||||
function addToast({ message, tone = 'info', duration = 3200 } = {}) {
|
||||
if (!message) return
|
||||
const id = `${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
const toast = { id, message, tone }
|
||||
|
||||
update((items) => {
|
||||
const next = [toast, ...items]
|
||||
return next.slice(0, TOAST_LIMIT)
|
||||
})
|
||||
|
||||
if (duration > 0) {
|
||||
setTimeout(() => removeToast(id), duration)
|
||||
}
|
||||
}
|
||||
|
||||
return { subscribe, addToast, removeToast }
|
||||
}
|
||||
|
||||
export const toasts = createToastStore()
|
||||
export const addToast = toasts.addToast
|
||||
export const removeToast = toasts.removeToast
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { mount } from 'svelte'
|
||||
import App from './App.svelte'
|
||||
import './styles/global.css'
|
||||
|
||||
const app = mount(App, {
|
||||
target: document.getElementById('app')
|
||||
})
|
||||
|
||||
export default app
|
||||
@@ -0,0 +1,89 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
/* Default OLED theme */
|
||||
--bg-primary: #000000;
|
||||
--bg-secondary: #050505;
|
||||
--bg-card: #0a0a0a;
|
||||
--bg-hover: #141414;
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #999999;
|
||||
--text-tertiary: #666666;
|
||||
--border: rgba(255, 255, 255, 0.06);
|
||||
--accent: #3b82f6;
|
||||
--focus-ring: rgba(255, 255, 255, 0.25);
|
||||
|
||||
/* Shadows for depth */
|
||||
--shadow-sm: inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
||||
--shadow-card: 0 0 0 1px rgba(255, 255, 255, 0.04);
|
||||
--shadow-elevated: 0 0 0 1px rgba(255, 255, 255, 0.06), 0 2px 8px rgba(0, 0, 0, 0.4);
|
||||
|
||||
/* Shadcn-style tokens mapped to theme variables */
|
||||
--background: var(--bg-primary);
|
||||
--foreground: var(--text-primary);
|
||||
--card: var(--bg-card);
|
||||
--card-foreground: var(--text-primary);
|
||||
--popover: var(--bg-card);
|
||||
--popover-foreground: var(--text-primary);
|
||||
--primary: var(--text-primary);
|
||||
--primary-foreground: var(--bg-primary);
|
||||
--secondary: var(--bg-secondary);
|
||||
--secondary-foreground: var(--text-primary);
|
||||
--muted: var(--bg-secondary);
|
||||
--muted-foreground: var(--text-tertiary);
|
||||
--accent-foreground: var(--bg-primary);
|
||||
--destructive: #ef4444;
|
||||
--destructive-foreground: #fef2f2;
|
||||
--input: var(--border);
|
||||
--ring: var(--focus-ring);
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: 'Inter', 'Segoe UI', system-ui, sans-serif;
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.25rem;
|
||||
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
}
|
||||
|
||||
* {
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
@apply w-2 h-2;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
@apply rounded;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
/* Light theme scrollbar adjustment */
|
||||
.light-theme ::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.light-theme ::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user