first commit

This commit is contained in:
2026-01-02 15:49:01 +03:00
commit 4348f76a7c
80 changed files with 10133 additions and 0 deletions

10
apps/web/Dockerfile Normal file
View File

@@ -0,0 +1,10 @@
FROM node:20-alpine
WORKDIR /app
RUN corepack enable
COPY package.json /app/apps/web/package.json
COPY package.json /app/package.json
COPY pnpm-workspace.yaml /app/pnpm-workspace.yaml
RUN pnpm install --frozen-lockfile=false
WORKDIR /app/apps/web
EXPOSE 5173
CMD ["pnpm", "dev", "--host", "0.0.0.0"]

19
apps/web/index.html Normal file
View File

@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
<title>q-buffer</title>
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

33
apps/web/package.json Normal file
View File

@@ -0,0 +1,33 @@
{
"name": "q-buffer-web",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.6.0",
"@fortawesome/free-solid-svg-icons": "^6.6.0",
"@fortawesome/react-fontawesome": "^0.2.2",
"@radix-ui/react-alert-dialog": "^1.1.2",
"axios": "^1.7.7",
"clsx": "^2.1.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2",
"socket.io-client": "^4.7.5",
"zustand": "^4.5.5"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.19",
"postcss": "^8.4.39",
"tailwindcss": "^3.4.6",
"typescript": "^5.5.3",
"vite": "^5.3.3"
}
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};

33
apps/web/src/App.tsx Normal file
View File

@@ -0,0 +1,33 @@
import React, { useEffect } from "react";
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 { AppLayout } from "./components/layout/AppLayout";
import { useAuthStore } from "./store/useAuthStore";
export const App = () => {
const username = useAuthStore((s) => s.username);
const check = useAuthStore((s) => s.check);
useEffect(() => {
check();
}, [check]);
if (!username) {
return <LoginPage />;
}
return (
<BrowserRouter>
<AppLayout>
<Routes>
<Route path="/" element={<Navigate to="/buffer" replace />} />
<Route path="/buffer" element={<DashboardPage />} />
<Route path="/timer" element={<TimerPage />} />
<Route path="*" element={<Navigate to="/buffer" replace />} />
</Routes>
</AppLayout>
</BrowserRouter>
);
};

View File

@@ -0,0 +1,8 @@
import axios from "axios";
const baseURL = import.meta.env.VITE_API_BASE || "";
export const api = axios.create({
baseURL: baseURL || undefined,
withCredentials: true,
});

View File

@@ -0,0 +1,137 @@
import React, { useEffect, useState } from "react";
import { NavLink } from "react-router-dom";
import { Shell } from "./Shell";
import { Badge } from "../ui/Badge";
import { Button } from "../ui/Button";
import { useAuthStore } from "../../store/useAuthStore";
import { useAppStore } from "../../store/useAppStore";
import { connectSocket } from "../../socket/socket";
import { api } from "../../api/client";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faMoon, faSun } from "@fortawesome/free-solid-svg-icons";
import { AlertToastStack } from "../ui/AlertToastStack";
export const AppLayout = ({ children }: { children: React.ReactNode }) => {
const logout = useAuthStore((s) => s.logout);
const qbit = useAppStore((s) => s.qbit);
const setSnapshot = useAppStore((s) => s.setSnapshot);
const [connected, setConnected] = useState(false);
const [theme, setTheme] = useState<"light" | "dark">("light");
const [menuOpen, setMenuOpen] = useState(false);
const applyTheme = (next: "light" | "dark") => {
document.documentElement.classList.toggle("dark", next === "dark");
localStorage.setItem("theme", next);
setTheme(next);
};
useEffect(() => {
const stored = localStorage.getItem("theme");
if (stored === "light" || stored === "dark") {
applyTheme(stored);
} else if (window.matchMedia?.("(prefers-color-scheme: dark)").matches) {
applyTheme("dark");
}
const socket = connectSocket();
socket.on("connect", () => setConnected(true));
socket.on("disconnect", () => setConnected(false));
return () => {
socket.disconnect();
};
}, []);
useEffect(() => {
let active = true;
const fetchStatus = async () => {
try {
const response = await api.get("/api/status");
if (active) {
setSnapshot(response.data);
}
} catch (error) {
if (active) {
// Ignore transient network errors; socket will update when available.
}
}
};
fetchStatus();
const interval = setInterval(fetchStatus, connected ? 15000 : 5000);
return () => {
active = false;
clearInterval(interval);
};
}, [connected, setSnapshot]);
return (
<Shell>
<header className="rounded-xl border border-slate-200 bg-white/80 px-4 py-3">
<div className="flex items-start justify-between gap-3 md:items-center">
<div>
<div className="text-lg font-semibold text-slate-900">q-buffer</div>
<div className="text-xs text-slate-500">
qBittorrent {qbit.version ?? "unknown"}
</div>
</div>
<button
className="inline-flex items-center justify-center rounded-md border border-slate-300 px-3 py-2 text-xs font-semibold text-slate-700 md:hidden"
onClick={() => setMenuOpen((open) => !open)}
type="button"
>
{menuOpen ? "Close" : "Menu"}
</button>
</div>
<div
className={`mt-3 flex flex-col gap-3 md:mt-0 md:flex md:flex-row md:items-center md:justify-between ${
menuOpen ? "flex" : "hidden md:flex"
}`}
>
<nav className="flex flex-wrap items-center gap-2 rounded-full bg-slate-100 px-2 py-1 text-xs font-semibold text-slate-600 md:justify-start">
<NavLink
to="/buffer"
className={({ isActive }) =>
`rounded-full px-3 py-1 ${
isActive ? "bg-slate-900 text-white" : "hover:bg-white"
}`
}
>
Buffer
</NavLink>
<NavLink
to="/timer"
className={({ isActive }) =>
`rounded-full px-3 py-1 ${
isActive ? "bg-slate-900 text-white" : "hover:bg-white"
}`
}
>
Timer
</NavLink>
</nav>
<div className="flex flex-wrap items-center gap-2">
<Badge variant={qbit.ok ? "success" : "danger"}>
{qbit.ok ? "Qbit OK" : "Qbit Down"}
</Badge>
<Badge variant={connected ? "success" : "warn"}>
{connected ? "Live" : "Offline"}
</Badge>
<Button
variant="outline"
onClick={() => applyTheme(theme === "dark" ? "light" : "dark")}
>
<FontAwesomeIcon
icon={theme === "dark" ? faSun : faMoon}
className="mr-2"
/>
{theme === "dark" ? "Light" : "Dark"}
</Button>
<Button variant="outline" onClick={logout}>
Logout
</Button>
</div>
</div>
</header>
<AlertToastStack />
{children}
</Shell>
);
};

View File

@@ -0,0 +1,9 @@
import React from "react";
export const Shell = ({ children }: { children: React.ReactNode }) => (
<div className="dashboard-bg min-h-screen">
<div className="mx-auto flex min-h-screen w-full max-w-6xl flex-col gap-6 px-6 py-6">
{children}
</div>
</div>
);

View File

@@ -0,0 +1,111 @@
import React, { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "../ui/Card";
import { Button } from "../ui/Button";
import { api } from "../../api/client";
import { useAppStore } from "../../store/useAppStore";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCircleInfo, faTriangleExclamation, faUpload } from "@fortawesome/free-solid-svg-icons";
import { useUiStore } from "../../store/useUiStore";
export const AdvancedUploadCard = () => {
const selectedHash = useAppStore((s) => s.selectedHash);
const [open, setOpen] = useState(false);
const [file, setFile] = useState<File | null>(null);
const pushAlert = useUiStore((s) => s.pushAlert);
const upload = async () => {
if (!selectedHash) {
pushAlert({
title: "Torrent seçilmedi",
description: "Önce bir torrent seçmelisiniz.",
variant: "warn",
});
return;
}
if (!file) {
pushAlert({
title: "Dosya seçilmedi",
description: "Lütfen bir .torrent dosyası seçin.",
variant: "warn",
});
return;
}
try {
const form = new FormData();
form.append("file", file);
form.append("hash", selectedHash);
const response = await api.post("/api/torrent/archive/upload", form, {
headers: { "Content-Type": "multipart/form-data" },
});
if (response.data?.added) {
pushAlert({
title: "Yükleme başarılı",
description: "Torrent qBittorrent'a eklendi.",
variant: "success",
});
} else {
pushAlert({
title: "Yükleme yapıldı",
description: "Torrent arşive alındı, qBittorrent'a eklenemedi.",
variant: "warn",
});
}
if (selectedHash) {
const statusResponse = await api.get(
`/api/torrent/archive/status/${selectedHash}`
);
window.dispatchEvent(
new CustomEvent("archive-status", {
detail: { hash: selectedHash, status: statusResponse.data?.status },
})
);
}
} catch (error: any) {
const apiError = error?.response?.data?.error;
pushAlert({
title: "Yükleme başarısız",
description: apiError || "Sunucu loglarını kontrol edin.",
variant: "error",
});
}
};
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FontAwesomeIcon icon={faCircleInfo} className="text-slate-400" />
Advanced
</CardTitle>
<Button variant="ghost" onClick={() => setOpen(!open)}>
{open ? "Hide" : "Show"}
</Button>
</CardHeader>
{open && (
<CardContent>
<div className="space-y-3 text-sm">
<div className="flex items-center gap-2 text-slate-600">
<FontAwesomeIcon icon={faCircleInfo} className="text-slate-400" />
Upload a .torrent file if magnet metadata fetch fails.
</div>
{!selectedHash && (
<div className="flex items-center gap-2 text-amber-700">
<FontAwesomeIcon icon={faTriangleExclamation} />
Önce bir torrent seçmelisiniz.
</div>
)}
<input
type="file"
accept=".torrent"
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
/>
<Button onClick={upload} disabled={!file || !selectedHash}>
<FontAwesomeIcon icon={faUpload} className="mr-2" />
Upload Torrent
</Button>
</div>
</CardContent>
)}
</Card>
);
};

View File

@@ -0,0 +1,65 @@
import React from "react";
import { useAppStore } from "../../store/useAppStore";
import { Card, CardContent, CardHeader, CardTitle } from "../ui/Card";
import { Badge } from "../ui/Badge";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faCircleExclamation,
faCircleInfo,
faList,
faTriangleExclamation,
} from "@fortawesome/free-solid-svg-icons";
export const LogsPanel = () => {
const logs = useAppStore((s) => s.logs);
const selectedHash = useAppStore((s) => s.selectedHash);
const jobs = useAppStore((s) => s.jobs);
const job = jobs.find((j) => j.torrentHash === selectedHash);
const filtered = job ? logs.filter((log) => log.jobId === job.id) : logs;
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FontAwesomeIcon icon={faList} className="text-slate-400" />
Logs
</CardTitle>
<Badge variant="default">Live</Badge>
</CardHeader>
<CardContent>
<div className="max-h-56 space-y-2 overflow-auto text-xs">
{[...filtered].reverse().map((log, idx) => (
<div
key={`${log.createdAt}-${idx}`}
className="rounded-md border border-slate-200 bg-white px-2 py-1"
>
<div className="flex items-center gap-2">
<span className="flex items-center gap-2 font-semibold">
<FontAwesomeIcon
icon={
log.level === "ERROR"
? faTriangleExclamation
: log.level === "WARN"
? faCircleExclamation
: faCircleInfo
}
className={
log.level === "ERROR"
? "text-rose-500"
: log.level === "WARN"
? "text-amber-500"
: "text-slate-400"
}
/>
{log.level}
</span>
<span className="text-slate-400">{log.createdAt}</span>
</div>
<div>{log.message}</div>
</div>
))}
</div>
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,111 @@
import React, { useState } from "react";
import { useAppStore } from "../../store/useAppStore";
import { Card, CardContent, CardHeader, CardTitle } from "../ui/Card";
import { Input } from "../ui/Input";
import { Button } from "../ui/Button";
import { api } from "../../api/client";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCircleInfo, faClock, faList, faLock } from "@fortawesome/free-solid-svg-icons";
export const LoopSetupCard = () => {
const selectedHash = useAppStore((s) => s.selectedHash);
const jobs = useAppStore((s) => s.jobs);
const loopForm = useAppStore((s) => s.loopForm);
const setLoopForm = useAppStore((s) => s.setLoopForm);
const [dryRun, setDryRun] = useState<string | null>(null);
const job = jobs.find((j) => j.torrentHash === selectedHash);
const startLoop = async () => {
if (!selectedHash) {
return;
}
await api.post("/api/loop/start", {
hash: selectedHash,
allowIp: loopForm.allowIp,
targetLoops: loopForm.targetLoops,
delayMs: loopForm.delayMs,
});
};
const stopLoop = async () => {
if (!selectedHash) {
return;
}
await api.post("/api/loop/stop-by-hash", { hash: selectedHash });
};
const runDry = async () => {
if (!selectedHash) {
return;
}
const response = await api.post("/api/loop/dry-run", {
hash: selectedHash,
allowIp: loopForm.allowIp || "127.0.0.1",
});
setDryRun(JSON.stringify(response.data, null, 2));
};
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FontAwesomeIcon icon={faCircleInfo} className="text-slate-400" />
Loop Setup
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-3 text-sm">
<label className="space-y-2">
<span className="flex items-center gap-2">
<FontAwesomeIcon icon={faLock} className="text-slate-400" />
Allow IP
</span>
<Input
value={loopForm.allowIp}
onChange={(e) => setLoopForm({ allowIp: e.target.value })}
/>
</label>
<label className="space-y-2">
<span className="flex items-center gap-2">
<FontAwesomeIcon icon={faList} className="text-slate-400" />
Loops
</span>
<Input
type="number"
value={loopForm.targetLoops}
onChange={(e) => setLoopForm({ targetLoops: Number(e.target.value) })}
/>
</label>
<label className="space-y-2">
<span className="flex items-center gap-2">
<FontAwesomeIcon icon={faClock} className="text-slate-400" />
Delay (ms)
</span>
<Input
type="number"
value={loopForm.delayMs}
onChange={(e) => setLoopForm({ delayMs: Number(e.target.value) })}
/>
</label>
<div className="flex gap-2">
<Button onClick={startLoop} disabled={!selectedHash || !loopForm.allowIp}>
Start
</Button>
<Button variant="outline" onClick={stopLoop} disabled={!selectedHash}>
Stop
</Button>
<Button variant="ghost" onClick={runDry} disabled={!selectedHash}>
Dry Run
</Button>
</div>
{dryRun && (
<pre className="max-h-40 overflow-auto rounded-md bg-slate-900 p-3 text-xs text-slate-100">
{dryRun}
</pre>
)}
</div>
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,92 @@
import React from "react";
import { useAppStore } from "../../store/useAppStore";
import { Card, CardContent, CardHeader, CardTitle } from "../ui/Card";
import { Badge } from "../ui/Badge";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faBan,
faCircleInfo,
faClock,
faDownload,
faList,
faTriangleExclamation,
} from "@fortawesome/free-solid-svg-icons";
export const LoopStatsCard = () => {
const selectedHash = useAppStore((s) => s.selectedHash);
const jobs = useAppStore((s) => s.jobs);
const job = jobs.find((j) => j.torrentHash === selectedHash);
const formatTotal = (bytes: number) => {
const gb = bytes / (1024 ** 3);
if (gb >= 1024) {
return `${(gb / 1024).toFixed(2)} TB`;
}
return `${gb.toFixed(2)} GB`;
};
if (!job) {
return (
<Card>
<CardHeader>
<CardTitle>Loop Stats</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-slate-500">No active job for selection.</p>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FontAwesomeIcon icon={faCircleInfo} className="text-slate-400" />
Loop Stats
</CardTitle>
<Badge variant={job.status === "RUNNING" ? "success" : "warn"}>
{job.status}
</Badge>
</CardHeader>
<CardContent>
<div className="grid gap-2 text-sm text-slate-700">
<div className="flex items-center gap-2">
<FontAwesomeIcon icon={faList} className="text-slate-400" />
<span>Loops: {job.doneLoops} / {job.targetLoops}</span>
</div>
<div className="flex items-center gap-2">
<FontAwesomeIcon icon={faDownload} className="text-slate-400" />
<span>
Total Download:{" "}
{formatTotal(
job.totals?.totalDownloadedBytes ??
job.doneLoops * job.sizeBytes
)}
</span>
</div>
<div className="flex items-center gap-2">
<FontAwesomeIcon icon={faClock} className="text-slate-400" />
<span>Delay: {job.delayMs} ms</span>
</div>
<div className="flex items-center gap-2">
<FontAwesomeIcon icon={faBan} className="text-slate-400" />
<span>Banned peers: {job.bans?.bannedIps?.length ?? 0}</span>
</div>
{job.nextRunAt && (
<div className="flex items-center gap-2">
<FontAwesomeIcon icon={faClock} className="text-slate-400" />
<span>Next run: {job.nextRunAt}</span>
</div>
)}
{job.lastError && (
<div className="flex items-center gap-2 text-rose-600">
<FontAwesomeIcon icon={faTriangleExclamation} />
<span>{job.lastError}</span>
</div>
)}
</div>
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,185 @@
import React, { useEffect, useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "../ui/Card";
import { Input } from "../ui/Input";
import { Button } from "../ui/Button";
import { api } from "../../api/client";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faList, faLock, faUser } from "@fortawesome/free-solid-svg-icons";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "../ui/AlertDialog";
import { useUiStore } from "../../store/useUiStore";
interface Profile {
id: string;
name: string;
allowIp: string;
delayMs: number;
targetLoops: number;
}
export const ProfilesCard = ({ onApply }: { onApply?: (profile: Profile) => void }) => {
const [profiles, setProfiles] = useState<Profile[]>([]);
const [name, setName] = useState("");
const [allowIp, setAllowIp] = useState("");
const [delayMs, setDelayMs] = useState(3000);
const [targetLoops, setTargetLoops] = useState(3);
const [editingId, setEditingId] = useState<string | null>(null);
const pushAlert = useUiStore((s) => s.pushAlert);
const loadProfiles = async () => {
const response = await api.get("/api/profiles");
setProfiles(response.data);
};
useEffect(() => {
loadProfiles();
}, []);
const saveProfile = async () => {
const payload = {
name,
allowIp,
delayMs,
targetLoops,
};
try {
if (editingId) {
const response = await api.put(`/api/profiles/${editingId}`, payload);
setProfiles((prev) =>
prev.map((profile) => (profile.id === editingId ? response.data : profile))
);
pushAlert({
title: "Profil güncellendi",
description: "Profil bilgileri kaydedildi.",
variant: "success",
});
setEditingId(null);
} else {
const response = await api.post("/api/profiles", payload);
setProfiles((prev) => [...prev, response.data]);
pushAlert({
title: "Profil kaydedildi",
description: "Yeni profil eklendi.",
variant: "success",
});
}
setName("");
setAllowIp("");
setDelayMs(3000);
setTargetLoops(3);
} catch (error) {
pushAlert({
title: "Profil kaydedilemedi",
description: "Lütfen bilgileri kontrol edip tekrar deneyin.",
variant: "error",
});
}
};
const startEdit = (profile: Profile) => {
setEditingId(profile.id);
setName(profile.name);
setAllowIp(profile.allowIp);
setDelayMs(profile.delayMs);
setTargetLoops(profile.targetLoops);
};
const removeProfile = async (profileId: string) => {
try {
await api.delete(`/api/profiles/${profileId}`);
setProfiles((prev) => prev.filter((profile) => profile.id !== profileId));
pushAlert({
title: "Profil silindi",
description: "Profil listeden kaldırıldı.",
variant: "success",
});
} catch (error) {
pushAlert({
title: "Silme başarısız",
description: "Profil silinemedi.",
variant: "error",
});
}
};
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FontAwesomeIcon icon={faUser} className="text-slate-400" />
Profiles
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-3 text-sm">
<div className="grid gap-2">
<Input placeholder="Preset name" value={name} onChange={(e) => setName(e.target.value)} />
<Input placeholder="Allow IP" value={allowIp} onChange={(e) => setAllowIp(e.target.value)} />
<div className="grid grid-cols-2 gap-2">
<Input type="number" value={delayMs} onChange={(e) => setDelayMs(Number(e.target.value))} />
<Input type="number" value={targetLoops} onChange={(e) => setTargetLoops(Number(e.target.value))} />
</div>
<Button onClick={saveProfile}>
{editingId ? "Update Profile" : "Save Profile"}
</Button>
</div>
<div className="space-y-2">
{profiles.map((profile) => (
<div key={profile.id} className="flex items-center justify-between rounded-md border border-slate-200 bg-white px-3 py-2">
<div>
<div className="font-semibold">{profile.name}</div>
<div className="flex items-center gap-2 text-xs text-slate-500">
<span className="flex items-center gap-1">
<FontAwesomeIcon icon={faLock} className="text-slate-400" />
{profile.allowIp}
</span>
<span className="flex items-center gap-1">
<FontAwesomeIcon icon={faList} className="text-slate-400" />
{profile.targetLoops} loops
</span>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={() => startEdit(profile)}>
Edit
</Button>
<Button variant="outline" onClick={() => onApply?.(profile)}>
Apply
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost">Delete</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Profil silinsin mi?</AlertDialogTitle>
<AlertDialogDescription>
Bu işlem geri alınamaz.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>İptal</AlertDialogCancel>
<AlertDialogAction onClick={() => removeProfile(profile.id)}>
Sil
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
))}
</div>
</div>
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,111 @@
import React, { useEffect, useState } from "react";
import { useAppStore } from "../../store/useAppStore";
import { Card, CardContent, CardHeader, CardTitle } from "../ui/Card";
import { Badge } from "../ui/Badge";
import { api } from "../../api/client";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faCircleInfo,
faDatabase,
faHashtag,
faLink,
} from "@fortawesome/free-solid-svg-icons";
export const TorrentDetailsCard = () => {
const selectedHash = useAppStore((s) => s.selectedHash);
const torrents = useAppStore((s) => s.torrents);
const [archiveStatus, setArchiveStatus] = useState<string>("MISSING");
const torrent = torrents.find((t) => t.hash === selectedHash);
const refreshArchiveStatus = async (hash: string) => {
try {
const response = await api.get(`/api/torrent/archive/status/${hash}`);
setArchiveStatus(response.data.status);
} catch (error) {
setArchiveStatus("MISSING");
}
};
useEffect(() => {
if (!selectedHash) {
return;
}
api.post("/api/torrent/select", { hash: selectedHash });
refreshArchiveStatus(selectedHash);
}, [selectedHash]);
useEffect(() => {
const handler = (event: Event) => {
const detail = (event as CustomEvent<{ hash: string; status: string }>).detail;
if (detail?.hash === selectedHash && detail.status) {
setArchiveStatus(detail.status);
}
};
window.addEventListener("archive-status", handler as EventListener);
return () => window.removeEventListener("archive-status", handler as EventListener);
}, [selectedHash]);
if (!torrent) {
return (
<Card>
<CardHeader>
<CardTitle>Selected Torrent</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-slate-500">Select a torrent to inspect.</p>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FontAwesomeIcon icon={faCircleInfo} className="text-slate-400" />
Selected Torrent
</CardTitle>
<Badge
variant={archiveStatus === "READY" ? "success" : "warn"}
>
Archive: {archiveStatus}
</Badge>
</CardHeader>
<CardContent>
<div className="text-sm text-slate-700">
<div className="truncate font-semibold text-slate-900" title={torrent.name}>
{torrent.name}
</div>
<div className="mt-2 flex items-center gap-2">
<FontAwesomeIcon icon={faHashtag} className="text-slate-400" />
<span>Hash: {torrent.hash}</span>
</div>
<div className="flex items-center gap-2">
<FontAwesomeIcon icon={faDatabase} className="text-slate-400" />
<span>
Size: {(torrent.size / (1024 * 1024 * 1024)).toFixed(2)} GB
</span>
</div>
<div className="flex items-center gap-2 truncate" title={torrent.tracker || "-"}>
<FontAwesomeIcon icon={faLink} className="text-slate-400" />
<span className="truncate">
Tracker:{" "}
{(() => {
if (!torrent.tracker) {
return "-";
}
try {
const url = new URL(torrent.tracker);
return url.hostname;
} catch {
return torrent.tracker;
}
})()}
</span>
</div>
</div>
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,219 @@
import React, { useMemo, useState } from "react";
import { useAppStore } from "../../store/useAppStore";
import { Card, CardContent, CardHeader, CardTitle } from "../ui/Card";
import { Input } from "../ui/Input";
import { api } from "../../api/client";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "../ui/AlertDialog";
import { useUiStore } from "../../store/useUiStore";
const formatSpeed = (bytesPerSec: number) => {
const kb = bytesPerSec / 1024;
if (kb >= 1024) {
return `${(kb / 1024).toFixed(1)} MB/s`;
}
return `${Math.round(kb)} kB/s`;
};
export const TorrentTable = () => {
const torrents = useAppStore((s) => s.torrents);
const selected = useAppStore((s) => s.selectedHash);
const selectHash = useAppStore((s) => s.selectHash);
const pushAlert = useUiStore((s) => s.pushAlert);
const [query, setQuery] = useState("");
const filtered = useMemo(() => {
return torrents.filter((torrent) =>
torrent.name.toLowerCase().includes(query.toLowerCase())
);
}, [torrents, query]);
const deleteTorrent = async (hash: string) => {
try {
await api.delete(`/api/qbit/torrent/${hash}`);
pushAlert({
title: "Torrent silindi",
description: "Torrent ve dosyalar diskten kaldırıldı.",
variant: "success",
});
} catch (error) {
pushAlert({
title: "Silme başarısız",
description: "Torrent silinemedi. Sunucu loglarını kontrol edin.",
variant: "error",
});
}
};
const uploadTorrent = async (hash: string, file: File) => {
const form = new FormData();
form.append("file", file);
form.append("hash", hash);
try {
const response = await api.post("/api/torrent/archive/upload", form, {
headers: { "Content-Type": "multipart/form-data" },
});
if (response.data?.added) {
pushAlert({
title: "Yükleme başarılı",
description: "Torrent qBittorrent'a eklendi.",
variant: "success",
});
} else {
pushAlert({
title: "Yükleme yapıldı",
description: "Torrent arşive alındı, qBittorrent'a eklenemedi.",
variant: "warn",
});
}
} catch (error: any) {
const apiError = error?.response?.data?.error;
pushAlert({
title: "Yükleme başarısız",
description: apiError || "Sunucu loglarını kontrol edin.",
variant: "error",
});
}
};
return (
<Card className="h-full">
<CardHeader>
<CardTitle>Torrents</CardTitle>
<Input
placeholder="Search"
value={query}
onChange={(event) => setQuery(event.target.value)}
className="w-40"
/>
</CardHeader>
<CardContent>
<div className="max-h-[360px] overflow-auto space-y-2">
{filtered.map((torrent) => (
<div
key={torrent.hash}
className={`flex items-start justify-between rounded-lg border px-3 py-2 text-left text-sm transition ${
selected === torrent.hash
? "border-ink bg-slate-900 text-white"
: "border-slate-200 bg-white hover:border-slate-300"
}`}
>
<div
className="flex-1 cursor-pointer"
onClick={() => selectHash(torrent.hash)}
>
<div className="truncate font-semibold" title={torrent.name}>
{torrent.name}
</div>
<div className="mt-2 flex items-center gap-3 text-xs">
<div className="h-2 w-28 rounded-full bg-slate-200">
<div
className="h-2 rounded-full bg-mint"
style={{ width: `${Math.round(torrent.progress * 100)}%` }}
/>
</div>
<span>{Math.round(torrent.progress * 100)}%</span>
<span>{formatSpeed(torrent.dlspeed)}</span>
<span className="uppercase text-slate-400">
{torrent.state}
</span>
</div>
</div>
<div className="ml-3 flex items-center gap-2">
<label
className={`cursor-pointer rounded-md p-2 transition ${
selected === torrent.hash
? "text-white/80 hover:text-white"
: "text-slate-500 hover:text-slate-900"
}`}
title="Torrent yükle"
>
<input
type="file"
accept=".torrent"
className="hidden"
onChange={async (event) => {
const file = event.target.files?.[0];
if (file) {
await uploadTorrent(torrent.hash, file);
}
event.target.value = "";
}}
/>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4"
>
<path d="M12 3v12" />
<path d="M8 7l4-4 4 4" />
<path d="M4 15v4h16v-4" />
</svg>
</label>
<AlertDialog>
<AlertDialogTrigger asChild>
<button
className={`rounded-md p-2 transition ${
selected === torrent.hash
? "text-white/80 hover:text-white"
: "text-slate-500 hover:text-slate-900"
}`}
title="Sil"
onClick={(event) => event.stopPropagation()}
type="button"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4"
>
<path d="M3 6h18" />
<path d="M8 6V4h8v2" />
<path d="M19 6l-1 14H6L5 6" />
<path d="M10 11v6" />
<path d="M14 11v6" />
</svg>
</button>
</AlertDialogTrigger>
<AlertDialogContent onClick={(event) => event.stopPropagation()}>
<AlertDialogHeader>
<AlertDialogTitle>Torrent silinsin mi?</AlertDialogTitle>
<AlertDialogDescription>
Bu işlem torrent ve indirilmiş dosyaları kalıcı olarak siler.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>İptal</AlertDialogCancel>
<AlertDialogAction onClick={() => deleteTorrent(torrent.hash)}>
Sil
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
))}
</div>
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,36 @@
import React from "react";
import clsx from "clsx";
export const Alert = ({
variant = "default",
className,
...props
}: React.HTMLAttributes<HTMLDivElement> & {
variant?: "default" | "success" | "warn" | "error";
}) => {
const variants: Record<string, string> = {
default: "border-slate-200 bg-white text-slate-700",
success: "border-emerald-200 bg-emerald-50 text-emerald-800",
warn: "border-amber-200 bg-amber-50 text-amber-800",
error: "border-rose-200 bg-rose-50 text-rose-800",
};
return (
<div
role="alert"
className={clsx(
"w-full rounded-xl border px-4 py-3 shadow-lg backdrop-blur",
variants[variant],
className
)}
{...props}
/>
);
};
export const AlertTitle = ({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) => (
<h5 className={clsx("text-sm font-semibold", className)} {...props} />
);
export const AlertDescription = ({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) => (
<p className={clsx("mt-1 text-xs", className)} {...props} />
);

View File

@@ -0,0 +1,69 @@
import React from "react";
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import clsx from "clsx";
export const AlertDialog = AlertDialogPrimitive.Root;
export const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
export const AlertDialogPortal = AlertDialogPrimitive.Portal;
export const AlertDialogOverlay = ({ className, ...props }: AlertDialogPrimitive.AlertDialogOverlayProps) => (
<AlertDialogPrimitive.Overlay
className={clsx("fixed inset-0 z-50 bg-black/40 backdrop-blur-sm", className)}
{...props}
/>
);
export const AlertDialogContent = ({
className,
...props
}: AlertDialogPrimitive.AlertDialogContentProps) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
className={clsx(
"fixed left-1/2 top-1/2 z-50 w-[90vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-2xl border border-slate-200 bg-white p-6 shadow-xl",
className
)}
{...props}
/>
</AlertDialogPortal>
);
export const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={clsx("space-y-2", className)} {...props} />
);
export const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={clsx("mt-4 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)} {...props} />
);
export const AlertDialogTitle = ({ className, ...props }: AlertDialogPrimitive.AlertDialogTitleProps) => (
<AlertDialogPrimitive.Title className={clsx("text-lg font-semibold text-slate-900", className)} {...props} />
);
export const AlertDialogDescription = ({
className,
...props
}: AlertDialogPrimitive.AlertDialogDescriptionProps) => (
<AlertDialogPrimitive.Description className={clsx("text-sm text-slate-600", className)} {...props} />
);
export const AlertDialogCancel = ({ className, ...props }: AlertDialogPrimitive.AlertDialogCancelProps) => (
<AlertDialogPrimitive.Cancel
className={clsx(
"inline-flex items-center justify-center rounded-md border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 transition hover:bg-slate-100",
className
)}
{...props}
/>
);
export const AlertDialogAction = ({ className, ...props }: AlertDialogPrimitive.AlertDialogActionProps) => (
<AlertDialogPrimitive.Action
className={clsx(
"inline-flex items-center justify-center rounded-md bg-rose-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-rose-700",
className
)}
{...props}
/>
);

View File

@@ -0,0 +1,24 @@
import React from "react";
import { Alert, AlertDescription, AlertTitle } from "./Alert";
import { useUiStore } from "../../store/useUiStore";
export const AlertToastStack = () => {
const alerts = useUiStore((s) => s.alerts);
if (alerts.length === 0) {
return null;
}
return (
<div className="toast-stack">
{alerts.map((alert) => (
<Alert key={alert.id} variant={alert.variant}>
<AlertTitle>{alert.title}</AlertTitle>
{alert.description ? (
<AlertDescription>{alert.description}</AlertDescription>
) : null}
</Alert>
))}
</div>
);
};

View File

@@ -0,0 +1,27 @@
import React from "react";
import clsx from "clsx";
export const Badge = ({
variant = "default",
className,
...props
}: React.HTMLAttributes<HTMLSpanElement> & {
variant?: "default" | "success" | "warn" | "danger";
}) => {
const variants: Record<string, string> = {
default: "bg-slate-100 text-slate-700",
success: "bg-emerald-100 text-emerald-700",
warn: "bg-amber-100 text-amber-700",
danger: "bg-rose-100 text-rose-700",
};
return (
<span
className={clsx(
"inline-flex items-center rounded-full px-2 py-1 text-xs font-semibold",
variants[variant],
className
)}
{...props}
/>
);
};

View File

@@ -0,0 +1,21 @@
import React from "react";
import clsx from "clsx";
export const Button = ({
variant = "default",
className,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: "default" | "outline" | "ghost";
}) => {
const base =
"inline-flex items-center justify-center rounded-md px-4 py-2 text-sm font-semibold transition";
const variants: Record<string, string> = {
default: "bg-ink text-white hover:bg-slate-800",
outline: "border border-slate-300 text-slate-700 hover:bg-slate-100",
ghost: "text-slate-600 hover:bg-slate-100",
};
return (
<button className={clsx(base, variants[variant], className)} {...props} />
);
};

View File

@@ -0,0 +1,24 @@
import React from "react";
import clsx from "clsx";
export const Card = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={clsx(
"rounded-xl border border-slate-200 bg-white/80 p-4 shadow-sm backdrop-blur",
className
)}
{...props}
/>
);
export const CardHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={clsx("mb-3 flex items-center justify-between", className)} {...props} />
);
export const CardTitle = ({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) => (
<h3 className={clsx("text-lg font-semibold text-slate-900", className)} {...props} />
);
export const CardContent = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={clsx("space-y-3", className)} {...props} />
);

View File

@@ -0,0 +1,12 @@
import React from "react";
import clsx from "clsx";
export const Input = ({ className, ...props }: React.InputHTMLAttributes<HTMLInputElement>) => (
<input
className={clsx(
"w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 focus:border-ink focus:outline-none focus:ring-2 focus:ring-slate-200",
className
)}
{...props}
/>
);

193
apps/web/src/index.css Normal file
View File

@@ -0,0 +1,193 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
color: #0f172a;
background: #f1f5f9;
font-family: "Space Grotesk", system-ui, sans-serif;
}
:root.dark {
color: #e2e8f0;
background: #0b1220;
color-scheme: dark;
}
:root,
html,
body {
text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
min-height: 100vh;
overflow-y: scroll;
scrollbar-gutter: stable;
-webkit-text-size-adjust: 100%;
touch-action: manipulation;
}
* {
box-sizing: border-box;
}
.dashboard-bg {
background: #f1f5f9;
}
:root.dark .dashboard-bg {
background: #0b1220;
}
:root.dark .border-slate-200 {
border-color: #1f2937;
}
:root.dark .border-slate-300 {
border-color: #334155;
}
:root.dark .bg-white\/80 {
background-color: rgba(15, 23, 42, 0.8);
}
:root.dark .bg-white {
background-color: #0f172a;
}
:root.dark .bg-slate-50 {
background-color: #0b1220;
}
:root.dark .bg-slate-100 {
background-color: #111827;
}
:root.dark .bg-slate-900 {
background-color: #1f2937;
}
:root.dark .bg-emerald-50 {
background-color: #0f2a23;
}
:root.dark .border-emerald-200 {
border-color: #14532d;
}
:root.dark .bg-amber-50 {
background-color: #2a1f0f;
}
:root.dark .border-amber-200 {
border-color: #7c5e10;
}
:root.dark .bg-rose-50 {
background-color: #2a1115;
}
:root.dark .border-rose-200 {
border-color: #7f1d1d;
}
:root.dark .text-emerald-800 {
color: #6ee7b7;
}
:root.dark .text-amber-800 {
color: #fcd34d;
}
:root.dark .text-rose-800 {
color: #fda4af;
}
:root.dark .text-slate-900 {
color: #f8fafc;
}
:root.dark .text-slate-700 {
color: #cbd5f5;
}
:root.dark .text-slate-600,
:root.dark .text-slate-500 {
color: #94a3b8;
}
:root.dark .text-slate-400 {
color: #64748b;
}
:root.dark .text-slate-200,
:root.dark .text-slate-100,
:root.dark .text-white {
color: #e2e8f0;
}
.toast-stack {
position: fixed;
right: 24px;
bottom: 24px;
z-index: 60;
display: flex;
flex-direction: column;
gap: 12px;
width: min(360px, 92vw);
}
@media (max-width: 768px) {
.toast-stack {
left: 50%;
right: auto;
bottom: 16px;
transform: translateX(-50%);
}
}
@media (max-width: 768px) {
:root {
font-size: 16px;
}
.max-w-6xl {
max-width: 100%;
}
.px-6 {
padding-left: 16px;
padding-right: 16px;
}
.py-6 {
padding-top: 16px;
padding-bottom: 16px;
}
body {
font-weight: 500;
}
.text-sm {
font-size: 0.95rem;
}
.text-xs {
font-size: 0.8rem;
}
.text-lg {
font-size: 1.1rem;
}
input,
select,
button,
textarea {
font-size: 16px;
}
}

10
apps/web/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { App } from "./App";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@@ -0,0 +1,40 @@
import React from "react";
import { TorrentTable } from "../components/torrents/TorrentTable";
import { TorrentDetailsCard } from "../components/torrents/TorrentDetailsCard";
import { LoopSetupCard } from "../components/loop/LoopSetupCard";
import { LoopStatsCard } from "../components/loop/LoopStatsCard";
import { LogsPanel } from "../components/loop/LogsPanel";
import { ProfilesCard } from "../components/loop/ProfilesCard";
import { useAppStore } from "../store/useAppStore";
export const DashboardPage = () => {
const setLoopForm = useAppStore((s) => s.setLoopForm);
return (
<>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1.2fr_1fr]">
<TorrentTable />
<div className="space-y-4">
<TorrentDetailsCard />
<LoopStatsCard />
<LoopSetupCard />
</div>
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1.2fr_1fr]">
<LogsPanel />
<div className="space-y-4">
<ProfilesCard
onApply={(profile) => {
setLoopForm({
allowIp: profile.allowIp,
delayMs: profile.delayMs,
targetLoops: profile.targetLoops,
});
}}
/>
</div>
</div>
</>
);
};

View File

@@ -0,0 +1,47 @@
import React, { useState } from "react";
import { useAuthStore } from "../store/useAuthStore";
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/Card";
import { Input } from "../components/ui/Input";
import { Button } from "../components/ui/Button";
export const LoginPage = () => {
const login = useAuthStore((s) => s.login);
const loading = useAuthStore((s) => s.loading);
const error = useAuthStore((s) => s.error);
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const onSubmit = async (event: React.FormEvent) => {
event.preventDefault();
await login(username, password);
};
return (
<div className="dashboard-bg flex min-h-screen items-center justify-center px-4">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>q-buffer Login</CardTitle>
</CardHeader>
<CardContent>
<form className="space-y-4" onSubmit={onSubmit}>
<Input
placeholder="Username"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
<Input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
{error && <div className="text-sm text-rose-600">{error}</div>}
<Button className="w-full" type="submit" disabled={loading}>
{loading ? "Signing in..." : "Sign In"}
</Button>
</form>
</CardContent>
</Card>
</div>
);
};

View File

@@ -0,0 +1,530 @@
import React, { useEffect, useMemo, useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/Card";
import { Button } from "../components/ui/Button";
import { Input } from "../components/ui/Input";
import { api } from "../api/client";
import { useAppStore } from "../store/useAppStore";
import { useUiStore } from "../store/useUiStore";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "../components/ui/AlertDialog";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faClockRotateLeft,
faClock,
faTags,
faTrash,
faChartBar,
faPlus,
faHourglassHalf,
} from "@fortawesome/free-solid-svg-icons";
const unitOptions = [
{ label: "Saat", value: "hours", seconds: 3600 },
{ label: "Gün", value: "days", seconds: 86400 },
{ label: "Hafta", value: "weeks", seconds: 604800 },
] as const;
const formatBytes = (value: number) => {
if (!Number.isFinite(value)) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
let size = value;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex += 1;
}
return `${size.toFixed(size >= 10 ? 0 : 1)} ${units[unitIndex]}`;
};
const formatDuration = (seconds: number) => {
if (!Number.isFinite(seconds)) return "0 sn";
if (seconds >= 86400) {
return `${(seconds / 86400).toFixed(1)} gün`;
}
if (seconds >= 3600) {
return `${(seconds / 3600).toFixed(1)} saat`;
}
return `${Math.max(1, Math.round(seconds / 60))} dk`;
};
const formatCountdown = (seconds: number) => {
if (!Number.isFinite(seconds)) return "—";
const clamped = Math.max(0, Math.floor(seconds));
const days = Math.floor(clamped / 86400);
const hours = Math.floor((clamped % 86400) / 3600);
const minutes = Math.floor((clamped % 3600) / 60);
const secs = clamped % 60;
const pad = (value: number) => value.toString().padStart(2, "0");
return `${days}g ${pad(hours)}:${pad(minutes)}:${pad(secs)}`;
};
const trackerLabel = (tracker?: string) => {
if (!tracker) return "Bilinmiyor";
try {
const host = new URL(tracker).hostname;
return host.replace(/^www\./, "");
} catch {
return tracker;
}
};
export const TimerPage = () => {
const torrents = useAppStore((s) => s.torrents);
const timerRules = useAppStore((s) => s.timerRules);
const timerLogs = useAppStore((s) => s.timerLogs);
const timerSummary = useAppStore((s) => s.timerSummary);
const setTimerRules = useAppStore((s) => s.setTimerRules);
const setTimerLogs = useAppStore((s) => s.setTimerLogs);
const setTimerSummary = useAppStore((s) => s.setTimerSummary);
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [seedValue, setSeedValue] = useState(2);
const [seedUnit, setSeedUnit] = useState<(typeof unitOptions)[number]["value"]>(
"weeks"
);
const [busy, setBusy] = useState(false);
const pushAlert = useUiStore((s) => s.pushAlert);
const [nowTick, setNowTick] = useState(() => Date.now());
const tagOptions = useMemo(() => {
const tags = new Set<string>();
torrents.forEach((torrent) => {
const tagList = torrent.tags
? torrent.tags.split(",").map((tag) => tag.trim())
: [];
tagList.filter(Boolean).forEach((tag) => tags.add(tag));
if (torrent.category) {
tags.add(torrent.category);
}
});
return Array.from(tags.values()).sort((a, b) => a.localeCompare(b));
}, [torrents]);
const rulesForDisplay = useMemo(() => {
return [...timerRules].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
}, [timerRules]);
const matchingTorrents = useMemo(() => {
if (timerRules.length === 0) {
return [];
}
return torrents
.map((torrent) => {
const tags = (torrent.tags ?? "")
.split(",")
.map((tag) => tag.trim().toLowerCase())
.filter(Boolean);
if (torrent.category) {
tags.push(torrent.category.toLowerCase());
}
const addedOnMs = Number(torrent.added_on ?? 0) * 1000;
const matchingRules = timerRules.filter((rule) => {
return rule.tags.some((tag) => tags.includes(tag.toLowerCase()));
});
if (matchingRules.length === 0) {
return null;
}
const rule = matchingRules.reduce((best, current) =>
current.seedLimitSeconds < best.seedLimitSeconds ? current : best
);
const ruleCreatedAtMs = Date.parse(rule.createdAt);
let baseMs = addedOnMs || nowTick;
if (Number.isFinite(ruleCreatedAtMs) && ruleCreatedAtMs > baseMs) {
baseMs = ruleCreatedAtMs;
}
const elapsedSeconds = Math.max(0, (nowTick - baseMs) / 1000);
const remainingSeconds = rule.seedLimitSeconds - elapsedSeconds;
return {
torrent,
rule,
remainingSeconds,
};
})
.filter(Boolean) as Array<{
torrent: typeof torrents[number];
rule: typeof timerRules[number];
remainingSeconds: number;
}>;
}, [timerRules, torrents, nowTick]);
useEffect(() => {
let active = true;
const load = async () => {
try {
const [rulesRes, logsRes, summaryRes] = await Promise.all([
api.get("/api/timer/rules"),
api.get("/api/timer/logs"),
api.get("/api/timer/summary"),
]);
if (!active) return;
setTimerRules(rulesRes.data ?? []);
setTimerLogs((logsRes.data ?? []).reverse());
if (summaryRes.data) {
setTimerSummary(summaryRes.data);
}
} catch (err) {
if (active) {
pushAlert({
title: "Timer verileri alınamadı",
description: "Bağlantıyı kontrol edip tekrar deneyin.",
variant: "error",
});
}
}
};
load();
return () => {
active = false;
};
}, [setTimerLogs, setTimerRules, setTimerSummary]);
useEffect(() => {
const interval = setInterval(() => setNowTick(Date.now()), 1000);
return () => clearInterval(interval);
}, []);
const toggleTag = (tag: string) => {
setSelectedTags((current) =>
current.includes(tag)
? current.filter((value) => value !== tag)
: [...current, tag]
);
};
const handleSaveRule = async () => {
const unit = unitOptions.find((item) => item.value === seedUnit);
if (!unit) return;
if (selectedTags.length === 0) {
pushAlert({
title: "Etiket seçilmedi",
description: "En az bir etiket seçmelisiniz.",
variant: "warn",
});
return;
}
if (!Number.isFinite(seedValue) || seedValue <= 0) {
pushAlert({
title: "Seed süresi geçersiz",
description: "Seed süresi 0dan büyük olmalı.",
variant: "warn",
});
return;
}
const seedLimitSeconds = Math.round(seedValue * unit.seconds);
setBusy(true);
try {
const response = await api.post("/api/timer/rules", {
tags: selectedTags,
seedLimitSeconds,
});
setTimerRules([response.data, ...timerRules]);
setSelectedTags([]);
pushAlert({
title: "Kural kaydedildi",
description: "Timer kuralı aktif edildi.",
variant: "success",
});
} catch (err) {
pushAlert({
title: "Kural kaydedilemedi",
description: "Lütfen daha sonra tekrar deneyin.",
variant: "error",
});
} finally {
setBusy(false);
}
};
const handleDeleteRule = async (ruleId: string) => {
try {
await api.delete(`/api/timer/rules/${ruleId}`);
setTimerRules(timerRules.filter((rule) => rule.id !== ruleId));
pushAlert({
title: "Kural silindi",
description: "Timer kuralı kaldırıldı.",
variant: "success",
});
} catch (err) {
pushAlert({
title: "Kural silinemedi",
description: "Lütfen daha sonra tekrar deneyin.",
variant: "error",
});
}
};
const summary = timerSummary ?? {
totalDeleted: 0,
totalSeededSeconds: 0,
totalUploadedBytes: 0,
updatedAt: "",
};
return (
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1.25fr_0.9fr]">
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FontAwesomeIcon icon={faHourglassHalf} className="text-slate-400" />
Zamanlayıcı Torrentleri
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{matchingTorrents.length === 0 ? (
<div className="text-sm text-slate-500">
Bu kurallara bağlı aktif torrent bulunamadı.
</div>
) : (
<div className="space-y-3">
{matchingTorrents.map(({ torrent, rule, remainingSeconds }) => (
<div
key={torrent.hash}
className="rounded-lg border border-slate-200 bg-white px-3 py-2"
>
<div className="flex items-start justify-between gap-3">
<div>
<div
className="text-sm font-semibold text-slate-900"
title={torrent.name}
>
{torrent.name}
</div>
<div className="text-xs text-slate-500">
{formatBytes(torrent.size)} {trackerLabel(torrent.tracker)}
</div>
</div>
<div className="text-right text-xs text-slate-600">
<div className="font-semibold text-slate-900">
{formatCountdown(remainingSeconds)}
</div>
<div>Kural: {formatDuration(rule.seedLimitSeconds)}</div>
</div>
</div>
<div className="mt-2 flex flex-wrap gap-2 text-xs text-slate-600">
<span>Hash: {torrent.hash.slice(0, 12)}...</span>
<span>
Etiket:{" "}
{(torrent.tags || torrent.category || "-")
.split(",")
.map((tag) => tag.trim())
.filter(Boolean)
.join(", ") || "-"}
</span>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FontAwesomeIcon icon={faTrash} className="text-slate-400" />
Silinen Torrent Logları
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{timerLogs.length === 0 ? (
<div className="text-sm text-slate-500">Henüz log yok.</div>
) : (
<div className="space-y-3">
{timerLogs.map((log) => (
<div
key={log.id}
className="rounded-lg border border-slate-200 bg-white px-3 py-2"
>
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-sm font-semibold text-slate-900">
{log.name}
</div>
<div className="text-xs text-slate-500">
{formatBytes(log.sizeBytes)} {" "}
{trackerLabel(log.tracker)}
</div>
</div>
<div className="text-xs text-slate-400">
{new Date(log.deletedAt).toLocaleString()}
</div>
</div>
<div className="mt-2 flex flex-wrap gap-2 text-xs text-slate-600">
<span>Seed: {formatDuration(log.seedingTimeSeconds)}</span>
<span>Upload: {formatBytes(log.uploadedBytes)}</span>
{log.tags?.length ? (
<span>Tags: {log.tags.join(", ")}</span>
) : null}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FontAwesomeIcon icon={faPlus} className="text-slate-400" />
Timer Kuralı Oluştur
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<div className="flex items-center gap-2 text-xs font-semibold text-slate-500">
<FontAwesomeIcon icon={faTags} className="text-slate-400" />
Etiketler
</div>
<div className="mt-2 flex flex-wrap gap-2">
{tagOptions.length === 0 ? (
<div className="text-sm text-slate-500">
Henüz etiket bulunamadı.
</div>
) : (
tagOptions.map((tag) => (
<button
key={tag}
type="button"
onClick={() => toggleTag(tag)}
className={`rounded-full border px-3 py-1 text-xs font-semibold ${
selectedTags.includes(tag)
? "border-slate-900 bg-slate-900 text-white"
: "border-slate-200 bg-white text-slate-600 hover:border-slate-300"
}`}
>
{tag}
</button>
))
)}
</div>
</div>
<div>
<div className="flex items-center gap-2 text-xs font-semibold text-slate-500">
<FontAwesomeIcon icon={faClock} className="text-slate-400" />
Seed Süresi
</div>
<div className="mt-2 flex gap-2">
<Input
type="number"
min={1}
value={seedValue}
onChange={(event) => setSeedValue(Number(event.target.value))}
/>
<select
value={seedUnit}
onChange={(event) =>
setSeedUnit(event.target.value as typeof seedUnit)
}
className="h-10 rounded-md border border-slate-200 bg-white px-3 text-sm text-slate-700"
>
{unitOptions.map((unit) => (
<option key={unit.value} value={unit.value}>
{unit.label}
</option>
))}
</select>
</div>
</div>
<Button onClick={handleSaveRule} disabled={busy}>
{busy ? "Kaydediliyor..." : "Kuralı Kaydet"}
</Button>
<div className="border-t border-slate-200 pt-3">
<div className="mb-2 flex items-center gap-2 text-xs font-semibold text-slate-500">
<FontAwesomeIcon icon={faClockRotateLeft} className="text-slate-400" />
Eklenen Kurallar
</div>
{rulesForDisplay.length === 0 ? (
<div className="text-sm text-slate-500">
Henüz kural yok.
</div>
) : (
<div className="space-y-2">
{rulesForDisplay.map((rule) => (
<div
key={rule.id}
className="flex items-center justify-between rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm"
>
<div>
<div className="font-semibold text-slate-900">
{rule.tags.join(", ")}
</div>
<div className="text-xs text-slate-500">
Seed limiti: {formatDuration(rule.seedLimitSeconds)}
</div>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
className="text-rose-600 hover:text-rose-700"
>
<FontAwesomeIcon icon={faTrash} className="mr-2" />
Sil
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Kural silinsin mi?</AlertDialogTitle>
<AlertDialogDescription>
Bu kural kaldırılınca zamanlayıcı artık bu etiketleri izlemeyecek.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>İptal</AlertDialogCancel>
<AlertDialogAction onClick={() => handleDeleteRule(rule.id)}>
Sil
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
))}
</div>
)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FontAwesomeIcon icon={faChartBar} className="text-slate-400" />
Timer Özeti
</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm text-slate-700">
<div className="flex items-center justify-between">
<span>Silinen dosya sayısı</span>
<span className="font-semibold text-slate-900">
{summary.totalDeleted}
</span>
</div>
<div className="flex items-center justify-between">
<span>Toplam seed süresi</span>
<span className="font-semibold text-slate-900">
{formatDuration(summary.totalSeededSeconds)}
</span>
</div>
<div className="flex items-center justify-between">
<span>Toplam upload</span>
<span className="font-semibold text-slate-900">
{formatBytes(summary.totalUploadedBytes)}
</span>
</div>
</CardContent>
</Card>
</div>
</div>
);
};

View File

@@ -0,0 +1,67 @@
import { io } from "socket.io-client";
import { useAppStore } from "../store/useAppStore";
import { api } from "../api/client";
let socket: ReturnType<typeof io> | null = null;
export const connectSocket = () => {
if (socket) {
return socket;
}
const baseUrl = import.meta.env.VITE_API_BASE || undefined;
socket = io(baseUrl, {
withCredentials: true,
autoConnect: false,
});
api
.get("/api/auth/socket-token")
.then((response) => {
socket!.auth = { token: response.data.token };
socket!.connect();
})
.catch(() => {
socket!.connect();
});
socket.on("status:snapshot", (snapshot) => {
useAppStore.getState().setSnapshot(snapshot);
});
socket.on("status:update", (snapshot) => {
useAppStore.getState().updateStatus(snapshot);
});
socket.on("job:metrics", (job) => {
const state = useAppStore.getState();
const jobs = state.jobs.map((existing) =>
existing.id === job.id ? job : existing
);
state.updateStatus({ jobs });
});
socket.on("job:log", (log) => {
useAppStore.getState().addLog(log);
});
socket.on("qbit:health", (qbit) => {
useAppStore.getState().updateStatus({ qbit });
});
socket.on("timer:log", (log) => {
useAppStore.getState().addTimerLog(log);
});
socket.on("timer:summary", (summary) => {
useAppStore.getState().setTimerSummary(summary);
});
return socket;
};
export const disconnectSocket = () => {
if (socket) {
socket.disconnect();
socket = null;
}
};

View File

@@ -0,0 +1,140 @@
import { create } from "zustand";
export interface TorrentInfo {
hash: string;
name: string;
size: number;
progress: number;
dlspeed: number;
state: string;
magnet_uri?: string;
tracker?: string;
tags?: string;
category?: string;
added_on?: number;
seeding_time?: number;
}
export interface LoopJob {
id: string;
torrentHash: string;
name: string;
sizeBytes: number;
allowIp: string;
targetLoops: number;
doneLoops: number;
delayMs: number;
status: string;
bans: { bannedIps: string[] };
nextRunAt?: string;
lastError?: string;
totals?: { totalDownloadedBytes: number };
}
export interface StatusSnapshot {
qbit: { ok: boolean; version?: string; lastError?: string };
torrents: TorrentInfo[];
transfer?: any;
jobs: LoopJob[];
}
export interface JobLog {
jobId: string;
level: "INFO" | "WARN" | "ERROR";
message: string;
createdAt: string;
}
export interface TimerRule {
id: string;
tags: string[];
seedLimitSeconds: number;
createdAt: string;
}
export interface TimerLog {
id: string;
hash: string;
name: string;
sizeBytes: number;
tracker?: string;
tags: string[];
category?: string;
seedingTimeSeconds: number;
uploadedBytes: number;
deletedAt: string;
}
export interface TimerSummary {
totalDeleted: number;
totalSeededSeconds: number;
totalUploadedBytes: number;
updatedAt: string;
}
interface AppState {
qbit: StatusSnapshot["qbit"];
torrents: TorrentInfo[];
transfer: any;
jobs: LoopJob[];
logs: JobLog[];
timerRules: TimerRule[];
timerLogs: TimerLog[];
timerSummary: TimerSummary | null;
selectedHash: string | null;
loopForm: { allowIp: string; delayMs: number; targetLoops: number };
setSnapshot: (snapshot: StatusSnapshot) => void;
updateStatus: (snapshot: Partial<StatusSnapshot>) => void;
addLog: (log: JobLog) => void;
setTimerRules: (rules: TimerRule[]) => void;
setTimerLogs: (logs: TimerLog[]) => void;
addTimerLog: (log: TimerLog) => void;
setTimerSummary: (summary: TimerSummary) => void;
selectHash: (hash: string) => void;
setLoopForm: (partial: Partial<AppState["loopForm"]>) => void;
}
export const useAppStore = create<AppState>((set) => ({
qbit: { ok: false },
torrents: [],
transfer: null,
jobs: [],
logs: [],
timerRules: [],
timerLogs: [],
timerSummary: null,
selectedHash: null,
loopForm: { allowIp: "", delayMs: 3000, targetLoops: 3 },
setSnapshot: (snapshot) =>
set((state) => ({
qbit: snapshot.qbit,
torrents: snapshot.torrents,
transfer: snapshot.transfer,
jobs: snapshot.jobs,
selectedHash:
state.selectedHash ?? snapshot.torrents?.[0]?.hash ?? null,
})),
updateStatus: (snapshot) =>
set((state) => ({
qbit: snapshot.qbit ?? state.qbit,
torrents: snapshot.torrents ?? state.torrents,
transfer: snapshot.transfer ?? state.transfer,
jobs: snapshot.jobs ?? state.jobs,
})),
addLog: (log) =>
set((state) => ({
logs: [...state.logs, log].slice(-500),
})),
setTimerRules: (rules) => set({ timerRules: rules }),
setTimerLogs: (logs) => set({ timerLogs: logs }),
addTimerLog: (log) =>
set((state) => ({
timerLogs: [log, ...state.timerLogs].slice(0, 500),
})),
setTimerSummary: (summary) => set({ timerSummary: summary }),
selectHash: (hash) => set({ selectedHash: hash }),
setLoopForm: (partial) =>
set((state) => ({
loopForm: { ...state.loopForm, ...partial },
})),
}));

View File

@@ -0,0 +1,40 @@
import { create } from "zustand";
import { api } from "../api/client";
interface AuthState {
username: string | null;
loading: boolean;
error: string | null;
login: (username: string, password: string) => Promise<boolean>;
logout: () => Promise<void>;
check: () => Promise<void>;
}
export const useAuthStore = create<AuthState>((set) => ({
username: null,
loading: false,
error: null,
login: async (username, password) => {
set({ loading: true, error: null });
try {
const response = await api.post("/api/auth/login", { username, password });
set({ username: response.data.username, loading: false });
return true;
} catch (error) {
set({ error: "Login failed", loading: false });
return false;
}
},
logout: async () => {
await api.post("/api/auth/logout");
set({ username: null });
},
check: async () => {
try {
const response = await api.get("/api/auth/me");
set({ username: response.data.username ?? "session" });
} catch (error) {
set({ username: null });
}
},
}));

View File

@@ -0,0 +1,31 @@
import { create } from "zustand";
export type AlertVariant = "default" | "success" | "warn" | "error";
export interface UiAlert {
id: string;
title: string;
description?: string;
variant: AlertVariant;
}
interface UiState {
alerts: UiAlert[];
pushAlert: (alert: Omit<UiAlert, "id">) => void;
removeAlert: (id: string) => void;
}
const generateId = () => Math.random().toString(36).slice(2);
export const useUiStore = create<UiState>((set, get) => ({
alerts: [],
pushAlert: (alert) => {
const id = generateId();
set((state) => ({ alerts: [{ ...alert, id }, ...state.alerts].slice(0, 5) }));
setTimeout(() => {
get().removeAlert(id);
}, 3000);
},
removeAlert: (id) =>
set((state) => ({ alerts: state.alerts.filter((item) => item.id !== id) })),
}));

View File

@@ -0,0 +1,14 @@
module.exports = {
content: ["./src/**/*.{ts,tsx}", "./index.html"],
theme: {
extend: {
colors: {
ink: "#0f172a",
fog: "#e2e8f0",
mint: "#14b8a6",
steel: "#94a3b8"
}
}
},
plugins: []
};

13
apps/web/tsconfig.json Normal file
View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"noEmit": true,
"skipLibCheck": true
},
"include": ["src"]
}

15
apps/web/vite.config.ts Normal file
View File

@@ -0,0 +1,15 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "node:path";
export default defineConfig({
plugins: [react()],
build: {
outDir: path.resolve(__dirname, "../server/public"),
emptyOutDir: true,
},
server: {
host: "0.0.0.0",
port: Number(process.env.WEB_PORT) || 5173,
},
});