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

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>
);
};