feat(ui): redesign panel with Mantine light theme, routing, modals and notifications
This commit is contained in:
1283
services/ui/package-lock.json
generated
1283
services/ui/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -9,8 +9,14 @@
|
||||
"start": "serve -s dist -l 3000"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mantine/core": "^8.3.15",
|
||||
"@mantine/hooks": "^8.3.15",
|
||||
"@mantine/modals": "^8.3.15",
|
||||
"@mantine/notifications": "^8.3.15",
|
||||
"@tabler/icons-react": "^3.37.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^7.13.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.18",
|
||||
@@ -19,4 +25,4 @@
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.0.11"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { Layout, Tab } from './components/Layout';
|
||||
import { Route, Routes, useNavigate, useParams } from 'react-router-dom';
|
||||
import { Alert, Button, Stack } from '@mantine/core';
|
||||
import { IconArrowLeft, IconAlertTriangle } from '@tabler/icons-react';
|
||||
import { Layout } from './components/Layout';
|
||||
import { DashboardPage } from './pages/DashboardPage';
|
||||
import { JobsPage } from './pages/JobsPage';
|
||||
import { JobDetailPage } from './pages/JobDetailPage';
|
||||
@@ -8,27 +10,44 @@ import { SettingsPage } from './pages/SettingsPage';
|
||||
import { WatchedPathsPage } from './pages/WatchedPathsPage';
|
||||
|
||||
export default function App() {
|
||||
const [tab, setTab] = useState<Tab>('dashboard');
|
||||
const [selectedJob, setSelectedJob] = useState<string | null>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSelectJob = (jobId: string) => {
|
||||
navigate(`/jobs/${jobId}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout tab={tab} setTab={setTab}>
|
||||
{selectedJob && (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<button onClick={() => setSelectedJob(null)}>Job listesine don</button>
|
||||
</div>
|
||||
)}
|
||||
{selectedJob ? (
|
||||
<JobDetailPage jobId={selectedJob} />
|
||||
) : (
|
||||
<>
|
||||
{tab === 'dashboard' && <DashboardPage onSelectJob={setSelectedJob} />}
|
||||
{tab === 'jobs' && <JobsPage onSelectJob={setSelectedJob} />}
|
||||
{tab === 'review' && <ReviewPage onSelectJob={setSelectedJob} />}
|
||||
{tab === 'settings' && <SettingsPage />}
|
||||
{tab === 'paths' && <WatchedPathsPage />}
|
||||
</>
|
||||
)}
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<DashboardPage onSelectJob={handleSelectJob} />} />
|
||||
<Route path="/jobs" element={<JobsPage onSelectJob={handleSelectJob} />} />
|
||||
<Route path="/jobs/:id" element={<JobDetailRoute />} />
|
||||
<Route path="/review" element={<ReviewPage onSelectJob={handleSelectJob} />} />
|
||||
<Route path="/paths" element={<WatchedPathsPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
function JobDetailRoute() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (!id) {
|
||||
return (
|
||||
<Alert color="red" variant="light" icon={<IconAlertTriangle size={16} />}>
|
||||
Geçersiz iş id.
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Button variant="default" leftSection={<IconArrowLeft size={16} />} w="fit-content" onClick={() => navigate('/jobs')}>
|
||||
Jobs listesine dön
|
||||
</Button>
|
||||
<JobDetailPage jobId={id} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,29 @@
|
||||
export async function api<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
function getCoreBaseUrl(): string {
|
||||
const base = import.meta.env.VITE_PUBLIC_CORE_URL || 'http://localhost:3001';
|
||||
const res = await fetch(`${base}${path}`, {
|
||||
return base.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function normalizePath(path: string): string {
|
||||
return path.startsWith('/') ? path : `/${path}`;
|
||||
}
|
||||
|
||||
export function buildApiUrl(path: string): string {
|
||||
return `${getCoreBaseUrl()}${normalizePath(path)}`;
|
||||
}
|
||||
|
||||
export function buildSseUrl(path: string): string {
|
||||
return buildApiUrl(path);
|
||||
}
|
||||
|
||||
export async function api<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(buildApiUrl(path), {
|
||||
headers: { 'content-type': 'application/json', ...(options?.headers || {}) },
|
||||
...options
|
||||
});
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`${res.status} ${res.statusText}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
@@ -1,26 +1,50 @@
|
||||
import { Badge, Paper, ScrollArea, Table, Text } from '@mantine/core';
|
||||
import { Job } from '../types';
|
||||
import { statusColor } from '../lib/status';
|
||||
|
||||
export function JobTable({ jobs, onSelect }: { jobs: Job[]; onSelect: (id: string) => void }) {
|
||||
return (
|
||||
<table width="100%" cellPadding={8} style={{ borderCollapse: 'collapse', background: '#fff' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th align="left">ID</th>
|
||||
<th align="left">Durum</th>
|
||||
<th align="left">Baslik</th>
|
||||
<th align="left">Guncelleme</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{jobs.map((j) => (
|
||||
<tr key={j._id} onClick={() => onSelect(j._id)} style={{ cursor: 'pointer', borderTop: '1px solid #e2e8f0' }}>
|
||||
<td>{j._id.slice(-8)}</td>
|
||||
<td>{j.status}</td>
|
||||
<td>{j.requestSnapshot?.title || '-'}</td>
|
||||
<td>{new Date(j.updatedAt).toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<Paper radius="lg" withBorder p={0} style={{ background: 'rgba(255,255,255,0.88)', borderColor: 'rgba(16,32,50,0.12)' }}>
|
||||
<ScrollArea>
|
||||
<Table highlightOnHover horizontalSpacing="md" verticalSpacing="sm" miw={780}>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>ID</Table.Th>
|
||||
<Table.Th>Durum</Table.Th>
|
||||
<Table.Th>Başlık</Table.Th>
|
||||
<Table.Th>Güncelleme</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{jobs.length === 0 ? (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={4}>
|
||||
<Text c="dimmed" ta="center" py="lg">
|
||||
Kayıt bulunamadı.
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
) : (
|
||||
jobs.map((job) => (
|
||||
<Table.Tr key={job._id} style={{ cursor: 'pointer' }} onClick={() => onSelect(job._id)}>
|
||||
<Table.Td>
|
||||
<Text ff="monospace" size="sm" c="dimmed">
|
||||
{job._id.slice(-8)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={statusColor(job.status)} variant="light">
|
||||
{job.status}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>{job.requestSnapshot?.title || job.requestSnapshot?.path || '-'}</Table.Td>
|
||||
<Table.Td>{new Date(job.updatedAt).toLocaleString()}</Table.Td>
|
||||
</Table.Tr>
|
||||
))
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,118 @@
|
||||
import React from 'react';
|
||||
import { useState } from 'react';
|
||||
import { AppShell, Box, Burger, Group, NavLink, Stack, Text } from '@mantine/core';
|
||||
import {
|
||||
IconChartDonut3,
|
||||
IconListDetails,
|
||||
IconMessage2Exclamation,
|
||||
IconFolderSearch,
|
||||
IconAdjustmentsCog,
|
||||
IconRadar2
|
||||
} from '@tabler/icons-react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
const tabs = ['dashboard', 'jobs', 'review', 'settings', 'paths'] as const;
|
||||
export type Tab = (typeof tabs)[number];
|
||||
interface MenuItem {
|
||||
label: string;
|
||||
path: string;
|
||||
icon: typeof IconChartDonut3;
|
||||
}
|
||||
|
||||
const items: MenuItem[] = [
|
||||
{ label: 'Dashboard', path: '/', icon: IconChartDonut3 },
|
||||
{ label: 'Jobs', path: '/jobs', icon: IconListDetails },
|
||||
{ label: 'Review', path: '/review', icon: IconMessage2Exclamation },
|
||||
{ label: 'Watched Paths', path: '/paths', icon: IconFolderSearch },
|
||||
{ label: 'Settings', path: '/settings', icon: IconAdjustmentsCog }
|
||||
];
|
||||
|
||||
export function Layout({ children }: { children: React.ReactNode }) {
|
||||
const [opened, setOpened] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
export function Layout({ tab, setTab, children }: { tab: Tab; setTab: (t: Tab) => void; children: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', background: 'linear-gradient(120deg,#f6f8fb,#eef3ff)', minHeight: '100vh', color: '#0f172a' }}>
|
||||
<header style={{ padding: 16, borderBottom: '1px solid #dbe2f0', display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<strong>subwatcher</strong>
|
||||
{tabs.map((t) => (
|
||||
<button key={t} onClick={() => setTab(t)} style={{ background: tab === t ? '#1e293b' : '#fff', color: tab === t ? '#fff' : '#111', border: '1px solid #94a3b8', borderRadius: 8, padding: '6px 10px' }}>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</header>
|
||||
<main style={{ padding: 16 }}>{children}</main>
|
||||
</div>
|
||||
<AppShell
|
||||
header={{ height: 70 }}
|
||||
navbar={{ width: 270, breakpoint: 'sm', collapsed: { mobile: !opened } }}
|
||||
padding="lg"
|
||||
styles={{
|
||||
main: {
|
||||
background: 'transparent'
|
||||
},
|
||||
navbar: {
|
||||
backdropFilter: 'blur(10px)',
|
||||
background: 'linear-gradient(180deg, rgba(255,255,255,0.9), rgba(247,251,255,0.94))',
|
||||
borderRight: '1px solid rgba(16,32,50,0.08)'
|
||||
},
|
||||
header: {
|
||||
backdropFilter: 'blur(10px)',
|
||||
background: 'linear-gradient(90deg, rgba(255,255,255,0.9), rgba(244,249,255,0.88))',
|
||||
borderBottom: '1px solid rgba(16,32,50,0.08)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AppShell.Header>
|
||||
<Group h="100%" px="md" justify="space-between">
|
||||
<Group gap="sm">
|
||||
<Burger opened={opened} onClick={() => setOpened((s) => !s)} hiddenFrom="sm" size="sm" />
|
||||
<Group gap={10}>
|
||||
<IconRadar2 size={24} color="#0ea5a3" />
|
||||
<div>
|
||||
<Text className="sw-page-title" fw={700} size="lg">
|
||||
SubWatcher Control
|
||||
</Text>
|
||||
<Text c="dimmed" size="xs">
|
||||
subtitle automation cockpit
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Group>
|
||||
</Group>
|
||||
</AppShell.Header>
|
||||
|
||||
<AppShell.Navbar p="md">
|
||||
<Stack gap="xs">
|
||||
{items.map((item) => {
|
||||
const active = item.path === '/' ? location.pathname === '/' : location.pathname.startsWith(item.path);
|
||||
return (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
active={active}
|
||||
label={item.label}
|
||||
leftSection={<item.icon size={18} />}
|
||||
onClick={() => {
|
||||
navigate(item.path);
|
||||
setOpened(false);
|
||||
}}
|
||||
styles={{
|
||||
root: {
|
||||
borderRadius: 10,
|
||||
color: '#233548'
|
||||
},
|
||||
body: {
|
||||
fontFamily: 'Sora, sans-serif',
|
||||
letterSpacing: '0.02em'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
|
||||
<Box
|
||||
mt="auto"
|
||||
p="sm"
|
||||
style={{ borderRadius: 12, background: 'rgba(14,165,163,0.08)', border: '1px solid rgba(14,165,163,0.2)' }}
|
||||
>
|
||||
<Text size="sm" fw={600} className="sw-page-title">
|
||||
Live Pipeline
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
Durumlar panelden canlı izlenir, hata durumları review ekranına düşer.
|
||||
</Text>
|
||||
</Box>
|
||||
</AppShell.Navbar>
|
||||
|
||||
<AppShell.Main>{children}</AppShell.Main>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
47
services/ui/src/index.css
Normal file
47
services/ui/src/index.css
Normal file
@@ -0,0 +1,47 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Sora:wght@400;500;600;700&family=Source+Serif+4:opsz,wght@8..60,400;8..60,500;8..60,600&display=swap');
|
||||
|
||||
:root {
|
||||
--sw-bg-0: #f7fbff;
|
||||
--sw-bg-1: #f1f6fd;
|
||||
--sw-bg-2: #e9f1fb;
|
||||
--sw-accent: #0ea5a3;
|
||||
--sw-accent-2: #1d4ed8;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background:
|
||||
radial-gradient(circle at 10% 12%, rgba(14, 165, 163, 0.14), transparent 32%),
|
||||
radial-gradient(circle at 88% 10%, rgba(29, 78, 216, 0.1), transparent 34%),
|
||||
linear-gradient(160deg, var(--sw-bg-0), var(--sw-bg-1) 48%, var(--sw-bg-2));
|
||||
color: #1c2733;
|
||||
font-family: 'Source Serif 4', serif;
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background-image: linear-gradient(rgba(14, 23, 38, 0.03) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(14, 23, 38, 0.03) 1px, transparent 1px);
|
||||
background-size: 36px 36px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.sw-page-title {
|
||||
font-family: 'Sora', sans-serif;
|
||||
letter-spacing: 0.02em;
|
||||
margin: 0;
|
||||
color: #102032;
|
||||
}
|
||||
23
services/ui/src/lib/status.ts
Normal file
23
services/ui/src/lib/status.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export const JOB_STATUSES = [
|
||||
'PENDING',
|
||||
'WAITING_FILE_STABLE',
|
||||
'PARSED',
|
||||
'ANALYZED',
|
||||
'REQUESTING_API',
|
||||
'FOUND_TEMP',
|
||||
'NORMALIZING_ENCODING',
|
||||
'WRITING_SUBTITLE',
|
||||
'DONE',
|
||||
'NEEDS_REVIEW',
|
||||
'NOT_FOUND',
|
||||
'AMBIGUOUS',
|
||||
'ERROR'
|
||||
] as const;
|
||||
|
||||
export function statusColor(status: string): string {
|
||||
if (status === 'DONE') return 'teal';
|
||||
if (status === 'NEEDS_REVIEW') return 'yellow';
|
||||
if (status === 'ERROR') return 'red';
|
||||
if (status === 'NOT_FOUND' || status === 'AMBIGUOUS') return 'orange';
|
||||
return 'blue';
|
||||
}
|
||||
@@ -1,9 +1,46 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { createTheme, MantineProvider } from '@mantine/core';
|
||||
import { ModalsProvider } from '@mantine/modals';
|
||||
import { Notifications } from '@mantine/notifications';
|
||||
import App from './App';
|
||||
import '@mantine/core/styles.css';
|
||||
import '@mantine/notifications/styles.css';
|
||||
import './index.css';
|
||||
|
||||
const theme = createTheme({
|
||||
primaryColor: 'cyan',
|
||||
defaultRadius: 'md',
|
||||
fontFamily: '"Source Serif 4", serif',
|
||||
headings: {
|
||||
fontFamily: '"Sora", sans-serif'
|
||||
},
|
||||
colors: {
|
||||
shell: [
|
||||
'#ffffff',
|
||||
'#f7fbff',
|
||||
'#eef5ff',
|
||||
'#dde9f7',
|
||||
'#ccdaec',
|
||||
'#b4c4d8',
|
||||
'#8fa0b3',
|
||||
'#6f7f90',
|
||||
'#556473',
|
||||
'#3a4651'
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
<MantineProvider theme={theme} defaultColorScheme="light">
|
||||
<ModalsProvider>
|
||||
<Notifications position="top-right" />
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</ModalsProvider>
|
||||
</MantineProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { Badge, Card, Group, SimpleGrid, Stack, Text } from '@mantine/core';
|
||||
import { IconAlertTriangle, IconChecklist, IconClockHour4, IconProgressCheck } from '@tabler/icons-react';
|
||||
import { api } from '../api/client';
|
||||
import { Job } from '../types';
|
||||
import { usePoll } from '../hooks/usePoll';
|
||||
@@ -8,37 +10,88 @@ export function DashboardPage({ onSelectJob }: { onSelectJob: (id: string) => vo
|
||||
const [jobs, setJobs] = useState<Job[]>([]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const data = await api<{ items: Job[] }>('/api/jobs?limit=20');
|
||||
const data = await api<{ items: Job[] }>('/api/jobs?limit=30');
|
||||
setJobs(data.items);
|
||||
}, []);
|
||||
|
||||
usePoll(load, 5000);
|
||||
|
||||
const since = Date.now() - 24 * 3600 * 1000;
|
||||
const recent = jobs.filter((x) => new Date(x.createdAt).getTime() >= since);
|
||||
const done = recent.filter((x) => x.status === 'DONE').length;
|
||||
const review = recent.filter((x) => x.status === 'NEEDS_REVIEW').length;
|
||||
const errors = recent.filter((x) => x.status === 'ERROR').length;
|
||||
const stats = useMemo(() => {
|
||||
const since = Date.now() - 24 * 3600 * 1000;
|
||||
const recent = jobs.filter((x) => new Date(x.createdAt).getTime() >= since);
|
||||
return {
|
||||
recent,
|
||||
total: recent.length,
|
||||
done: recent.filter((x) => x.status === 'DONE').length,
|
||||
review: recent.filter((x) => x.status === 'NEEDS_REVIEW').length,
|
||||
error: recent.filter((x) => x.status === 'ERROR').length
|
||||
};
|
||||
}, [jobs]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(120px, 1fr))', gap: 12, marginBottom: 16 }}>
|
||||
<Stat label="24h Job" value={String(recent.length)} />
|
||||
<Stat label="DONE" value={String(done)} />
|
||||
<Stat label="REVIEW" value={String(review)} />
|
||||
<Stat label="ERROR" value={String(errors)} />
|
||||
</div>
|
||||
<h3>Son Isler</h3>
|
||||
<JobTable jobs={jobs} onSelect={onSelectJob} />
|
||||
</div>
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="flex-end">
|
||||
<div>
|
||||
<h1 className="sw-page-title">Operations Dashboard</h1>
|
||||
<Text c="dimmed">Son 24 saatin özetini ve son işleri buradan takip et.</Text>
|
||||
</div>
|
||||
<Badge size="lg" variant="outline" color="cyan">
|
||||
Live polling: 5s
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, xl: 4 }} spacing="md">
|
||||
<StatCard title="24h Total" value={stats.total} color="#3fa6ff" icon={<IconClockHour4 size={18} />} />
|
||||
<StatCard title="Completed" value={stats.done} color="#0ea5a3" icon={<IconProgressCheck size={18} />} />
|
||||
<StatCard title="Needs Review" value={stats.review} color="#e3a008" icon={<IconChecklist size={18} />} />
|
||||
<StatCard title="Errors" value={stats.error} color="#dc4c64" icon={<IconAlertTriangle size={18} />} />
|
||||
</SimpleGrid>
|
||||
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between">
|
||||
<Text fw={600} size="lg" className="sw-page-title">
|
||||
Son İşler
|
||||
</Text>
|
||||
<Text c="dimmed" size="sm">
|
||||
Liste otomatik yenilenir.
|
||||
</Text>
|
||||
</Group>
|
||||
<JobTable jobs={jobs} onSelect={onSelectJob} />
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value: string }) {
|
||||
function StatCard({
|
||||
title,
|
||||
value,
|
||||
color,
|
||||
icon
|
||||
}: {
|
||||
title: string;
|
||||
value: number;
|
||||
color: string;
|
||||
icon: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div style={{ border: '1px solid #cbd5e1', borderRadius: 12, padding: 12, background: '#ffffffcc' }}>
|
||||
<div style={{ fontSize: 12, opacity: 0.7 }}>{label}</div>
|
||||
<div style={{ fontSize: 24, fontWeight: 700 }}>{value}</div>
|
||||
</div>
|
||||
<Card
|
||||
radius="lg"
|
||||
withBorder
|
||||
p="lg"
|
||||
style={{
|
||||
background: 'linear-gradient(160deg, rgba(255,255,255,0.92), rgba(246,251,255,0.9))',
|
||||
borderColor: 'rgba(16,32,50,0.12)'
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" mb={8}>
|
||||
<Text c="dimmed" size="sm" ff="Sora, sans-serif">
|
||||
{title}
|
||||
</Text>
|
||||
<div style={{ color }}>{icon}</div>
|
||||
</Group>
|
||||
<Text fw={700} size="2rem" ff="Sora, sans-serif" c="#18314a">
|
||||
{value}
|
||||
</Text>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,25 +1,54 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from '../api/client';
|
||||
import { Job, JobLog } from '../types';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Group,
|
||||
NumberInput,
|
||||
ScrollArea,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput
|
||||
} from '@mantine/core';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { IconCheck, IconSparkles, IconTerminal2 } from '@tabler/icons-react';
|
||||
import { api, buildSseUrl } from '../api/client';
|
||||
import { Job, JobCandidate, JobLog } from '../types';
|
||||
import { statusColor } from '../lib/status';
|
||||
|
||||
interface ManualOverride {
|
||||
title?: string;
|
||||
year?: number;
|
||||
release?: string;
|
||||
season?: number;
|
||||
episode?: number;
|
||||
}
|
||||
|
||||
export function JobDetailPage({ jobId }: { jobId: string }) {
|
||||
const [job, setJob] = useState<Job | null>(null);
|
||||
const [logs, setLogs] = useState<JobLog[]>([]);
|
||||
const [override, setOverride] = useState<any>({});
|
||||
const [candidates, setCandidates] = useState<any[]>([]);
|
||||
const [override, setOverride] = useState<ManualOverride>({});
|
||||
const [candidates, setCandidates] = useState<JobCandidate[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let es: EventSource | null = null;
|
||||
(async () => {
|
||||
const j = await api<Job>(`/api/jobs/${jobId}`);
|
||||
setJob(j);
|
||||
const l = await api<{ items: JobLog[] }>(`/api/jobs/${jobId}/logs?limit=200`);
|
||||
setLogs(l.items);
|
||||
if (j.apiSnapshot?.candidates) setCandidates(j.apiSnapshot.candidates);
|
||||
const loadedJob = await api<Job>(`/api/jobs/${jobId}`);
|
||||
setJob(loadedJob);
|
||||
|
||||
es = new EventSource(`/api/jobs/${jobId}/stream`);
|
||||
es.onmessage = (ev) => {
|
||||
const item = JSON.parse(ev.data);
|
||||
const loadedLogs = await api<{ items: JobLog[] }>(`/api/jobs/${jobId}/logs?limit=200`);
|
||||
setLogs(loadedLogs.items);
|
||||
|
||||
if (loadedJob.apiSnapshot?.candidates) {
|
||||
setCandidates(loadedJob.apiSnapshot.candidates);
|
||||
}
|
||||
|
||||
es = new EventSource(buildSseUrl(`/api/jobs/${jobId}/stream`));
|
||||
es.onmessage = (event) => {
|
||||
const item = JSON.parse(event.data) as JobLog;
|
||||
setLogs((prev) => [...prev, item]);
|
||||
};
|
||||
})();
|
||||
@@ -30,66 +59,173 @@ export function JobDetailPage({ jobId }: { jobId: string }) {
|
||||
}, [jobId]);
|
||||
|
||||
async function manualSearch() {
|
||||
const res = await api<any>(`/api/review/${jobId}/search`, { method: 'POST', body: JSON.stringify(override) });
|
||||
setCandidates(res.candidates || []);
|
||||
try {
|
||||
const res = await api<{ candidates?: JobCandidate[] }>(`/api/review/${jobId}/search`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(override)
|
||||
});
|
||||
const list = res.candidates || [];
|
||||
setCandidates(list);
|
||||
notifications.show({
|
||||
color: list.length > 0 ? 'teal' : 'yellow',
|
||||
title: 'Arama tamamlandı',
|
||||
message: list.length > 0 ? `${list.length} aday bulundu.` : 'Aday bulunamadı.'
|
||||
});
|
||||
} catch (error) {
|
||||
notifications.show({ color: 'red', title: 'Hata', message: (error as Error).message || 'Arama başarısız.' });
|
||||
}
|
||||
}
|
||||
|
||||
async function choose(c: any) {
|
||||
await api(`/api/review/${jobId}/choose`, { method: 'POST', body: JSON.stringify({ chosenCandidateId: c.id, lang: c.lang || 'tr' }) });
|
||||
const j = await api<Job>(`/api/jobs/${jobId}`);
|
||||
setJob(j);
|
||||
async function choose(candidate: JobCandidate) {
|
||||
try {
|
||||
await api(`/api/review/${jobId}/choose`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ chosenCandidateId: candidate.id, lang: candidate.lang || 'tr' })
|
||||
});
|
||||
|
||||
const refreshed = await api<Job>(`/api/jobs/${jobId}`);
|
||||
setJob(refreshed);
|
||||
notifications.show({ color: 'teal', title: 'Seçim kaydedildi', message: 'Aday başarıyla uygulandı.' });
|
||||
} catch (error) {
|
||||
notifications.show({ color: 'red', title: 'Hata', message: (error as Error).message || 'Aday seçilemedi.' });
|
||||
}
|
||||
}
|
||||
|
||||
if (!job) return <div>Yukleniyor...</div>;
|
||||
if (!job) return <Text c="dimmed">Yükleniyor...</Text>;
|
||||
|
||||
const media = job.mediaFileId;
|
||||
|
||||
const media = job.mediaFileId as any;
|
||||
return (
|
||||
<div style={{ display: 'grid', gap: 12 }}>
|
||||
<h3>Job #{job._id.slice(-8)} - {job.status}</h3>
|
||||
<div style={{ border: '1px solid #cbd5e1', padding: 12, borderRadius: 8, background: '#fff' }}>
|
||||
<div>Baslik: {job.requestSnapshot?.title || '-'}</div>
|
||||
<div>Tip: {job.requestSnapshot?.type || '-'}</div>
|
||||
<div>Yil: {job.requestSnapshot?.year || '-'}</div>
|
||||
<div>Release: {job.requestSnapshot?.release || '-'}</div>
|
||||
<div>Season/Episode: {job.requestSnapshot?.season ?? '-'} / {job.requestSnapshot?.episode ?? '-'}</div>
|
||||
<div>Media: {media?.path || '-'}</div>
|
||||
<div>Video: {media?.mediaInfo?.video?.codec_name || '-'} {media?.mediaInfo?.video?.width || '-'}x{media?.mediaInfo?.video?.height || '-'}</div>
|
||||
<div>Sonuc: {job.result?.subtitles?.map((s: any) => s.writtenPath).join(', ') || '-'}</div>
|
||||
</div>
|
||||
|
||||
{job.status === 'NEEDS_REVIEW' && (
|
||||
<div style={{ border: '1px solid #f59e0b', padding: 12, borderRadius: 8, background: '#fffbeb' }}>
|
||||
<h4>Manual Override</h4>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<input placeholder="title" onChange={(e) => setOverride((x: any) => ({ ...x, title: e.target.value }))} />
|
||||
<input placeholder="year" type="number" onChange={(e) => setOverride((x: any) => ({ ...x, year: Number(e.target.value) }))} />
|
||||
<input placeholder="release" onChange={(e) => setOverride((x: any) => ({ ...x, release: e.target.value }))} />
|
||||
<input placeholder="season" type="number" onChange={(e) => setOverride((x: any) => ({ ...x, season: Number(e.target.value) }))} />
|
||||
<input placeholder="episode" type="number" onChange={(e) => setOverride((x: any) => ({ ...x, episode: Number(e.target.value) }))} />
|
||||
<button onClick={manualSearch}>Search</button>
|
||||
</div>
|
||||
<ul>
|
||||
{candidates.map((c) => (
|
||||
<li key={c.id}>
|
||||
{c.provider} | {c.id} | score={c.score}
|
||||
<button onClick={() => choose(c)}>Sec</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<div>
|
||||
<h1 className="sw-page-title">Job #{job._id.slice(-8)}</h1>
|
||||
<Text c="dimmed">{job.requestSnapshot?.path || 'manual trigger'}</Text>
|
||||
</div>
|
||||
)}
|
||||
<Badge color={statusColor(job.status)} size="lg" variant="light">
|
||||
{job.status}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<div style={{ border: '1px solid #cbd5e1', borderRadius: 8, background: '#fff', padding: 12 }}>
|
||||
<h4>Canli Loglar</h4>
|
||||
<div style={{ maxHeight: 320, overflow: 'auto', fontSize: 12 }}>
|
||||
{logs.map((l) => (
|
||||
<div key={l._id + l.ts} style={{ borderBottom: '1px dashed #e2e8f0', padding: '4px 0' }}>
|
||||
[{new Date(l.ts).toLocaleTimeString()}] {l.step} - {l.message}
|
||||
{l.meta ? ` | meta=${JSON.stringify(l.meta)}` : ''}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Card withBorder radius="lg" p="lg" style={{ background: 'rgba(255,255,255,0.9)', borderColor: 'rgba(16,32,50,0.12)' }}>
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="sm">
|
||||
<Detail label="Başlık" value={job.requestSnapshot?.title || '-'} />
|
||||
<Detail label="Tür" value={job.requestSnapshot?.type || '-'} />
|
||||
<Detail label="Yıl" value={String(job.requestSnapshot?.year || '-')} />
|
||||
<Detail label="Release" value={job.requestSnapshot?.release || '-'} />
|
||||
<Detail label="Season / Episode" value={`${job.requestSnapshot?.season ?? '-'} / ${job.requestSnapshot?.episode ?? '-'}`} />
|
||||
<Detail label="Media" value={media?.path || '-'} />
|
||||
<Detail
|
||||
label="Video"
|
||||
value={`${media?.mediaInfo?.video?.codec_name || '-'} ${media?.mediaInfo?.video?.width || '-'}x${media?.mediaInfo?.video?.height || '-'}`}
|
||||
/>
|
||||
<Detail label="Çıktı" value={job.result?.subtitles?.map((item) => item.writtenPath).join(', ') || '-'} />
|
||||
</SimpleGrid>
|
||||
</Card>
|
||||
|
||||
{job.status === 'NEEDS_REVIEW' ? (
|
||||
<Card withBorder radius="lg" p="lg" style={{ background: 'rgba(255,252,243,0.92)', borderColor: 'rgba(227,160,8,0.3)' }}>
|
||||
<Stack>
|
||||
<Group gap="xs">
|
||||
<IconSparkles size={18} color="#e3a008" />
|
||||
<Text fw={600} className="sw-page-title">
|
||||
Manual Override
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, md: 3 }} spacing="sm">
|
||||
<TextInput placeholder="title" value={override.title || ''} onChange={(e) => setOverride((prev) => ({ ...prev, title: e.currentTarget.value }))} />
|
||||
<NumberInput placeholder="year" value={override.year} onChange={(value) => setOverride((prev) => ({ ...prev, year: Number(value) }))} hideControls />
|
||||
<TextInput placeholder="release" value={override.release || ''} onChange={(e) => setOverride((prev) => ({ ...prev, release: e.currentTarget.value }))} />
|
||||
<NumberInput placeholder="season" value={override.season} onChange={(value) => setOverride((prev) => ({ ...prev, season: Number(value) }))} hideControls />
|
||||
<NumberInput placeholder="episode" value={override.episode} onChange={(value) => setOverride((prev) => ({ ...prev, episode: Number(value) }))} hideControls />
|
||||
<Button onClick={manualSearch}>Search</Button>
|
||||
</SimpleGrid>
|
||||
|
||||
{candidates.length === 0 ? (
|
||||
<Text c="dimmed" size="sm">
|
||||
Aday bulunamadı.
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="xs">
|
||||
{candidates.map((candidate) => (
|
||||
<Group
|
||||
key={candidate.id}
|
||||
justify="space-between"
|
||||
p="sm"
|
||||
style={{ border: '1px solid rgba(16,32,50,0.1)', borderRadius: 10, background: 'rgba(255,255,255,0.75)' }}
|
||||
>
|
||||
<div>
|
||||
<Text fw={600}>{candidate.provider}</Text>
|
||||
<Text c="dimmed" size="sm">
|
||||
{candidate.id} • score {candidate.score}
|
||||
</Text>
|
||||
</div>
|
||||
<Button size="xs" variant="light" leftSection={<IconCheck size={14} />} onClick={() => choose(candidate)}>
|
||||
Seç
|
||||
</Button>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card withBorder radius="lg" p="lg" style={{ background: 'rgba(255,255,255,0.9)', borderColor: 'rgba(16,32,50,0.12)' }}>
|
||||
<Stack gap="sm">
|
||||
<Group gap="xs">
|
||||
<IconTerminal2 size={18} />
|
||||
<Text fw={600} className="sw-page-title">
|
||||
Canlı Log Akışı
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Divider />
|
||||
|
||||
<ScrollArea h={340}>
|
||||
<Stack gap={6}>
|
||||
{logs.length === 0 ? (
|
||||
<Text c="dimmed" size="sm">
|
||||
Henüz log yok.
|
||||
</Text>
|
||||
) : (
|
||||
logs.map((log) => (
|
||||
<Alert key={log._id + log.ts} color={log.level === 'error' ? 'red' : log.level === 'warn' ? 'yellow' : 'teal'} variant="light" p="xs">
|
||||
<Group gap={8} wrap="nowrap" align="flex-start">
|
||||
<Text ff="monospace" size="xs" c="dimmed" mt={2}>
|
||||
[{new Date(log.ts).toLocaleTimeString()}]
|
||||
</Text>
|
||||
<div>
|
||||
<Text fw={600} size="sm" ff="Sora, sans-serif">
|
||||
{log.step}
|
||||
</Text>
|
||||
<Text size="sm">{log.message}</Text>
|
||||
{log.meta ? (
|
||||
<Text size="xs" c="dimmed" ff="monospace">
|
||||
{JSON.stringify(log.meta)}
|
||||
</Text>
|
||||
) : null}
|
||||
</div>
|
||||
</Group>
|
||||
</Alert>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function Detail({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<Text size="xs" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
<Text fw={600}>{value}</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Button, Group, Select, Stack, Text, TextInput } from '@mantine/core';
|
||||
import { IconFilter, IconSearch } from '@tabler/icons-react';
|
||||
import { api } from '../api/client';
|
||||
import { Job } from '../types';
|
||||
import { usePoll } from '../hooks/usePoll';
|
||||
import { JobTable } from '../components/JobTable';
|
||||
import { JOB_STATUSES } from '../lib/status';
|
||||
|
||||
export function JobsPage({ onSelectJob }: { onSelectJob: (id: string) => void }) {
|
||||
const [jobs, setJobs] = useState<Job[]>([]);
|
||||
const [status, setStatus] = useState('');
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const q = new URLSearchParams({ limit: '100' });
|
||||
if (status) q.set('status', status);
|
||||
if (search) q.set('search', search);
|
||||
if (search.trim()) q.set('search', search.trim());
|
||||
const data = await api<{ items: Job[] }>(`/api/jobs?${q.toString()}`);
|
||||
setJobs(data.items);
|
||||
}, [status, search]);
|
||||
@@ -20,13 +23,39 @@ export function JobsPage({ onSelectJob }: { onSelectJob: (id: string) => void })
|
||||
usePoll(load, 4000);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
|
||||
<input placeholder="status" value={status} onChange={(e) => setStatus(e.target.value)} />
|
||||
<input placeholder="title/path" value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
<button onClick={() => load()}>Filtrele</button>
|
||||
<Stack gap="lg">
|
||||
<div>
|
||||
<h1 className="sw-page-title">Jobs Explorer</h1>
|
||||
<Text c="dimmed">Durum ve metin filtresiyle işleri detaylı inceleyin.</Text>
|
||||
</div>
|
||||
|
||||
<Group align="end" gap="md" wrap="wrap">
|
||||
<Select
|
||||
label="Status"
|
||||
placeholder="Tüm durumlar"
|
||||
data={JOB_STATUSES.map((value) => ({ value, label: value }))}
|
||||
value={status}
|
||||
onChange={setStatus}
|
||||
leftSection={<IconFilter size={16} />}
|
||||
w={260}
|
||||
clearable
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Ara"
|
||||
placeholder="title/path"
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.currentTarget.value)}
|
||||
leftSection={<IconSearch size={16} />}
|
||||
w={320}
|
||||
/>
|
||||
|
||||
<Button variant="light" onClick={() => load()}>
|
||||
Filtreyi Uygula
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<JobTable jobs={jobs} onSelect={onSelectJob} />
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { ActionIcon, Badge, Card, Group, Stack, Text } from '@mantine/core';
|
||||
import { IconArrowRight } from '@tabler/icons-react';
|
||||
import { api } from '../api/client';
|
||||
import { Job } from '../types';
|
||||
import { usePoll } from '../hooks/usePoll';
|
||||
@@ -8,22 +10,46 @@ export function ReviewPage({ onSelectJob }: { onSelectJob: (id: string) => void
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const data = await api<Job[]>('/api/review');
|
||||
setJobs(data);
|
||||
setJobs(data || []);
|
||||
}, []);
|
||||
|
||||
usePoll(load, 5000);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3>Needs Review</h3>
|
||||
<ul>
|
||||
{jobs.map((j) => (
|
||||
<li key={j._id}>
|
||||
{j.requestSnapshot?.title || '-'} ({j.status})
|
||||
<button onClick={() => onSelectJob(j._id)}>Ac</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between">
|
||||
<div>
|
||||
<h1 className="sw-page-title">Manual Review Queue</h1>
|
||||
<Text c="dimmed">Belirsiz veya bulunamayan işler burada manuel karar bekler.</Text>
|
||||
</div>
|
||||
<Badge size="lg" color="yellow" variant="light">
|
||||
{jobs.length} pending
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<Stack gap="sm">
|
||||
{jobs.length === 0 ? (
|
||||
<Card withBorder radius="lg" p="lg" style={{ background: 'rgba(255,255,255,0.9)', borderColor: 'rgba(16,32,50,0.12)' }}>
|
||||
<Text c="dimmed">Review bekleyen iş yok.</Text>
|
||||
</Card>
|
||||
) : (
|
||||
jobs.map((job) => (
|
||||
<Card key={job._id} withBorder radius="lg" p="lg" style={{ background: 'rgba(255,255,255,0.9)', borderColor: 'rgba(16,32,50,0.12)' }}>
|
||||
<Group justify="space-between" align="center">
|
||||
<div>
|
||||
<Text fw={600}>{job.requestSnapshot?.title || job.requestSnapshot?.path || 'Unknown job'}</Text>
|
||||
<Text c="dimmed" size="sm">
|
||||
{job.status} • {new Date(job.updatedAt).toLocaleString()}
|
||||
</Text>
|
||||
</div>
|
||||
<ActionIcon variant="light" size="lg" onClick={() => onSelectJob(job._id)}>
|
||||
<IconArrowRight size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,32 +1,125 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Card, Checkbox, Grid, Group, NumberInput, Stack, Text, TextInput } from '@mantine/core';
|
||||
import { IconDeviceFloppy } from '@tabler/icons-react';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { api } from '../api/client';
|
||||
import { Settings } from '../types';
|
||||
|
||||
export function SettingsPage() {
|
||||
const [settings, setSettings] = useState<any>(null);
|
||||
const [settings, setSettings] = useState<Settings | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api<any>('/api/settings').then(setSettings);
|
||||
api<Settings>('/api/settings').then(setSettings);
|
||||
}, []);
|
||||
|
||||
async function save() {
|
||||
await api('/api/settings', { method: 'POST', body: JSON.stringify(settings) });
|
||||
if (!settings) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await api('/api/settings', { method: 'POST', body: JSON.stringify(settings) });
|
||||
notifications.show({ color: 'teal', title: 'Kaydedildi', message: 'Ayarlar başarıyla güncellendi.' });
|
||||
} catch (error) {
|
||||
notifications.show({ color: 'red', title: 'Hata', message: (error as Error).message || 'Ayarlar kaydedilemedi.' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!settings) return <div>Yukleniyor...</div>;
|
||||
if (!settings) return <Text c="dimmed">Yükleniyor...</Text>;
|
||||
|
||||
return (
|
||||
<div style={{ display: 'grid', gap: 8, maxWidth: 760 }}>
|
||||
<label>Languages (comma)
|
||||
<input value={settings.languages.join(',')} onChange={(e) => setSettings({ ...settings, languages: e.target.value.split(',').map((x) => x.trim()).filter(Boolean) })} />
|
||||
</label>
|
||||
<label><input type="checkbox" checked={settings.multiSubtitleEnabled} onChange={(e) => setSettings({ ...settings, multiSubtitleEnabled: e.target.checked })} /> Multi subtitle</label>
|
||||
<label><input type="checkbox" checked={settings.overwriteExisting} onChange={(e) => setSettings({ ...settings, overwriteExisting: e.target.checked })} /> Overwrite existing</label>
|
||||
<label><input type="checkbox" checked={settings.preferHI} onChange={(e) => setSettings({ ...settings, preferHI: e.target.checked })} /> Prefer HI</label>
|
||||
<label><input type="checkbox" checked={settings.preferForced} onChange={(e) => setSettings({ ...settings, preferForced: e.target.checked })} /> Prefer Forced</label>
|
||||
<label>stableChecks <input type="number" value={settings.stableChecks} onChange={(e) => setSettings({ ...settings, stableChecks: Number(e.target.value) })} /></label>
|
||||
<label>stableIntervalSeconds <input type="number" value={settings.stableIntervalSeconds} onChange={(e) => setSettings({ ...settings, stableIntervalSeconds: Number(e.target.value) })} /></label>
|
||||
<label>autoWriteThreshold <input type="number" step="0.01" value={settings.autoWriteThreshold} onChange={(e) => setSettings({ ...settings, autoWriteThreshold: Number(e.target.value) })} /></label>
|
||||
<button onClick={save}>Kaydet</button>
|
||||
</div>
|
||||
<Stack gap="lg">
|
||||
<div>
|
||||
<h1 className="sw-page-title">Settings</h1>
|
||||
<Text c="dimmed">Arama, stabilite ve yazma davranışlarını buradan yönetin.</Text>
|
||||
</div>
|
||||
|
||||
<Card withBorder radius="lg" p="lg" style={{ background: 'rgba(255,255,255,0.9)', borderColor: 'rgba(16,32,50,0.12)' }}>
|
||||
<Stack>
|
||||
<TextInput
|
||||
label="Öncelikli diller"
|
||||
description="Virgülle ayırın (örn: tr, en)"
|
||||
value={settings.languages.join(', ')}
|
||||
onChange={(event) =>
|
||||
setSettings({
|
||||
...settings,
|
||||
languages: event.currentTarget.value
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<Grid>
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<Checkbox
|
||||
label="Multi subtitle enabled"
|
||||
checked={settings.multiSubtitleEnabled}
|
||||
onChange={(event) => setSettings({ ...settings, multiSubtitleEnabled: event.currentTarget.checked })}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<Checkbox
|
||||
label="Overwrite existing"
|
||||
checked={settings.overwriteExisting}
|
||||
onChange={(event) => setSettings({ ...settings, overwriteExisting: event.currentTarget.checked })}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<Checkbox
|
||||
label="Prefer HI"
|
||||
checked={settings.preferHI}
|
||||
onChange={(event) => setSettings({ ...settings, preferHI: event.currentTarget.checked })}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<Checkbox
|
||||
label="Prefer forced"
|
||||
checked={settings.preferForced}
|
||||
onChange={(event) => setSettings({ ...settings, preferForced: event.currentTarget.checked })}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
<Grid>
|
||||
<Grid.Col span={{ base: 12, md: 4 }}>
|
||||
<NumberInput
|
||||
label="Stable checks"
|
||||
min={1}
|
||||
value={settings.stableChecks}
|
||||
onChange={(value) => setSettings({ ...settings, stableChecks: Number(value) })}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, md: 4 }}>
|
||||
<NumberInput
|
||||
label="Stable interval seconds"
|
||||
min={1}
|
||||
value={settings.stableIntervalSeconds}
|
||||
onChange={(value) => setSettings({ ...settings, stableIntervalSeconds: Number(value) })}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, md: 4 }}>
|
||||
<NumberInput
|
||||
label="Auto write threshold"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
decimalScale={2}
|
||||
value={settings.autoWriteThreshold}
|
||||
onChange={(value) => setSettings({ ...settings, autoWriteThreshold: Number(value) })}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button loading={saving} leftSection={<IconDeviceFloppy size={16} />} onClick={save}>
|
||||
Kaydet
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,60 +1,160 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Button,
|
||||
Group,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
TextInput
|
||||
} from '@mantine/core';
|
||||
import { modals } from '@mantine/modals';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { IconPlus, IconTrash, IconToggleLeft, IconToggleRight } from '@tabler/icons-react';
|
||||
import { api } from '../api/client';
|
||||
import { usePoll } from '../hooks/usePoll';
|
||||
import { WatchedPath } from '../types';
|
||||
|
||||
export function WatchedPathsPage() {
|
||||
const [items, setItems] = useState<any[]>([]);
|
||||
const [items, setItems] = useState<WatchedPath[]>([]);
|
||||
const [path, setPath] = useState('');
|
||||
const [kind, setKind] = useState('mixed');
|
||||
const [kind, setKind] = useState<string>('mixed');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const data = await api<any[]>('/api/watched-paths');
|
||||
setItems(data);
|
||||
const data = await api<WatchedPath[]>('/api/watched-paths');
|
||||
setItems(data || []);
|
||||
}, []);
|
||||
|
||||
usePoll(load, 5000);
|
||||
|
||||
async function add() {
|
||||
await api('/api/watched-paths', { method: 'POST', body: JSON.stringify({ action: 'add', path, kind }) });
|
||||
setPath('');
|
||||
await load();
|
||||
if (!path.trim()) return;
|
||||
try {
|
||||
await api('/api/watched-paths', { method: 'POST', body: JSON.stringify({ action: 'add', path: path.trim(), kind }) });
|
||||
setPath('');
|
||||
notifications.show({ color: 'teal', title: 'Eklendi', message: 'Watched path başarıyla eklendi.' });
|
||||
await load();
|
||||
} catch (error) {
|
||||
notifications.show({ color: 'red', title: 'Hata', message: (error as Error).message || 'Path eklenemedi.' });
|
||||
}
|
||||
}
|
||||
|
||||
async function toggle(item: any) {
|
||||
await api('/api/watched-paths', { method: 'POST', body: JSON.stringify({ action: 'toggle', path: item.path, enabled: !item.enabled }) });
|
||||
await load();
|
||||
async function toggle(item: WatchedPath) {
|
||||
try {
|
||||
await api('/api/watched-paths', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ action: 'toggle', path: item.path, enabled: !item.enabled })
|
||||
});
|
||||
notifications.show({ color: 'cyan', title: 'Güncellendi', message: `${item.path} durumu değiştirildi.` });
|
||||
await load();
|
||||
} catch (error) {
|
||||
notifications.show({ color: 'red', title: 'Hata', message: (error as Error).message || 'Durum güncellenemedi.' });
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(item: any) {
|
||||
await api('/api/watched-paths', { method: 'POST', body: JSON.stringify({ action: 'remove', path: item.path }) });
|
||||
await load();
|
||||
function remove(item: WatchedPath) {
|
||||
modals.openConfirmModal({
|
||||
title: 'Path silinsin mi?',
|
||||
centered: true,
|
||||
children: <Text size="sm">{item.path} izleme listesinden çıkarılacak.</Text>,
|
||||
labels: { confirm: 'Sil', cancel: 'Vazgeç' },
|
||||
confirmProps: { color: 'red' },
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await api('/api/watched-paths', { method: 'POST', body: JSON.stringify({ action: 'remove', path: item.path }) });
|
||||
notifications.show({ color: 'teal', title: 'Silindi', message: 'Watched path kaldırıldı.' });
|
||||
await load();
|
||||
} catch (error) {
|
||||
notifications.show({ color: 'red', title: 'Hata', message: (error as Error).message || 'Path silinemedi.' });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
|
||||
<input placeholder="/media/custom" value={path} onChange={(e) => setPath(e.target.value)} />
|
||||
<select value={kind} onChange={(e) => setKind(e.target.value)}>
|
||||
<option value="tv">tv</option>
|
||||
<option value="movie">movie</option>
|
||||
<option value="mixed">mixed</option>
|
||||
</select>
|
||||
<button onClick={add}>Ekle</button>
|
||||
<Stack gap="lg">
|
||||
<div>
|
||||
<h1 className="sw-page-title">Watched Paths</h1>
|
||||
<Text c="dimmed">Core watcher’ın izleyeceği dizinleri buradan yönetin.</Text>
|
||||
</div>
|
||||
<table width="100%" cellPadding={8} style={{ borderCollapse: 'collapse', background: '#fff' }}>
|
||||
<thead><tr><th align="left">Path</th><th align="left">Kind</th><th align="left">Enabled</th><th /></tr></thead>
|
||||
<tbody>
|
||||
{items.map((i) => (
|
||||
<tr key={i.path} style={{ borderTop: '1px solid #e2e8f0' }}>
|
||||
<td>{i.path}</td><td>{i.kind}</td><td>{String(i.enabled)}</td>
|
||||
<td>
|
||||
<button onClick={() => toggle(i)}>Toggle</button>
|
||||
<button onClick={() => remove(i)}>Sil</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Group align="end" wrap="wrap">
|
||||
<TextInput
|
||||
label="Yeni dizin"
|
||||
placeholder="/media/custom"
|
||||
value={path}
|
||||
onChange={(event) => setPath(event.currentTarget.value)}
|
||||
w={420}
|
||||
/>
|
||||
<Select
|
||||
label="İçerik tipi"
|
||||
value={kind}
|
||||
onChange={(value) => setKind(value || 'mixed')}
|
||||
data={[
|
||||
{ value: 'tv', label: 'TV' },
|
||||
{ value: 'movie', label: 'Movie' },
|
||||
{ value: 'mixed', label: 'Mixed' }
|
||||
]}
|
||||
w={140}
|
||||
/>
|
||||
<Button leftSection={<IconPlus size={16} />} onClick={add}>
|
||||
Ekle
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Paper radius="lg" withBorder p={0} style={{ background: 'rgba(255,255,255,0.88)', borderColor: 'rgba(16,32,50,0.12)' }}>
|
||||
<ScrollArea>
|
||||
<Table horizontalSpacing="md" verticalSpacing="sm" miw={780}>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Path</Table.Th>
|
||||
<Table.Th>Kind</Table.Th>
|
||||
<Table.Th>Enabled</Table.Th>
|
||||
<Table.Th></Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{items.length === 0 ? (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={4}>
|
||||
<Text c="dimmed" ta="center" py="md">
|
||||
Henüz watched path eklenmedi.
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
) : (
|
||||
items.map((item) => (
|
||||
<Table.Tr key={item.path}>
|
||||
<Table.Td>{item.path}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge variant="light">{item.kind}</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={item.enabled ? 'teal' : 'gray'} variant="dot">
|
||||
{item.enabled ? 'Enabled' : 'Disabled'}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group justify="flex-end" gap={6} wrap="nowrap">
|
||||
<ActionIcon variant="light" color={item.enabled ? 'yellow' : 'teal'} onClick={() => toggle(item)}>
|
||||
{item.enabled ? <IconToggleLeft size={16} /> : <IconToggleRight size={16} />}
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="light" color="red" onClick={() => remove(item)}>
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,40 @@
|
||||
export interface JobRequestSnapshot {
|
||||
title?: string;
|
||||
path?: string;
|
||||
type?: string;
|
||||
year?: number;
|
||||
release?: string;
|
||||
season?: number;
|
||||
episode?: number;
|
||||
}
|
||||
|
||||
export interface JobCandidate {
|
||||
id: string;
|
||||
provider: string;
|
||||
score: number;
|
||||
lang?: string;
|
||||
}
|
||||
|
||||
export interface Job {
|
||||
_id: string;
|
||||
status: string;
|
||||
requestSnapshot?: any;
|
||||
apiSnapshot?: any;
|
||||
result?: any;
|
||||
mediaFileId?: any;
|
||||
requestSnapshot?: JobRequestSnapshot;
|
||||
apiSnapshot?: {
|
||||
candidates?: JobCandidate[];
|
||||
};
|
||||
result?: {
|
||||
subtitles?: Array<{ writtenPath: string }>;
|
||||
};
|
||||
mediaFileId?: {
|
||||
path?: string;
|
||||
mediaInfo?: {
|
||||
video?: {
|
||||
codec_name?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -16,5 +46,22 @@ export interface JobLog {
|
||||
message: string;
|
||||
level: 'info' | 'warn' | 'error';
|
||||
ts: string;
|
||||
meta?: any;
|
||||
meta?: unknown;
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
languages: string[];
|
||||
multiSubtitleEnabled: boolean;
|
||||
overwriteExisting: boolean;
|
||||
preferHI: boolean;
|
||||
preferForced: boolean;
|
||||
stableChecks: number;
|
||||
stableIntervalSeconds: number;
|
||||
autoWriteThreshold: number;
|
||||
}
|
||||
|
||||
export interface WatchedPath {
|
||||
path: string;
|
||||
kind: 'tv' | 'movie' | 'mixed';
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user