Initial commit
This commit is contained in:
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user