first commit
This commit is contained in:
40
apps/web/src/pages/DashboardPage.tsx
Normal file
40
apps/web/src/pages/DashboardPage.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
47
apps/web/src/pages/LoginPage.tsx
Normal file
47
apps/web/src/pages/LoginPage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
530
apps/web/src/pages/TimerPage.tsx
Normal file
530
apps/web/src/pages/TimerPage.tsx
Normal 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 0’dan 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user