feat(timer): sıralama özelliği ekle

This commit is contained in:
2026-01-09 16:22:01 +03:00
parent dcd66fdd11
commit f6d54ca623
4 changed files with 455 additions and 11 deletions

View File

@@ -12,6 +12,7 @@
"@fortawesome/free-solid-svg-icons": "^6.6.0",
"@fortawesome/react-fontawesome": "^0.2.2",
"@radix-ui/react-alert-dialog": "^1.1.2",
"@radix-ui/react-select": "^2.1.2",
"axios": "^1.7.7",
"clsx": "^2.1.1",
"react": "^18.3.1",

View File

@@ -0,0 +1,104 @@
import React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import clsx from "clsx";
const ChevronDown = ({ className }: { className?: string }) => (
<svg
viewBox="0 0 20 20"
fill="currentColor"
className={className}
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M5.23 7.21a.75.75 0 0 1 1.06.02L10 11.17l3.71-3.94a.75.75 0 1 1 1.08 1.04l-4.24 4.5a.75.75 0 0 1-1.08 0l-4.24-4.5a.75.75 0 0 1 .02-1.06Z"
clipRule="evenodd"
/>
</svg>
);
const Check = ({ className }: { className?: string }) => (
<svg
viewBox="0 0 20 20"
fill="currentColor"
className={className}
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M16.7 5.29a1 1 0 0 1 .01 1.42l-7.5 7.5a1 1 0 0 1-1.42 0l-3.5-3.5a1 1 0 1 1 1.42-1.42l2.79 2.8 6.79-6.8a1 1 0 0 1 1.41 0Z"
clipRule="evenodd"
/>
</svg>
);
export const Select = SelectPrimitive.Root;
export const SelectGroup = SelectPrimitive.Group;
export const SelectValue = SelectPrimitive.Value;
export const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={clsx(
"flex h-10 items-center justify-between gap-2 rounded-md border border-slate-200 bg-white px-3 text-sm text-slate-700 shadow-sm transition focus:outline-none focus:ring-2 focus:ring-slate-300",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 text-slate-400" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = "SelectTrigger";
export const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={clsx(
"z-50 min-w-[8rem] overflow-hidden rounded-md border border-slate-200 bg-white text-slate-700 shadow-md",
className
)}
position={position}
{...props}
>
<SelectPrimitive.Viewport className="p-1">
{children}
</SelectPrimitive.Viewport>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = "SelectContent";
export const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={clsx(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-slate-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-3.5 w-3.5 text-slate-700" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = "SelectItem";

View File

@@ -16,6 +16,13 @@ import {
AlertDialogTitle,
AlertDialogTrigger,
} from "../components/ui/AlertDialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../components/ui/Select";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faClockRotateLeft,
@@ -33,6 +40,14 @@ const unitOptions = [
{ label: "Hafta", value: "weeks", seconds: 604800 },
] as const;
const sortOptions = [
{ label: "İsim", value: "name" },
{ label: "Boyut", value: "size" },
{ label: "Geri Sayım", value: "countdown" },
{ label: "Tracker", value: "tracker" },
{ label: "Eklenme Tarihi", value: "addedOn" },
] as const;
const formatBytes = (value: number) => {
if (!Number.isFinite(value)) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
@@ -96,6 +111,9 @@ export const TimerPage = () => {
const [seedUnit, setSeedUnit] = useState<(typeof unitOptions)[number]["value"]>(
"weeks"
);
const [sortKey, setSortKey] =
useState<(typeof sortOptions)[number]["value"]>("countdown");
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
const [deleteFiles, setDeleteFiles] = useState(true);
const [busy, setBusy] = useState(false);
const pushAlert = useUiStore((s) => s.pushAlert);
@@ -142,11 +160,7 @@ export const TimerPage = () => {
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 baseMs = addedOnMs || nowTick;
const elapsedSeconds = Math.max(0, (nowTick - baseMs) / 1000);
const remainingSeconds = rule.seedLimitSeconds - elapsedSeconds;
return {
@@ -162,6 +176,47 @@ export const TimerPage = () => {
}>;
}, [timerRules, torrents, nowTick]);
const sortedMatchingTorrents = useMemo(() => {
const direction = sortDirection === "asc" ? 1 : -1;
const withFallback = (value: number | string | undefined, fallback: number | string) =>
value === undefined || value === null || value === "" ? fallback : value;
return [...matchingTorrents].sort((a, b) => {
switch (sortKey) {
case "name":
return (
String(withFallback(a.torrent.name, ""))
.localeCompare(String(withFallback(b.torrent.name, "")), "tr") *
direction
);
case "size":
return (
(Number(withFallback(a.torrent.size, 0)) -
Number(withFallback(b.torrent.size, 0))) *
direction
);
case "tracker":
return (
trackerLabel(a.torrent.tracker)
.localeCompare(trackerLabel(b.torrent.tracker), "tr") * direction
);
case "addedOn":
return (
(Number(withFallback(a.torrent.added_on, 0)) -
Number(withFallback(b.torrent.added_on, 0))) *
direction
);
case "countdown":
default:
return (
(Number(withFallback(a.remainingSeconds, 0)) -
Number(withFallback(b.remainingSeconds, 0))) *
direction
);
}
});
}, [matchingTorrents, sortDirection, sortKey]);
useEffect(() => {
let active = true;
const load = async () => {
@@ -282,19 +337,54 @@ export const TimerPage = () => {
<div className="min-w-0 space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FontAwesomeIcon icon={faHourglassHalf} className="text-slate-400" />
Zamanlayıcı Torrentleri
</CardTitle>
<div className="flex flex-wrap items-center justify-between gap-3">
<CardTitle className="flex items-center gap-2">
<FontAwesomeIcon icon={faHourglassHalf} className="text-slate-400" />
Zamanlayıcı Torrentleri
</CardTitle>
<div className="flex items-center gap-2 text-xs font-semibold text-slate-500">
<span>Sıralama</span>
<Select
value={sortKey}
onValueChange={(value) => {
if (value !== sortKey) {
setSortKey(value as typeof sortKey);
setSortDirection("asc");
}
}}
>
<SelectTrigger className="h-9 min-w-[180px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{sortOptions.map((option) => (
<SelectItem
key={option.value}
value={option.value}
onClick={() => {
if (option.value === sortKey) {
setSortDirection((current) =>
current === "asc" ? "desc" : "asc"
);
}
}}
>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{matchingTorrents.length === 0 ? (
{sortedMatchingTorrents.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 }) => (
{sortedMatchingTorrents.map(({ torrent, rule, remainingSeconds }) => (
<div
key={torrent.hash}
className="rounded-lg border border-slate-200 bg-white px-3 py-2"