feat: watcher akislarini ve wscraper servis entegrasyonunu ekle
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
22
apps/web/src/components/ui/Textarea.tsx
Normal file
22
apps/web/src/components/ui/Textarea.tsx
Normal 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";
|
||||
782
apps/web/src/pages/WatcherPage.tsx
Normal file
782
apps/web/src/pages/WatcherPage.tsx
Normal 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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
Reference in New Issue
Block a user