UI update

This commit is contained in:
2025-11-26 20:17:53 +03:00
parent e07c5933ee
commit b6dfe5546e
7 changed files with 544 additions and 19 deletions

View File

@@ -10,8 +10,10 @@
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^7.1.0",
"@fortawesome/free-brands-svg-icons": "^7.1.0",
"@fortawesome/free-solid-svg-icons": "^7.1.0",
"@fortawesome/react-fontawesome": "^3.1.0",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.0.2",
"axios": "^1.5.1",
"class-variance-authority": "^0.7.0",

41
frontend/src/api/jobs.ts Normal file
View File

@@ -0,0 +1,41 @@
import { apiClient } from "./client";
export type TimeUnit = "dakika" | "saat" | "gün";
export interface Job {
_id: string;
name: string;
repoUrl: string;
testCommand: string;
checkValue: number;
checkUnit: TimeUnit;
createdAt: string;
updatedAt: string;
}
export interface JobInput {
name: string;
repoUrl: string;
testCommand: string;
checkValue: number;
checkUnit: TimeUnit;
}
export async function fetchJobs(): Promise<Job[]> {
const { data } = await apiClient.get("/jobs");
return data as Job[];
}
export async function createJob(payload: JobInput): Promise<Job> {
const { data } = await apiClient.post("/jobs", payload);
return data as Job;
}
export async function updateJob(id: string, payload: JobInput): Promise<Job> {
const { data } = await apiClient.put(`/jobs/${id}`, payload);
return data as Job;
}
export async function deleteJob(id: string): Promise<void> {
await apiClient.delete(`/jobs/${id}`);
}

View File

@@ -0,0 +1,115 @@
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import { cn } from "../../lib/utils";
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
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={cn(
"relative z-50 min-w-[8rem] overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectPrimitive.ScrollUpButton className="flex cursor-default items-center justify-center py-1">
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectPrimitive.ScrollDownButton className="flex cursor-default items-center justify-center py-1">
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label ref={ref} className={cn("px-2 py-1.5 text-sm font-semibold", className)} {...props} />
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 inline-flex h-4 w-4 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator ref={ref} className={cn("m-1 h-px bg-border", className)} {...props} />
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator
};

View File

@@ -1,30 +1,315 @@
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faGithub, faGitlab } from "@fortawesome/free-brands-svg-icons";
import { faCodeBranch, faPen, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card";
import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
import { Label } from "../components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "../components/ui/select";
import { useLiveCounter } from "../providers/live-provider";
import { createJob, deleteJob, fetchJobs, Job, JobInput, updateJob } from "../api/jobs";
type FormState = {
_id?: string;
name: string;
repoUrl: string;
testCommand: string;
checkValue: string;
checkUnit: JobInput["checkUnit"];
};
const defaultForm: FormState = {
name: "",
repoUrl: "",
testCommand: "",
checkValue: "",
checkUnit: "dakika"
};
function RepoIcon({ repoUrl }: { repoUrl: string }) {
const lower = repoUrl.toLowerCase();
if (lower.includes("github.com")) {
return <FontAwesomeIcon icon={faGithub} className="h-5 w-5 text-foreground" />;
}
if (lower.includes("gitlab.com")) {
return <FontAwesomeIcon icon={faGitlab} className="h-5 w-5 text-foreground" />;
}
if (lower.includes("gitea")) {
return (
<div className="flex h-5 w-5 items-center justify-center rounded-sm bg-emerald-600 text-[10px] font-semibold text-white">
Ge
</div>
);
}
return <FontAwesomeIcon icon={faCodeBranch} className="h-5 w-5 text-foreground" />;
}
export function JobsPage() {
const { value, running } = useLiveCounter();
const [jobs, setJobs] = useState<Job[]>([]);
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [form, setForm] = useState<FormState>(defaultForm);
const [saving, setSaving] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
const isEdit = useMemo(() => !!form._id, [form._id]);
const loadJobs = async () => {
setLoading(true);
try {
const data = await fetchJobs();
setJobs(data);
} catch (err) {
toast.error("Jobs alınamadı");
} finally {
setLoading(false);
}
};
useEffect(() => {
loadJobs();
}, []);
const handleOpenNew = () => {
setForm(defaultForm);
setModalOpen(true);
};
const handleSave = async () => {
setSaving(true);
try {
const payload: JobInput = {
name: form.name,
repoUrl: form.repoUrl,
testCommand: form.testCommand,
checkValue: Number(form.checkValue),
checkUnit: form.checkUnit
};
if (!payload.name || !payload.repoUrl || !payload.testCommand || !payload.checkValue) {
toast.error("Tüm alanları doldurun");
setSaving(false);
return;
}
if (Number.isNaN(payload.checkValue) || payload.checkValue <= 0) {
toast.error("Check Time değeri 1 veya daha büyük olmalı");
setSaving(false);
return;
}
if (isEdit && form._id) {
const updated = await updateJob(form._id, payload);
setJobs((prev) => prev.map((j) => (j._id === updated._id ? updated : j)));
toast.success("Job güncellendi");
} else {
const created = await createJob(payload);
setJobs((prev) => [created, ...prev]);
toast.success("Job oluşturuldu");
}
setModalOpen(false);
} catch (err) {
toast.error("İşlem sırasında hata oluştu");
} finally {
setSaving(false);
}
};
const handleDelete = async (id: string) => {
setDeletingId(id);
try {
await deleteJob(id);
setJobs((prev) => prev.filter((j) => j._id !== id));
toast.success("Job silindi");
} catch (err) {
toast.error("Silme sırasında hata oluştu");
} finally {
setDeletingId(null);
}
};
const handleEdit = (job: Job) => {
const { _id, name, repoUrl, testCommand, checkValue, checkUnit } = job;
setForm({ _id, name, repoUrl, testCommand, checkValue: String(checkValue), checkUnit });
setModalOpen(true);
};
const handleClose = () => {
setModalOpen(false);
};
return (
<Card className="border-border card-shadow">
<CardHeader>
<CardTitle>Jobs Durumu</CardTitle>
<CardDescription>Diğer sayfalarda başlatılan canlı sayaç burada izlenebilir.</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm text-muted-foreground">
<div className="flex items-center justify-between">
<span>Canlı Durum</span>
<span className="font-semibold text-foreground">
{running ? "Çalışıyor" : "Beklemede"}
</span>
<>
<div className="flex items-center justify-between gap-4">
<div>
<h2 className="text-xl font-semibold text-foreground">Jobs</h2>
<p className="text-sm text-muted-foreground">
Home sayfasında başlatılan sayaç:{" "}
<span className="font-mono text-foreground">{value}</span> ({running ? "çalışıyor" : "beklemede"})
</p>
</div>
<div className="flex items-center justify-between">
<span>Sayaç Değeri</span>
<span className="font-mono text-lg text-foreground">{value}</span>
<Button onClick={handleOpenNew} className="gap-2">
<FontAwesomeIcon icon={faPlus} className="h-4 w-4" />
New Job
</Button>
</div>
<div className="grid gap-4">
{loading && (
<div className="rounded-md border border-border bg-muted/30 px-4 py-3 text-sm text-muted-foreground">
Jobs yükleniyor...
</div>
)}
{!loading && jobs.length === 0 && (
<div className="rounded-md border border-dashed border-border px-4 py-6 text-center text-sm text-muted-foreground">
Henüz job yok. Sağ üstten ekleyebilirsiniz.
</div>
)}
{!loading &&
jobs.map((job) => (
<Card key={job._id} className="border-border card-shadow">
<CardContent className="flex items-start gap-4 px-4 py-4">
<div className="pt-2.5">
<RepoIcon repoUrl={job.repoUrl} />
</div>
<div className="flex-1 space-y-2">
<div className="flex items-center justify-between">
<div className="text-lg font-semibold text-foreground">{job.name}</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
className="h-10 w-10"
onClick={() => handleEdit(job)}
>
<FontAwesomeIcon icon={faPen} className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
className="h-10 w-10"
disabled={deletingId === job._id}
onClick={() => handleDelete(job._id)}
>
<FontAwesomeIcon icon={faTrash} className="h-4 w-4 text-destructive" />
</Button>
</div>
</div>
<div className="grid gap-1 text-sm text-muted-foreground">
<div className="flex items-center gap-2">
<span className="font-medium text-foreground">Repo:</span>
<span className="truncate text-foreground/80">{job.repoUrl}</span>
</div>
<div className="flex items-center gap-2">
<span className="font-medium text-foreground">Test:</span>
<span className="text-foreground/80">{job.testCommand}</span>
</div>
<div className="flex items-center gap-2">
<span className="font-medium text-foreground">Kontrol:</span>
<span className="text-foreground/80">
{job.checkValue} {job.checkUnit}
</span>
</div>
</div>
</div>
</CardContent>
</Card>
))}
</div>
{modalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4 py-8">
<div className="w-full max-w-lg rounded-lg border border-border bg-card card-shadow">
<div className="flex items-center justify-between border-b border-border px-5 py-4">
<div className="space-y-1">
<div className="text-lg font-semibold text-foreground">{isEdit ? "Job Güncelle" : "Yeni Job"}</div>
<div className="text-sm text-muted-foreground">
Detayları girin, Jobs listesi canlı olarak güncellenecek.
</div>
</div>
<Button variant="ghost" size="icon" onClick={handleClose}>
</Button>
</div>
<div className="space-y-4 px-5 py-4">
<div className="space-y-2">
<Label htmlFor="name">Job Name</Label>
<Input
id="name"
value={form.name}
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
placeholder="Nightly E2E"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="repo">Repo URL</Label>
<Input
id="repo"
value={form.repoUrl}
onChange={(e) => setForm((prev) => ({ ...prev, repoUrl: e.target.value }))}
placeholder="https://github.com/org/repo"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="test">Test Command</Label>
<Input
id="test"
value={form.testCommand}
onChange={(e) => setForm((prev) => ({ ...prev, testCommand: e.target.value }))}
placeholder="npm test"
required
/>
</div>
<div className="space-y-2">
<Label>Check Time</Label>
<div className="flex gap-3">
<Input
type="number"
min={1}
className="w-32"
value={form.checkValue}
placeholder="15"
onChange={(e) => setForm((prev) => ({ ...prev, checkValue: e.target.value }))}
/>
<Select
value={form.checkUnit}
onValueChange={(value) =>
setForm((prev) => ({ ...prev, checkUnit: value as JobInput["checkUnit"] }))
}
>
<SelectTrigger className="flex-1">
<SelectValue placeholder="Birim seçin" />
</SelectTrigger>
<SelectContent>
<SelectItem value="dakika">dakika</SelectItem>
<SelectItem value="saat">saat</SelectItem>
<SelectItem value="gün">gün</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
<div className="flex items-center justify-end gap-3 border-t border-border px-5 py-4">
<Button variant="ghost" onClick={handleClose} disabled={saving}>
İptal
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? "Kaydediliyor..." : "Kaydet"}
</Button>
</div>
</div>
</div>
<div className="rounded-md border border-border bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
Sayaç Home sayfasından başlatılır. Bu panel aynı anda ık olan tüm kullanıcılarda anlık güncellenir.
</div>
</CardContent>
</Card>
)}
</>
);
}