feat: watcher akislarini ve wscraper servis entegrasyonunu ekle

This commit is contained in:
2026-03-12 22:30:43 +03:00
parent 6507d13325
commit baad2b3e96
34 changed files with 2663 additions and 11 deletions

View File

@@ -3,6 +3,7 @@ import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
import { LoginPage } from "./pages/LoginPage";
import { DashboardPage } from "./pages/DashboardPage";
import { TimerPage } from "./pages/TimerPage";
import { WatcherPage } from "./pages/WatcherPage";
import { AppLayout } from "./components/layout/AppLayout";
import { useAuthStore } from "./store/useAuthStore";
@@ -25,6 +26,7 @@ export const App = () => {
<Route path="/" element={<Navigate to="/buffer" replace />} />
<Route path="/buffer" element={<DashboardPage />} />
<Route path="/timer" element={<TimerPage />} />
<Route path="/watcher" element={<WatcherPage />} />
<Route path="*" element={<Navigate to="/buffer" replace />} />
</Routes>
</AppLayout>

View File

@@ -112,6 +112,16 @@ export const AppLayout = ({ children }: { children: React.ReactNode }) => {
>
Timer
</NavLink>
<NavLink
to="/watcher"
className={({ isActive }) =>
`rounded-full px-3 py-1 ${
isActive ? "bg-slate-900 text-white" : "hover:bg-white"
}`
}
>
Watcher
</NavLink>
</nav>
<Button
variant="outline"
@@ -164,6 +174,17 @@ export const AppLayout = ({ children }: { children: React.ReactNode }) => {
>
Timer
</NavLink>
<NavLink
to="/watcher"
onClick={() => setMenuOpen(false)}
className={({ isActive }) =>
`rounded-lg border px-3 py-2 ${
isActive ? "border-slate-900 bg-slate-900 text-white" : "border-slate-200 bg-white"
}`
}
>
Watcher
</NavLink>
</nav>
<div className="mt-auto flex items-center gap-3">
<Button

View File

@@ -0,0 +1,22 @@
import React from "react";
import clsx from "clsx";
export const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.TextareaHTMLAttributes<HTMLTextAreaElement> & { masked?: boolean }
>(({ className, masked = false, style, ...props }, ref) => (
<textarea
ref={ref}
className={clsx(
"w-full rounded-xl border border-slate-300 bg-white px-3 py-3 text-sm text-slate-900 focus:border-slate-500 focus:outline-none focus:ring-2 focus:ring-slate-200",
className
)}
style={{
...(masked ? ({ WebkitTextSecurity: "disc" } as React.CSSProperties) : {}),
...style,
}}
{...props}
/>
));
Textarea.displayName = "Textarea";

View File

@@ -0,0 +1,782 @@
import React, { useEffect, useMemo, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faArrowsRotate,
faCheckCircle,
faClock,
faPlay,
faDownload,
faEye,
faEyeSlash,
faLayerGroup,
faPlus,
faSave,
faSatelliteDish,
faTrash,
faToggleOff,
faToggleOn,
faTriangleExclamation,
faWandMagicSparkles,
} from "@fortawesome/free-solid-svg-icons";
import { api } from "../api/client";
import {
Watcher,
WatcherItem,
WatcherSummary,
useAppStore,
} from "../store/useAppStore";
import { useUiStore } from "../store/useUiStore";
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/Card";
import { Button } from "../components/ui/Button";
import { Input } from "../components/ui/Input";
import { Textarea } from "../components/ui/Textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../components/ui/Select";
import { Badge } from "../components/ui/Badge";
const intervalUnits = [
{ label: "Dakika", value: "minutes", multiplier: 1 },
{ label: "Saat", value: "hours", multiplier: 60 },
] as const;
const formatDate = (value?: string) => {
if (!value) return "—";
return new Date(value).toLocaleString("tr-TR");
};
const formatBytes = (value?: number) => {
if (!value || !Number.isFinite(value)) return "Boyut bilinmiyor";
const units = ["B", "KB", "MB", "GB", "TB"];
let current = value;
let unit = 0;
while (current >= 1024 && unit < units.length - 1) {
current /= 1024;
unit += 1;
}
return `${current.toFixed(current >= 10 ? 0 : 1)} ${units[unit]}`;
};
const compactStateLabel = (value?: string) => {
if (!value) return "Bekliyor";
const normalized = value.replace(/_/g, " ");
return normalized.length > 18 ? normalized.slice(0, 18) : normalized;
};
const progressLabel = (item: WatcherItem) => `${Math.round((item.progress ?? 0) * 100)}%`;
const buildWatcherImageUrl = (item: WatcherItem) => {
if (!item.imageUrl) {
return null;
}
const base = import.meta.env.VITE_API_BASE || "";
const params = new URLSearchParams({
watcherId: item.watcherId,
url: item.imageUrl,
});
return `${base}/api/watchers/image?${params.toString()}`;
};
const statusVariant = (status: string): "success" | "warn" | "danger" => {
if (status === "completed" || status === "sent_to_qbit") return "success";
if (status === "failed") return "danger";
return "warn";
};
const placeholderImage =
"linear-gradient(135deg, rgba(15,23,42,0.92), rgba(8,47,73,0.78) 55%, rgba(14,165,233,0.44) 100%)";
export const WatcherPage = () => {
const watcherTrackers = useAppStore((s) => s.watcherTrackers);
const qbitCategories = useAppStore((s) => s.qbitCategories);
const watchers = useAppStore((s) => s.watchers);
const watcherItems = useAppStore((s) => s.watcherItems);
const watcherSummary = useAppStore((s) => s.watcherSummary);
const setWatcherTrackers = useAppStore((s) => s.setWatcherTrackers);
const setQbitCategories = useAppStore((s) => s.setQbitCategories);
const setWatchers = useAppStore((s) => s.setWatchers);
const setWatcherItems = useAppStore((s) => s.setWatcherItems);
const setWatcherSummary = useAppStore((s) => s.setWatcherSummary);
const pushAlert = useUiStore((s) => s.pushAlert);
const [selectedWatcherId, setSelectedWatcherId] = useState<string | null>(null);
const [tracker, setTracker] = useState("happyfappy");
const [cookie, setCookie] = useState("");
const [category, setCategory] = useState("__none__");
const [showCookie, setShowCookie] = useState(false);
const [intervalValue, setIntervalValue] = useState("30");
const [intervalUnit, setIntervalUnit] = useState<(typeof intervalUnits)[number]["value"]>("minutes");
const [enabled, setEnabled] = useState(true);
const [saving, setSaving] = useState(false);
const [running, setRunning] = useState(false);
const [deleting, setDeleting] = useState(false);
const selectedWatcher = useMemo(
() => watchers.find((watcher) => watcher.id === selectedWatcherId) ?? null,
[selectedWatcherId, watchers]
);
const load = async () => {
const [trackersRes, categoriesRes, watchersRes, summaryRes, itemsRes] =
await Promise.allSettled([
api.get("/api/watchers/trackers"),
api.get("/api/watchers/categories"),
api.get("/api/watchers"),
api.get("/api/watchers/summary"),
api.get("/api/watchers/items"),
]);
const watchersLoaded =
watchersRes.status === "fulfilled" ? watchersRes.value.data ?? [] : null;
if (!watchersLoaded && trackersRes.status !== "fulfilled") {
pushAlert({
title: "Watcher verileri alinmadi",
description: "Sunucu baglantisini kontrol edip tekrar deneyin.",
variant: "error",
});
return;
}
if (trackersRes.status === "fulfilled") {
setWatcherTrackers(trackersRes.value.data ?? []);
}
if (categoriesRes.status === "fulfilled") {
setQbitCategories(categoriesRes.value.data ?? []);
} else {
setQbitCategories([]);
}
if (watchersLoaded) {
setWatchers(watchersLoaded);
if (!selectedWatcherId && watchersLoaded[0]?.id) {
setSelectedWatcherId(watchersLoaded[0].id);
}
}
if (summaryRes.status === "fulfilled") {
setWatcherSummary(summaryRes.value.data ?? null);
}
if (itemsRes.status === "fulfilled") {
setWatcherItems(itemsRes.value.data ?? []);
}
};
useEffect(() => {
load();
const interval = setInterval(load, 15000);
return () => clearInterval(interval);
}, []);
useEffect(() => {
if (!selectedWatcher) {
return;
}
setTracker(selectedWatcher.tracker);
setCategory(selectedWatcher.category ?? "__none__");
setCookie("");
setShowCookie(false);
setEnabled(selectedWatcher.enabled);
if (selectedWatcher.intervalMinutes >= 60 && selectedWatcher.intervalMinutes % 60 === 0) {
setIntervalUnit("hours");
setIntervalValue(String(selectedWatcher.intervalMinutes / 60));
} else {
setIntervalUnit("minutes");
setIntervalValue(String(selectedWatcher.intervalMinutes));
}
}, [selectedWatcher]);
const intervalMinutes = useMemo(() => {
const unit = intervalUnits.find((entry) => entry.value === intervalUnit) ?? intervalUnits[0];
return Math.max(1, Number(intervalValue || 0) * unit.multiplier);
}, [intervalUnit, intervalValue]);
const resetForm = () => {
setSelectedWatcherId(null);
setTracker(watcherTrackers[0]?.key ?? "happyfappy");
setCategory("__none__");
setCookie("");
setShowCookie(false);
setIntervalValue("30");
setIntervalUnit("minutes");
setEnabled(true);
};
const submit = async () => {
if (!cookie.trim() && !selectedWatcher) {
pushAlert({
title: "Cookie gerekli",
description: "Yeni watcher eklemek icin cookie girin.",
variant: "warn",
});
return;
}
setSaving(true);
try {
if (selectedWatcher) {
await api.patch(`/api/watchers/${selectedWatcher.id}`, {
tracker,
category: category === "__none__" ? "" : category,
intervalMinutes,
enabled,
...(cookie.trim() ? { cookie } : {}),
});
pushAlert({
title: "Watcher guncellendi",
description: `${selectedWatcher.trackerLabel} ayarlari kaydedildi.`,
variant: "success",
});
} else {
const response = await api.post("/api/watchers", {
tracker,
category: category === "__none__" ? "" : category,
cookie,
intervalMinutes,
enabled,
});
setWatchers([response.data, ...watchers]);
setSelectedWatcherId(response.data.id);
pushAlert({
title: "Watcher eklendi",
description: "Yeni bookmark watcher kaydedildi.",
variant: "success",
});
}
void load();
setCookie("");
setShowCookie(false);
} catch (error: any) {
pushAlert({
title: "Kaydetme basarisiz",
description: error?.response?.data?.error ?? "Watcher kaydedilemedi.",
variant: "error",
});
} finally {
setSaving(false);
}
};
const toggleWatcher = async (watcher: Watcher) => {
try {
await api.patch(`/api/watchers/${watcher.id}`, {
enabled: !watcher.enabled,
});
await load();
} catch (error: any) {
pushAlert({
title: "Durum degismedi",
description: error?.response?.data?.error ?? "Watcher durumu guncellenemedi.",
variant: "error",
});
}
};
const runNow = async () => {
if (!selectedWatcher) {
pushAlert({
title: "Once kaydet",
description: "Manuel calistirma icin once watcher secin.",
variant: "warn",
});
return;
}
setRunning(true);
try {
await api.post(`/api/watchers/${selectedWatcher.id}/run`);
await load();
pushAlert({
title: "Watcher calistirildi",
description: "Gercek import akisi bir kez manuel olarak tetiklendi.",
variant: "success",
});
} catch (error: any) {
pushAlert({
title: "Calistirma basarisiz",
description: error?.response?.data?.error ?? "Watcher manuel olarak calistirilamadi.",
variant: "error",
});
} finally {
setRunning(false);
}
};
const removeWatcher = async (watcherOverride?: Watcher | null) => {
const watcherToDelete = watcherOverride ?? selectedWatcher;
if (!watcherToDelete) {
return;
}
const confirmed = window.confirm(
`${watcherToDelete.trackerLabel} watcher silinsin mi? Ilgili watcher item kayitlari da silinecek.`
);
if (!confirmed) {
return;
}
setDeleting(true);
try {
await api.delete(`/api/watchers/${watcherToDelete.id}`);
await load();
if (!watcherOverride || watcherOverride.id === selectedWatcherId) {
resetForm();
}
pushAlert({
title: "Watcher silindi",
description: "Kayit ve iliskili item gecmisi kaldirildi.",
variant: "success",
});
} catch (error: any) {
pushAlert({
title: "Silme basarisiz",
description: error?.response?.data?.error ?? "Watcher silinemedi.",
variant: "error",
});
} finally {
setDeleting(false);
}
};
return (
<div className="grid w-full grid-cols-1 gap-6 xl:grid-cols-[1.45fr_0.85fr]">
<div className="min-w-0 space-y-5">
<Card className="overflow-hidden border-slate-200 bg-[radial-gradient(circle_at_top_left,_rgba(15,118,110,0.12),_transparent_42%),linear-gradient(180deg,rgba(255,255,255,0.98),rgba(248,250,252,0.92))]">
<CardHeader className="items-start">
<div>
<CardTitle className="flex items-center gap-3">
<span className="inline-flex h-11 w-11 items-center justify-center rounded-2xl bg-slate-900 text-white shadow-lg shadow-slate-900/20">
<FontAwesomeIcon icon={faSatelliteDish} />
</span>
Watcher Feed
</CardTitle>
<p className="mt-2 max-w-2xl text-sm text-slate-500">
Bookmark kaynaklarindan gelen torrentler burada tek akista gorunur. Kartlar,
qBittorrent aktarim sonucunu ve canli indirme durumunu bir arada gosterir.
</p>
</div>
<Button variant="outline" onClick={load} className="gap-2">
<FontAwesomeIcon icon={faArrowsRotate} />
Yenile
</Button>
</CardHeader>
<CardContent>
{watcherItems.length === 0 ? (
<div className="rounded-2xl border border-dashed border-slate-300 bg-white/70 px-5 py-10 text-center text-sm text-slate-500">
Henuz watcher item yok. Sag taraftan bir watcher ekleyip calistirarak akisi
baslatabilirsiniz.
</div>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{watcherItems.map((item) => {
const proxiedImageUrl = buildWatcherImageUrl(item);
return (
<article
key={item.id}
className="overflow-hidden rounded-[1.25rem] border border-slate-200 bg-white shadow-[0_20px_60px_-40px_rgba(15,23,42,0.42)]"
>
<div className="relative h-36 bg-slate-900">
{proxiedImageUrl ? (
<img
src={proxiedImageUrl}
alt={item.title}
className="absolute inset-0 h-full w-full object-cover"
onError={(event) => {
event.currentTarget.style.display = "none";
}}
/>
) : (
<div
className="absolute inset-0"
style={{ backgroundImage: placeholderImage }}
/>
)}
<div className="absolute inset-0 bg-gradient-to-t from-slate-950/46 via-slate-950/10 to-transparent" />
<div className="absolute bottom-4 left-4 right-4">
<div className="text-xs uppercase tracking-[0.22em] text-slate-200/80">
{formatDate(item.lastSyncAt)}
</div>
<div className="mt-1 truncate text-[0.98rem] font-semibold leading-6 text-white">
{item.title}
</div>
</div>
</div>
<div className="space-y-3 p-4">
<div className="flex flex-wrap gap-2">
<Badge variant={statusVariant(item.status)}>{item.statusLabel}</Badge>
<Badge variant="default">{item.trackerLabel}</Badge>
</div>
<div className="flex flex-wrap items-center gap-2 text-[11px] font-semibold text-slate-500">
<span className="rounded-full bg-slate-100 px-2.5 py-1">
{compactStateLabel(item.state)}
</span>
<span className="rounded-full bg-slate-100 px-2.5 py-1">
{item.qbitCategory?.trim() ? item.qbitCategory : "Kategori yok"}
</span>
<span className="rounded-full bg-slate-100 px-2.5 py-1">
Seeds {item.seeders ?? 0}
{typeof item.leechers === "number" ? ` / ${item.leechers}` : ""}
</span>
</div>
<div className="grid grid-cols-2 gap-3 text-sm text-slate-600">
<div className="rounded-2xl bg-slate-50 px-3 py-2">
<div className="text-[10px] uppercase tracking-[0.16em] text-slate-400">
Boyut
</div>
<div className="mt-1 text-[0.92rem] font-semibold text-slate-800">
{formatBytes(item.sizeBytes)}
</div>
</div>
<div className="rounded-2xl bg-slate-50 px-3 py-2">
<div className="text-[10px] uppercase tracking-[0.16em] text-slate-400">
Durum
</div>
<div className="mt-1 text-[0.92rem] font-semibold text-slate-800">
{item.state ?? "Bekleniyor"}
</div>
</div>
</div>
<div>
<div className="mb-2 flex items-center justify-between text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-400">
<span>Qbit Progress</span>
<span>{progressLabel(item)}</span>
</div>
<div className="h-2 rounded-full bg-slate-100">
<div
className="h-2 rounded-full bg-gradient-to-r from-teal-500 via-cyan-500 to-slate-900 transition-all"
style={{ width: `${Math.min(100, Math.max(4, Math.round((item.progress ?? 0) * 100)))}%` }}
/>
</div>
</div>
<div className="flex items-center justify-between text-xs text-slate-500">
<span>Import: {formatDate(item.importedAt)}</span>
<a
href={item.pageUrl}
target="_blank"
rel="noreferrer"
className="font-semibold text-slate-700 underline decoration-slate-300 underline-offset-4"
>
Kaynak sayfa
</a>
</div>
{item.errorMessage ? (
<div className="rounded-2xl border border-rose-200 bg-rose-50 px-3 py-3 text-sm text-rose-800">
{item.errorMessage}
</div>
) : null}
</div>
</article>
);
})}
</div>
)}
</CardContent>
</Card>
</div>
<div className="min-w-0 space-y-4">
<Card className="border-slate-200 bg-white/90">
<CardHeader className="items-start">
<div>
<CardTitle className="flex items-center gap-3">
<span className="inline-flex h-10 w-10 items-center justify-center rounded-2xl bg-teal-600 text-white">
<FontAwesomeIcon icon={selectedWatcher ? faWandMagicSparkles : faPlus} />
</span>
{selectedWatcher ? "Bookmark Watcher Duzenle" : "Bookmark Watcher Ekle"}
</CardTitle>
<p className="mt-2 text-sm text-slate-500">
Tracker secin, cookie ekleyin ve bookmark izleme araligini belirleyin.
</p>
</div>
{selectedWatcher ? (
<Button variant="ghost" onClick={resetForm} className="gap-2">
<FontAwesomeIcon icon={faPlus} />
Yeni
</Button>
) : null}
</CardHeader>
<CardContent>
<div className="space-y-3">
<label className="block space-y-2">
<span className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">
Tracker
</span>
<Select value={tracker} onValueChange={setTracker} disabled={Boolean(selectedWatcher)}>
<SelectTrigger className="w-full rounded-xl">
<SelectValue placeholder="Tracker sec" />
</SelectTrigger>
<SelectContent>
{watcherTrackers.map((entry) => (
<SelectItem key={entry.key} value={entry.key}>
{entry.label}
</SelectItem>
))}
</SelectContent>
</Select>
</label>
<label className="block space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">
Cookie
</span>
<button
type="button"
onClick={() => setShowCookie((current) => !current)}
className="inline-flex items-center gap-2 text-xs font-semibold text-slate-500"
>
<FontAwesomeIcon icon={showCookie ? faEyeSlash : faEye} />
{showCookie ? "Gizle" : "Goster"}
</button>
</div>
<div className="space-y-2">
<Textarea
rows={5}
masked={!showCookie}
value={cookie}
onChange={(event) => setCookie(event.target.value)}
placeholder={
selectedWatcher
? "Yeni cookie yapistirin veya mevcut kaydi korumak icin bos birakin."
: "Tracker cookie degerlerini buraya yapistirin."
}
className="resize-none"
/>
{selectedWatcher?.hasCookie ? (
<div className="text-xs text-slate-500">
Kayitli cookie ipucu: <span className="font-semibold">{selectedWatcher.cookieHint}</span>
</div>
) : null}
</div>
</label>
<div className="grid grid-cols-[1fr_140px] gap-3">
<label className="block space-y-2">
<span className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">
Kontrol Araligi
</span>
<Input
type="number"
min={1}
value={intervalValue}
onChange={(event) => setIntervalValue(event.target.value)}
className="rounded-xl"
/>
</label>
<label className="block space-y-2">
<span className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">
Birim
</span>
<Select value={intervalUnit} onValueChange={(value) => setIntervalUnit(value as "minutes" | "hours")}>
<SelectTrigger className="w-full rounded-xl">
<SelectValue />
</SelectTrigger>
<SelectContent>
{intervalUnits.map((unit) => (
<SelectItem key={unit.value} value={unit.value}>
{unit.label}
</SelectItem>
))}
</SelectContent>
</Select>
</label>
</div>
<label className="block space-y-2">
<span className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">
qBittorrent Kategori
</span>
<Select value={category} onValueChange={setCategory}>
<SelectTrigger className="w-full rounded-xl">
<SelectValue placeholder="Kategori sec" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">Kategori yok</SelectItem>
{qbitCategories.map((entry) => (
<SelectItem key={entry} value={entry}>
{entry}
</SelectItem>
))}
</SelectContent>
</Select>
</label>
<button
type="button"
onClick={() => setEnabled((current) => !current)}
className="flex w-full items-center justify-between rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-left"
>
<div>
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">
Watcher Durumu
</div>
<div className="mt-1 text-sm font-semibold text-slate-900">
{enabled ? "Aktif izleme acik" : "Pasif durumda"}
</div>
</div>
<FontAwesomeIcon
icon={enabled ? faToggleOn : faToggleOff}
className={`text-3xl ${enabled ? "text-teal-600" : "text-slate-300"}`}
/>
</button>
<div className="grid grid-cols-2 gap-3">
<Button onClick={submit} disabled={saving} className="gap-2 rounded-xl">
<FontAwesomeIcon icon={faSave} />
{saving ? "Kaydediliyor" : "Kaydet"}
</Button>
<Button
variant="outline"
onClick={runNow}
disabled={!selectedWatcher || running}
className="gap-2 rounded-xl"
>
<FontAwesomeIcon icon={faPlay} />
{running ? "Calisiyor" : "Simdi Calistir"}
</Button>
</div>
<div className="rounded-xl border border-dashed border-slate-200 bg-slate-50/70 px-4 py-3 text-xs text-slate-500">
Manuel calistirma, scheduler beklemeden watcher akisini aninda tetikler.
</div>
</div>
</CardContent>
</Card>
<Card className="border-slate-200 bg-white/90">
<CardHeader>
<CardTitle className="flex items-center gap-3">
<span className="inline-flex h-10 w-10 items-center justify-center rounded-2xl bg-slate-900 text-white">
<FontAwesomeIcon icon={faLayerGroup} />
</span>
Watcher Listesi
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{watchers.length === 0 ? (
<div className="rounded-2xl border border-dashed border-slate-300 px-4 py-6 text-sm text-slate-500">
Henuz kayitli watcher yok.
</div>
) : (
watchers.map((watcher) => (
<div
key={watcher.id}
onClick={() => setSelectedWatcherId(watcher.id)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setSelectedWatcherId(watcher.id);
}
}}
role="button"
tabIndex={0}
className={`w-full rounded-2xl border px-4 py-4 text-left transition ${
selectedWatcherId === watcher.id
? "border-slate-900 bg-slate-900 text-white shadow-lg shadow-slate-900/15"
: "border-slate-200 bg-white hover:border-slate-300"
}`}
>
<div className="flex items-center justify-between gap-3">
<div>
<div className="font-semibold">{watcher.trackerLabel}</div>
<div className={`mt-1 text-xs ${selectedWatcherId === watcher.id ? "text-slate-300" : "text-slate-500"}`}>
Her {watcher.intervalMinutes} dakikada bir
</div>
</div>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
toggleWatcher(watcher);
}}
className={`inline-flex h-10 w-10 items-center justify-center rounded-full ${
watcher.enabled
? "bg-emerald-500/15 text-emerald-500"
: selectedWatcherId === watcher.id
? "bg-white/10 text-slate-300"
: "bg-slate-100 text-slate-400"
}`}
>
<FontAwesomeIcon icon={watcher.enabled ? faToggleOn : faToggleOff} />
</button>
</div>
<div className={`mt-4 grid grid-cols-[1fr_auto] gap-3 text-xs ${selectedWatcherId === watcher.id ? "text-slate-300" : "text-slate-500"}`}>
<div className="grid grid-cols-2 gap-2">
<div>Son basari: {formatDate(watcher.lastSuccessAt)}</div>
<div>Sonraki tur: {formatDate(watcher.nextRunAt)}</div>
</div>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
removeWatcher(watcher);
}}
disabled={deleting}
className={`inline-flex h-8 w-8 items-center justify-center rounded-full border ${
selectedWatcherId === watcher.id
? "border-white/20 bg-white/10 text-white"
: "border-rose-200 bg-rose-50 text-rose-600"
}`}
title="Watcher sil"
>
<FontAwesomeIcon icon={faTrash} />
</button>
</div>
</div>
))
)}
</div>
</CardContent>
</Card>
<Card className="border-slate-200 bg-[linear-gradient(180deg,rgba(255,255,255,0.96),rgba(240,249,255,0.95))]">
<CardHeader>
<CardTitle className="flex items-center gap-3">
<span className="inline-flex h-10 w-10 items-center justify-center rounded-2xl bg-cyan-600 text-white">
<FontAwesomeIcon icon={faChartIcon(watcherSummary)} />
</span>
Watcher Ozeti
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-3">
<SummaryTile label="Aktif Watcher" value={String(watcherSummary?.activeWatchers ?? 0)} />
<SummaryTile label="Import Edilen" value={String(watcherSummary?.totalImported ?? 0)} />
<SummaryTile label="Gorulen" value={String(watcherSummary?.totalSeen ?? 0)} />
<SummaryTile label="Tracker" value={String(watcherSummary?.trackedLabels.length ?? 0)} />
</div>
<div className="space-y-2 rounded-2xl bg-white/80 px-4 py-4 text-sm text-slate-600">
<div>Son tur: {formatDate(watcherSummary?.lastRunAt)}</div>
<div>Son basari: {formatDate(watcherSummary?.lastSuccessAt)}</div>
<div>Sonraki kontrol: {formatDate(watcherSummary?.nextRunAt)}</div>
<div>
Son hata:{" "}
<span className="font-medium text-slate-700">
{watcherSummary?.lastError ?? "Yok"}
</span>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
);
};
const SummaryTile = ({ label, value }: { label: string; value: string }) => (
<div className="rounded-2xl border border-slate-200 bg-white/90 px-4 py-4">
<div className="text-[11px] uppercase tracking-[0.18em] text-slate-400">{label}</div>
<div className="mt-2 text-2xl font-semibold text-slate-900">{value}</div>
</div>
);
const faChartIcon = (summary: WatcherSummary | null) => {
if (summary?.lastError) {
return faTriangleExclamation;
}
if ((summary?.totalImported ?? 0) > 0) {
return faCheckCircle;
}
if ((summary?.activeWatchers ?? 0) > 0) {
return faClock;
}
return faDownload;
};

View File

@@ -56,6 +56,18 @@ export const connectSocket = () => {
useAppStore.getState().setTimerSummary(summary);
});
socket.on("watchers:list", (watchers) => {
useAppStore.getState().setWatchers(watchers);
});
socket.on("watcher:items", (items) => {
useAppStore.getState().setWatcherItems(items);
});
socket.on("watcher:summary", (summary) => {
useAppStore.getState().setWatcherSummary(summary);
});
return socket;
};

View File

@@ -10,6 +10,8 @@ export interface TorrentInfo {
state: string;
magnet_uri?: string;
tracker?: string;
num_seeds?: number;
num_leechs?: number;
tags?: string;
category?: string;
added_on?: number;
@@ -78,6 +80,79 @@ export interface TimerSummary {
updatedAt: string;
}
export interface WatcherTracker {
key: string;
label: string;
cliSiteKey: string;
supportsRemoveBookmark: boolean;
}
export interface Watcher {
id: string;
tracker: string;
trackerLabel: string;
category?: string;
cookieHint: string;
hasCookie: boolean;
intervalMinutes: number;
enabled: boolean;
lastRunAt?: string;
lastSuccessAt?: string;
lastError?: string;
nextRunAt?: string;
totalImported: number;
totalSeen: number;
createdAt: string;
updatedAt: string;
}
export interface WatcherItem {
id: string;
watcherId: string;
tracker: string;
trackerLabel: string;
sourceKey: string;
pageUrl: string;
title: string;
imageUrl?: string;
status: string;
statusLabel: string;
qbitHash?: string;
qbitName?: string;
progress: number;
state?: string;
qbitCategory?: string;
sizeBytes?: number;
seeders?: number;
leechers?: number;
seenAt: string;
importedAt?: string;
lastSyncAt: string;
errorMessage?: string;
}
export interface WatcherSummary {
activeWatchers: number;
totalImported: number;
totalSeen: number;
trackedLabels: string[];
lastRunAt?: string;
lastSuccessAt?: string;
nextRunAt?: string;
lastError?: string;
updatedAt: string;
watchers: Array<{
id: string;
tracker: string;
trackerLabel: string;
enabled: boolean;
lastRunAt?: string;
lastSuccessAt?: string;
nextRunAt?: string;
totalImported: number;
}>;
}
interface AppState {
qbit: StatusSnapshot["qbit"];
torrents: TorrentInfo[];
@@ -87,6 +162,11 @@ interface AppState {
timerRules: TimerRule[];
timerLogs: TimerLog[];
timerSummary: TimerSummary | null;
watcherTrackers: WatcherTracker[];
qbitCategories: string[];
watchers: Watcher[];
watcherItems: WatcherItem[];
watcherSummary: WatcherSummary | null;
selectedHash: string | null;
loopForm: { allowIp: string; delayMs: number; targetLoops: number };
setSnapshot: (snapshot: StatusSnapshot) => void;
@@ -97,6 +177,11 @@ interface AppState {
setTimerLogs: (logs: TimerLog[]) => void;
addTimerLog: (log: TimerLog) => void;
setTimerSummary: (summary: TimerSummary) => void;
setWatcherTrackers: (trackers: WatcherTracker[]) => void;
setQbitCategories: (categories: string[]) => void;
setWatchers: (watchers: Watcher[]) => void;
setWatcherItems: (items: WatcherItem[]) => void;
setWatcherSummary: (summary: WatcherSummary | null) => void;
selectHash: (hash: string) => void;
setLoopForm: (partial: Partial<AppState["loopForm"]>) => void;
}
@@ -110,6 +195,11 @@ export const useAppStore = create<AppState>((set) => ({
timerRules: [],
timerLogs: [],
timerSummary: null,
watcherTrackers: [],
qbitCategories: [],
watchers: [],
watcherItems: [],
watcherSummary: null,
selectedHash: null,
loopForm: { allowIp: "", delayMs: 3000, targetLoops: 3 },
setSnapshot: (snapshot) =>
@@ -140,6 +230,11 @@ export const useAppStore = create<AppState>((set) => ({
timerLogs: [log, ...state.timerLogs].slice(0, 500),
})),
setTimerSummary: (summary) => set({ timerSummary: summary }),
setWatcherTrackers: (watcherTrackers) => set({ watcherTrackers }),
setQbitCategories: (qbitCategories) => set({ qbitCategories }),
setWatchers: (watchers) => set({ watchers }),
setWatcherItems: (watcherItems) => set({ watcherItems }),
setWatcherSummary: (watcherSummary) => set({ watcherSummary }),
selectHash: (hash) => set({ selectedHash: hash }),
setLoopForm: (partial) =>
set((state) => ({