feat: panel UI'ını Tailwind v4 + shadcn/ui ile yeniden tasarla (Faz 4)

Elle yazılmış tek-dosya CSS (sabit koyu tema, ham table/button, window.confirm
ile silme onayı) yerine tutarlı bir bileşen sistemi: Card/Table/Badge/Button/
Input/Switch/AlertDialog primitifleri, Hanken Grotesk + JetBrains Mono
tipografi çifti, "broadcast/monitör" temalı özel bir renk paleti (camgöbeği
accent, ok/warn/err/live durum rengi sözlüğü). Tüm sayfalar (dashboard, kanal
detay, segmentler, ayarlar, istatistik, kullanıcılar, login) aynı oturumda
taşındı — silme onayları artık gerçek AlertDialog, native confirm() değil.

Veri akışı/server action'lar değişmedi, tamamen görsel katman.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-02 14:33:24 +03:00
co-authored by Claude Sonnet 5
parent d045b24c85
commit 7d504d1938
30 changed files with 3436 additions and 801 deletions
+114 -80
View File
@@ -11,14 +11,18 @@ import {
retryRender, retryRender,
cancelRender, cancelRender,
} from "../../actions"; } from "../../actions";
import { DeleteButton } from "../../components/DeleteButton";
import { ConfirmButton } from "../../components/ConfirmButton"; import { ConfirmButton } from "../../components/ConfirmButton";
import { LiveLogViewer } from "../../components/LiveLogViewer"; import { LiveLogViewer } from "../../components/LiveLogViewer";
import { SessionTimer } from "../../components/SessionTimer"; import { SessionTimer } from "../../components/SessionTimer";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge, type badgeVariants } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import type { VariantProps } from "class-variance-authority";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
const STATUS_BADGE: Record<string, string> = { const STATUS_BADGE: Record<string, VariantProps<typeof badgeVariants>["variant"]> = {
PENDING: "muted", PENDING: "muted",
PROCESSED: "ok", PROCESSED: "ok",
DISCARDED: "err", DISCARDED: "err",
@@ -62,22 +66,33 @@ export default async function ChannelDetailPage({ params }: { params: Promise<{
const activeSession = channel.sessions.find((s) => s.endedAt === null); const activeSession = channel.sessions.find((s) => s.endedAt === null);
return ( return (
<> <div className="flex flex-col gap-6">
<p><Link href="/">&larr; Kanal Durumu</Link></p> <div>
<Link href="/" className="text-sm text-muted-foreground hover:text-foreground">
Kanal Durumu
</Link>
</div>
<div className="card-head"> <div className="flex items-center justify-between">
<h1>{channel.name}</h1> <div>
<h1 className="text-2xl font-semibold tracking-tight">{channel.name}</h1>
<p className="font-mono-num text-xs text-muted-foreground">
{channel.youtubeHandle} · {channel.channelId}
</p>
</div>
<form action={deleteChannel.bind(null, channel.id)}> <form action={deleteChannel.bind(null, channel.id)}>
<DeleteButton confirmText="Bu kanalı ve tüm kayıt geçmişini silmek istediğine emin misin? Geri alınamaz." label="Kanalı Sil" /> <ConfirmButton
confirmText="Bu kanalı ve tüm kayıt geçmişini silmek istediğine emin misin? Geri alınamaz."
label="Kanalı Sil"
/>
</form> </form>
</div> </div>
<p className="mono">{channel.youtubeHandle} · {channel.channelId}</p>
<div className="card"> <Card>
<div className="card-head"> <CardHeader className="flex-row items-center justify-between">
<span>Canlı Durum</span> <CardTitle className="text-sm font-medium text-muted-foreground">Canlı Durum</CardTitle>
<span style={{ display: "flex", gap: "0.4rem", alignItems: "center" }}> <div className="flex items-center gap-2">
<span className={`badge ${status?.recording ? "warn" : "muted"}`}> <Badge variant={status?.recording ? "live" : "muted"}>
{status?.recording ? "🔴 Kayıtta" : "Kayıtta değil"} {status?.recording ? "🔴 Kayıtta" : "Kayıtta değil"}
{status?.recording && activeSession && ( {status?.recording && activeSession && (
<> <>
@@ -85,157 +100,176 @@ export default async function ChannelDetailPage({ params }: { params: Promise<{
<SessionTimer startedAt={activeSession.startedAt.toISOString()} /> <SessionTimer startedAt={activeSession.startedAt.toISOString()} />
</> </>
)} )}
</span> </Badge>
{status?.recording && activeSession && ( {status?.recording && activeSession && (
<form action={stopSession.bind(null, activeSession.id)}> <form action={stopSession.bind(null, activeSession.id)}>
<ConfirmButton confirmText="Bu kaydı şimdi durdurmak istediğine emin misin?" label="Durdur" /> <ConfirmButton confirmText="Bu kaydı şimdi durdurmak istediğine emin misin?" label="Durdur" />
</form> </form>
)} )}
</span>
</div>
<div className="mono">
Aktif: {channel.isActive ? "evet" : "hayır"} ·{" "}
Son kontrol: {channel.lastCheckedAt ? new Date(channel.lastCheckedAt).toLocaleString("tr-TR") : "—"}
</div> </div>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<p className="font-mono-num text-xs text-muted-foreground">
Aktif: {channel.isActive ? "evet" : "hayır"} · Son kontrol:{" "}
{channel.lastCheckedAt ? new Date(channel.lastCheckedAt).toLocaleString("tr-TR") : "—"}
</p>
{status === undefined && ( {status === undefined && (
<p className="empty" style={{ marginTop: "0.5rem" }}> <p className="text-sm text-muted-foreground">
api-daemon&apos;dan canlı durum alınamadı (servis erişilemez olabilir). api-daemon&apos;dan canlı durum alınamadı (servis erişilemez olabilir).
</p> </p>
)} )}
{status?.lastPollError && ( {status?.lastPollError && (
<div style={{ marginTop: "0.5rem" }}> <div className="flex flex-col gap-1.5">
<span className="badge err">son poll hatası</span> <Badge variant="err" className="w-fit">
<pre className="mono" style={{ whiteSpace: "pre-wrap", marginTop: "0.4rem" }}> son poll hatası
</Badge>
<pre className="font-mono-num whitespace-pre-wrap rounded-md border border-border bg-background/60 p-3 text-[0.7rem] text-muted-foreground">
{status.lastPollError} {status.lastPollError}
</pre> </pre>
</div> </div>
)} )}
{status?.recording && activeSession?.liveVideoId && activeSession.liveVideoId !== "forced" && ( {status?.recording && activeSession?.liveVideoId && activeSession.liveVideoId !== "forced" && (
<iframe <iframe
src={`https://www.youtube.com/embed/${activeSession.liveVideoId}`} src={`https://www.youtube.com/embed/${activeSession.liveVideoId}`}
title="Canlı yayın önizleme" title="Canlı yayın önizleme"
style={{ width: "100%", maxWidth: "480px", aspectRatio: "16/9", marginTop: "0.6rem", border: "none", borderRadius: "6px" }} className="aspect-video w-full max-w-md rounded-md border border-border"
allow="autoplay; encrypted-media" allow="autoplay; encrypted-media"
/> />
)} )}
{status?.recording && activeSession && <LiveLogViewer sessionId={activeSession.id} />} {status?.recording && activeSession && <LiveLogViewer sessionId={activeSession.id} />}
<details style={{ marginTop: "0.75rem" }}> <details className="mt-1 text-sm">
<summary className="mono" style={{ cursor: "pointer", fontSize: "0.8rem" }}> <summary className="cursor-pointer font-mono-num text-xs text-muted-foreground">
Manuel URL ile kayıt başlat Manuel URL ile kayıt başlat
</summary> </summary>
<form action={forceStartCapture.bind(null, channel.id)} style={{ display: "flex", gap: "0.4rem", marginTop: "0.5rem" }}> <form action={forceStartCapture.bind(null, channel.id)} className="mt-2 flex gap-2">
<input name="url" placeholder="https://www.youtube.com/watch?v=..." required style={{ flex: 1 }} /> <Input name="url" placeholder="https://www.youtube.com/watch?v=..." required className="flex-1" />
<button type="submit" className="badge ok" style={{ border: "none", cursor: "pointer" }}> <Button type="submit">Başlat</Button>
Başlat
</button>
</form> </form>
</details> </details>
</div> </CardContent>
</Card>
<h1 style={{ marginTop: "2rem" }}>Yayın Geçmişi</h1> <div>
<h2 className="mb-3 text-lg font-semibold tracking-tight">Yayın Geçmişi</h2>
{channel.sessions.length === 0 ? ( {channel.sessions.length === 0 ? (
<p className="empty">Bu kanal için henüz bir kayıt oturumu yok.</p> <p className="text-sm text-muted-foreground">Bu kanal için henüz bir kayıt oturumu yok.</p>
) : ( ) : (
channel.sessions.map((session) => ( <div className="flex flex-col gap-4">
<div className="card" key={session.id}> {channel.sessions.map((session) => (
<div className="card-head"> <Card key={session.id}>
<span className="mono"> <CardHeader className="flex-row items-center justify-between">
<span className="font-mono-num text-xs text-muted-foreground">
{new Date(session.startedAt).toLocaleString("tr-TR")} {new Date(session.startedAt).toLocaleString("tr-TR")}
{session.endedAt ? ` ${new Date(session.endedAt).toLocaleString("tr-TR")}` : " (devam ediyor)"} {session.endedAt ? ` ${new Date(session.endedAt).toLocaleString("tr-TR")}` : " (devam ediyor)"}
</span> </span>
<span className="badge muted">{session.totalSegments} segment</span> <Badge variant="muted">{session.totalSegments} segment</Badge>
</div> </CardHeader>
{session.segments.length === 0 ? ( {session.segments.length === 0 ? (
<p className="empty" style={{ marginTop: "0.5rem" }}>Henüz segment yok.</p> <CardContent>
<p className="text-sm text-muted-foreground">Henüz segment yok.</p>
</CardContent>
) : ( ) : (
session.segments.map((segment) => ( <CardContent className="flex flex-col gap-4">
<div key={segment.id} style={{ marginTop: "0.75rem", paddingLeft: "0.75rem", borderLeft: "2px solid var(--border)" }}> {session.segments.map((segment) => (
<div className="card-head"> <div key={segment.id} className="border-l-2 border-border pl-4">
<span className="mono">{segment.filePath}</span> <div className="flex items-center justify-between gap-2">
<span className={`badge ${STATUS_BADGE[segment.status] ?? "muted"}`}>{segment.status}</span> <span className="font-mono-num text-xs text-muted-foreground">{segment.filePath}</span>
<Badge variant={STATUS_BADGE[segment.status] ?? "muted"}>{segment.status}</Badge>
</div> </div>
<video controls preload="metadata" style={{ width: "100%", maxWidth: "480px", marginTop: "0.5rem", borderRadius: "6px" }}> <video controls preload="metadata" className="mt-2 w-full max-w-md rounded-md border border-border">
<source src={`/api/segments/${segment.id}/video`} type="video/mp4" /> <source src={`/api/segments/${segment.id}/video`} type="video/mp4" />
</video> </video>
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center", marginTop: "0.5rem" }}> <div className="mt-2 flex items-center gap-2">
<a className="badge muted" href={`/api/segments/${segment.id}/video?download=1`}>İndir</a> <Button asChild variant="outline" size="sm">
<a href={`/api/segments/${segment.id}/video?download=1`}>İndir</a>
</Button>
<form action={deleteRawSegment.bind(null, segment.id)}> <form action={deleteRawSegment.bind(null, segment.id)}>
<DeleteButton confirmText="Bu segmenti ve içindeki tüm aday klipleri silmek istediğine emin misin? Geri alınamaz." /> <ConfirmButton
confirmText="Bu segmenti ve içindeki tüm aday klipleri silmek istediğine emin misin? Geri alınamaz."
label="Sil"
/>
</form> </form>
</div> </div>
{segment.candidates.map((c) => ( {segment.candidates.map((c) => (
<div key={c.id} style={{ marginTop: "0.5rem", paddingLeft: "0.75rem", borderLeft: "2px solid var(--border)" }}> <div key={c.id} className="mt-3 border-l-2 border-border pl-4">
<div className="card-head"> <div className="flex items-center justify-between gap-2">
<span className="mono"> <span className="font-mono-num text-xs text-muted-foreground">
{c.startSec}s {c.endSec}s {c.startSec}s {c.endSec}s
{c.audioPeakScore != null && ` · peak ${c.audioPeakScore.toFixed(1)}dB`} {c.audioPeakScore != null && ` · peak ${c.audioPeakScore.toFixed(1)}dB`}
</span> </span>
<span className={`badge ${STATUS_BADGE[c.status] ?? "muted"}`}>{c.status}</span> <Badge variant={STATUS_BADGE[c.status] ?? "muted"}>{c.status}</Badge>
</div> </div>
{transcriptPreview(c.transcriptJson) && ( {transcriptPreview(c.transcriptJson) && (
<p className="transcript-preview">{transcriptPreview(c.transcriptJson)}</p> <p className="mt-1.5 text-sm leading-relaxed">{transcriptPreview(c.transcriptJson)}</p>
)} )}
{c.short && ( {c.short && (
<div style={{ marginTop: "0.5rem" }}> <div className="mt-2 flex flex-col items-start gap-2">
<span className={`badge ${STATUS_BADGE[c.short.status] ?? "muted"}`}> <Badge variant={STATUS_BADGE[c.short.status] ?? "muted"}>9:16: {c.short.status}</Badge>
9:16: {c.short.status}
</span>
{c.short.status === "READY" && ( {c.short.status === "READY" && (
<> <>
<video controls preload="metadata" style={{ width: "220px", marginTop: "0.4rem", borderRadius: "6px", display: "block" }}> <video controls preload="metadata" className="w-[220px] rounded-md border border-border">
<source src={`/api/shorts/${c.short.id}/video`} type="video/mp4" /> <source src={`/api/shorts/${c.short.id}/video`} type="video/mp4" />
</video> </video>
<a className="badge muted" style={{ marginTop: "0.3rem", display: "inline-block" }} href={`/api/shorts/${c.short.id}/video?download=1`}> <Button asChild variant="outline" size="sm">
İndir <a href={`/api/shorts/${c.short.id}/video?download=1`}>İndir</a>
</a> </Button>
</> </>
)} )}
{c.short.status === "FAILED" && c.short.errorMessage && (
<p className="mono" style={{ fontSize: "0.75rem", marginTop: "0.3rem" }}>{c.short.errorMessage}</p>
)}
{c.short.status === "FAILED" && ( {c.short.status === "FAILED" && (
<form action={retryRender.bind(null, c.id)} style={{ marginTop: "0.3rem" }}> <>
<button type="submit" className="badge ok" style={{ border: "none", cursor: "pointer" }}> {c.short.errorMessage && (
<p className="font-mono-num text-xs text-muted-foreground">{c.short.errorMessage}</p>
)}
<form action={retryRender.bind(null, c.id)}>
<Button type="submit" size="sm">
Tekrar Dene Tekrar Dene
</button> </Button>
</form> </form>
</>
)} )}
{c.short.status === "RENDERING" && ( {c.short.status === "RENDERING" && (
<form action={cancelRender.bind(null, c.id)} style={{ marginTop: "0.3rem" }}> <form action={cancelRender.bind(null, c.id)}>
<ConfirmButton <ConfirmButton
confirmText="Render'ı başarısız say ve kilidi aç? (Çalışan process'i öldürmez, sadece durumu sıfırlar)" confirmText="Render'ı başarısız say ve kilidi aç? (Çalışan process'i öldürmez, sadece durumu sıfırlar)"
label="Takıldıysa İptal Et" label="Takıldıysa İptal Et"
variant="muted" variant="outline"
/> />
</form> </form>
)} )}
</div> </div>
)} )}
{!c.short && c.status === "TRANSCRIBED" && ( {!c.short && c.status === "TRANSCRIBED" && (
<form action={retryRender.bind(null, c.id)} style={{ marginTop: "0.5rem" }}> <form action={retryRender.bind(null, c.id)} className="mt-2">
<button type="submit" className="badge ok" style={{ border: "none", cursor: "pointer" }}> <Button type="submit" size="sm">
9:16 Render Et 9:16 Render Et
</button> </Button>
</form> </form>
)} )}
<form action={deleteCandidateSegment.bind(null, c.id)} style={{ marginTop: "0.35rem" }}> <form action={deleteCandidateSegment.bind(null, c.id)} className="mt-2">
<DeleteButton confirmText="Bu aday klibi silmek istediğine emin misin?" /> <ConfirmButton confirmText="Bu aday klibi silmek istediğine emin misin?" label="Sil" />
</form> </form>
</div> </div>
))} ))}
</div> </div>
)) ))}
</CardContent>
)}
</Card>
))}
</div>
)} )}
</div> </div>
)) </div>
)}
</>
); );
} }
+43 -10
View File
@@ -1,24 +1,57 @@
"use client"; "use client";
import { useRef } from "react";
import { Button, type buttonVariants } from "@/components/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import type { VariantProps } from "class-variance-authority";
/** Submits the form this button lives in — used inside the (portaled) AlertDialogAction, which isn't a DOM descendant of that form, so `type="submit"` alone wouldn't reach it. */
export function ConfirmButton({ export function ConfirmButton({
confirmText, confirmText,
label, label,
variant = "err", triggerLabel,
variant = "destructive",
}: { }: {
confirmText: string; confirmText: string;
label: string; label: string;
variant?: "err" | "warn" | "muted" | "ok"; triggerLabel?: string;
variant?: VariantProps<typeof buttonVariants>["variant"];
}) { }) {
const triggerRef = useRef<HTMLButtonElement>(null);
return ( return (
<button <AlertDialog>
type="submit" <AlertDialogTrigger asChild>
className={`badge ${variant}`} <Button ref={triggerRef} type="button" variant={variant} size="sm">
style={{ border: "none", cursor: "pointer" }} {triggerLabel ?? label}
onClick={(e) => { </Button>
if (!confirm(confirmText)) e.preventDefault(); </AlertDialogTrigger>
}} <AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Emin misin?</AlertDialogTitle>
<AlertDialogDescription>{confirmText}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Vazgeç</AlertDialogCancel>
<AlertDialogAction
variant={variant}
onClick={() => triggerRef.current?.closest("form")?.requestSubmit()}
> >
{label} {label}
</button> </AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
); );
} }
@@ -1,16 +0,0 @@
"use client";
export function DeleteButton({ confirmText, label = "Sil" }: { confirmText: string; label?: string }) {
return (
<button
type="submit"
className="badge err"
style={{ border: "none", cursor: "pointer" }}
onClick={(e) => {
if (!confirm(confirmText)) e.preventDefault();
}}
>
{label}
</button>
);
}
+1 -12
View File
@@ -34,18 +34,7 @@ export function LiveLogViewer({ sessionId }: { sessionId: string }) {
return ( return (
<pre <pre
ref={boxRef} ref={boxRef}
className="mono" className="font-mono-num mt-3 max-h-56 overflow-y-auto rounded-md border border-border bg-background/60 p-3 text-[0.7rem] leading-relaxed whitespace-pre-wrap text-muted-foreground"
style={{
maxHeight: "220px",
overflowY: "auto",
background: "var(--bg)",
border: "1px solid var(--border)",
borderRadius: "6px",
padding: "0.6rem 0.7rem",
fontSize: "0.7rem",
whiteSpace: "pre-wrap",
marginTop: "0.5rem",
}}
> >
{lines.length > 0 ? lines.join("\n") : "Log bekleniyor…"} {lines.length > 0 ? lines.join("\n") : "Log bekleniyor…"}
</pre> </pre>
+24
View File
@@ -0,0 +1,24 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
export function NavLink({ href, children }: { href: string; children: ReactNode }) {
const pathname = usePathname();
const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
return (
<Link
href={href}
className={cn(
"text-sm font-medium transition-colors hover:text-foreground",
active ? "text-foreground" : "text-muted-foreground",
)}
>
{children}
</Link>
);
}
@@ -19,5 +19,5 @@ export function SessionTimer({ startedAt }: { startedAt: string }) {
return () => clearInterval(timer); return () => clearInterval(timer);
}, []); }, []);
return <span className="mono">{formatElapsed(now - new Date(startedAt).getTime())}</span>; return <span className="font-mono-num">{formatElapsed(now - new Date(startedAt).getTime())}</span>;
} }
+81 -141
View File
@@ -1,154 +1,94 @@
@import "tailwindcss";
@import "tw-animate-css";
/*
* Panel koyu temayla sabit tasarlandı (broadcast/monitör kimliği) — açık
* tema desteği bilinçli olarak yok, tek bir palet var.
*/
:root { :root {
color-scheme: light dark; --radius: 0.625rem;
--bg: #0b0d12;
--panel: #141822; --background: #0a0e15;
--border: #262c3a; --foreground: #e8ecf2;
--text: #e6e9ef;
--muted: #8b93a7; --card: #121722;
--accent: #4f8cff; --card-foreground: #e8ecf2;
--popover: #121722;
--popover-foreground: #e8ecf2;
--border: #232b3a;
--input: #1b2230;
--muted: #171d29;
--muted-foreground: #8b93a7;
--primary: #22d3c8;
--primary-foreground: #072421;
--secondary: #1b2230;
--secondary-foreground: #c7cfde;
--accent: #1a2130;
--accent-foreground: #e8ecf2;
--destructive: #ef5b5b;
--ring: #22d3c8;
/* Ürünün durum sözlüğü — badge/rozet renkleri, primary'den bağımsız */
--ok: #37c976; --ok: #37c976;
--warn: #f2b84b; --warn: #f2b84b;
--err: #ef5b5b; --err: #ef5b5b;
--live: #ff5c5c;
} }
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-ring: var(--ring);
--color-ok: var(--ok);
--color-warn: var(--warn);
--color-err: var(--err);
--color-live: var(--live);
--font-sans: var(--font-hanken-grotesk);
--font-mono: var(--font-jetbrains-mono);
}
@layer base {
* { * {
box-sizing: border-box; @apply border-border outline-ring/50;
} }
body { body {
margin: 0; @apply bg-background text-foreground;
background: var(--bg); font-family: var(--font-sans), ui-sans-serif, system-ui, sans-serif;
color: var(--text); }
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
} }
.nav { @layer utilities {
display: flex; .font-mono-num {
gap: 1.5rem; font-family: var(--font-mono), ui-monospace, SFMono-Regular, Menlo, monospace;
padding: 1rem 2rem; font-variant-numeric: tabular-nums;
border-bottom: 1px solid var(--border);
} }
.nav a {
color: var(--muted);
text-decoration: none;
font-weight: 600;
}
.nav a.active {
color: var(--text);
}
main {
max-width: 960px;
margin: 0 auto;
padding: 2rem;
}
h1 {
font-size: 1.4rem;
margin-bottom: 1.5rem;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
text-align: left;
padding: 0.6rem 0.8rem;
border-bottom: 1px solid var(--border);
font-size: 0.9rem;
}
th {
color: var(--muted);
font-weight: 600;
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 0.03em;
}
.badge {
display: inline-block;
padding: 0.15rem 0.5rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
}
.badge.ok { background: rgba(55,201,118,0.15); color: var(--ok); }
.badge.muted { background: rgba(139,147,167,0.15); color: var(--muted); }
.badge.warn { background: rgba(242,184,75,0.15); color: var(--warn); }
.badge.err { background: rgba(239,91,91,0.15); color: var(--err); }
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 1rem 1.25rem;
margin-bottom: 1rem;
}
.add-channel-form {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.add-channel-form input {
flex: 1;
min-width: 160px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.5rem 0.7rem;
color: var(--text);
font-size: 0.9rem;
}
.add-channel-form input:focus {
outline: none;
border-color: var(--accent);
}
.add-channel-form button {
background: var(--accent);
border: none;
border-radius: 6px;
padding: 0.5rem 1rem;
color: #fff;
font-weight: 600;
font-size: 0.9rem;
cursor: pointer;
}
.add-channel-form button:hover {
opacity: 0.9;
}
.card-head {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 0.5rem;
}
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.8rem;
color: var(--muted);
}
.transcript-preview {
margin-top: 0.5rem;
font-size: 0.85rem;
color: var(--text);
line-height: 1.4;
}
.empty {
color: var(--muted);
font-size: 0.9rem;
} }
+30 -16
View File
@@ -1,8 +1,22 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import Link from "next/link"; import { Hanken_Grotesk, JetBrains_Mono } from "next/font/google";
import "./globals.css"; import "./globals.css";
import { getSession } from "../lib/auth"; import { getSession } from "../lib/auth";
import { logout } from "./login/actions"; import { logout } from "./login/actions";
import { NavLink } from "./components/NavLink";
import { Button } from "@/components/ui/button";
const hankenGrotesk = Hanken_Grotesk({
subsets: ["latin"],
variable: "--font-hanken-grotesk",
display: "swap",
});
const jetbrainsMono = JetBrains_Mono({
subsets: ["latin"],
variable: "--font-jetbrains-mono",
display: "swap",
});
export const metadata: Metadata = { export const metadata: Metadata = {
title: "StreamClipper AI — Panel", title: "StreamClipper AI — Panel",
@@ -12,29 +26,29 @@ export default async function RootLayout({ children }: { children: React.ReactNo
const session = await getSession(); const session = await getSession();
return ( return (
<html lang="tr"> <html lang="tr" className={`${hankenGrotesk.variable} ${jetbrainsMono.variable}`}>
<body> <body>
{session && ( {session && (
<nav className="nav"> <nav className="flex items-center gap-6 border-b border-border px-6 py-4">
<Link href="/">Kanal Durumu</Link> <span className="mr-2 text-sm font-semibold tracking-tight text-foreground">
<Link href="/segments">Segment & Aday Kütüphanesi</Link> StreamClipper<span className="text-primary">.</span>
<Link href="/stats">İstatistik</Link> </span>
<Link href="/settings">Ayarlar</Link> <NavLink href="/">Kanal Durumu</NavLink>
<Link href="/users">Kullanıcılar</Link> <NavLink href="/segments">Segment & Aday Kütüphanesi</NavLink>
<span style={{ marginLeft: "auto", display: "flex", gap: "1rem", alignItems: "center" }}> <NavLink href="/stats">İstatistik</NavLink>
<span className="mono">{session.username}</span> <NavLink href="/settings">Ayarlar</NavLink>
<NavLink href="/users">Kullanıcılar</NavLink>
<span className="ml-auto flex items-center gap-4">
<span className="font-mono-num text-xs text-muted-foreground">{session.username}</span>
<form action={logout}> <form action={logout}>
<button <Button type="submit" variant="ghost" size="sm" className="text-muted-foreground">
type="submit"
style={{ background: "none", border: "none", color: "var(--muted)", cursor: "pointer", font: "inherit" }}
>
Çıkış Yap Çıkış Yap
</button> </Button>
</form> </form>
</span> </span>
</nav> </nav>
)} )}
<main>{children}</main> <main className="mx-auto max-w-4xl px-6 py-10">{children}</main>
</body> </body>
</html> </html>
); );
+18 -55
View File
@@ -1,71 +1,34 @@
import { prisma } from "@streamclipper/db"; import { prisma } from "@streamclipper/db";
import { bootstrapAdmin, login } from "./actions"; import { bootstrapAdmin, login } from "./actions";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
const fieldStyle = { export default async function LoginPage({ searchParams }: { searchParams: Promise<{ error?: string }> }) {
background: "var(--bg)",
border: "1px solid var(--border)",
borderRadius: "6px",
padding: "0.6rem 0.7rem",
color: "var(--text)",
fontSize: "0.9rem",
};
const buttonStyle = {
background: "var(--accent)",
border: "none",
borderRadius: "6px",
padding: "0.6rem 1rem",
color: "#fff",
fontWeight: 600,
fontSize: "0.9rem",
cursor: "pointer",
};
export default async function LoginPage({
searchParams,
}: {
searchParams: Promise<{ error?: string }>;
}) {
const { error } = await searchParams; const { error } = await searchParams;
const userCount = await prisma.user.count(); const userCount = await prisma.user.count();
const isBootstrap = userCount === 0; const isBootstrap = userCount === 0;
return ( return (
<div style={{ maxWidth: "360px", margin: "4rem auto" }}> <div className="flex min-h-[60vh] items-center justify-center">
<h1>{isBootstrap ? "İlk Yönetici Hesabını Oluştur" : "Giriş Yap"}</h1> <Card className="w-full max-w-sm">
<CardHeader>
<CardTitle className="text-xl">{isBootstrap ? "İlk Yönetici Hesabını Oluştur" : "Giriş Yap"}</CardTitle>
{isBootstrap && ( {isBootstrap && (
<p className="empty" style={{ marginBottom: "1rem" }}> <CardDescription>Henüz kullanıcı yok panele erişecek ilk admin hesabını burada oluştur.</CardDescription>
Henüz kullanıcı yok panele erişecek ilk admin hesabını burada oluştur.
</p>
)} )}
</CardHeader>
{!isBootstrap && error && ( <CardContent>
<p style={{ color: "var(--err)", fontSize: "0.9rem", marginBottom: "1rem" }}> {!isBootstrap && error && <p className="mb-3 text-sm text-destructive">Kullanıcı adı veya şifre hatalı.</p>}
Kullanıcı adı veya şifre hatalı. <form action={isBootstrap ? bootstrapAdmin : login} className="flex flex-col gap-3">
</p> <Input name="username" placeholder="Kullanıcı adı" required />
)} <Input name="password" type="password" placeholder="Şifre" required minLength={isBootstrap ? 8 : undefined} />
<Button type="submit">{isBootstrap ? "Hesabı Oluştur" : "Giriş Yap"}</Button>
<form
action={isBootstrap ? bootstrapAdmin : login}
className="card"
style={{ display: "flex", flexDirection: "column", gap: "0.6rem" }}
>
<input name="username" placeholder="Kullanıcı adı" required style={fieldStyle} />
<input
name="password"
type="password"
placeholder="Şifre"
required
minLength={isBootstrap ? 8 : undefined}
style={fieldStyle}
/>
<button type="submit" style={buttonStyle}>
{isBootstrap ? "Hesabı Oluştur" : "Giriş Yap"}
</button>
</form> </form>
</CardContent>
</Card>
</div> </div>
); );
} }
+73 -58
View File
@@ -2,6 +2,11 @@ import Link from "next/link";
import { prisma } from "@streamclipper/db"; import { prisma } from "@streamclipper/db";
import { addChannel, toggleChannelActive, toggleAllChannels, checkChannelNow } from "./actions"; import { addChannel, toggleChannelActive, toggleAllChannels, checkChannelNow } from "./actions";
import { fetchChannelStatuses } from "../lib/apiDaemon"; import { fetchChannelStatuses } from "../lib/apiDaemon";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -21,89 +26,99 @@ export default async function DashboardPage() {
]); ]);
return ( return (
<> <div className="flex flex-col gap-6">
<h1>Kanal Durumu</h1> <h1 className="text-2xl font-semibold tracking-tight">Kanal Durumu</h1>
<form action={addChannel} className="card add-channel-form"> <Card>
<input name="name" placeholder="Kanal adı" required /> <CardHeader>
<input name="youtubeHandle" placeholder="@handle" required /> <CardTitle className="text-sm font-medium text-muted-foreground">Yeni Kanal Ekle</CardTitle>
<input name="channelId" placeholder="channel_id (UC...)" required /> </CardHeader>
<button type="submit">Kanal Ekle</button> <CardContent>
<form action={addChannel} className="flex flex-wrap gap-2">
<Input name="name" placeholder="Kanal adı" required className="flex-1 min-w-[140px]" />
<Input name="youtubeHandle" placeholder="@handle" required className="flex-1 min-w-[120px]" />
<Input name="channelId" placeholder="channel_id (UC...)" required className="flex-1 min-w-[180px]" />
<Button type="submit">Kanal Ekle</Button>
</form> </form>
</CardContent>
</Card>
{channels.length === 0 ? ( {channels.length === 0 ? (
<p className="empty">Henüz kanal eklenmedi.</p> <p className="text-sm text-muted-foreground">Henüz kanal eklenmedi.</p>
) : ( ) : (
<> <Card>
<div style={{ display: "flex", gap: "0.5rem", marginBottom: "0.6rem" }}> <CardHeader className="flex-row items-center justify-between">
<CardTitle className="text-sm font-medium text-muted-foreground">{channels.length} kanal</CardTitle>
<div className="flex gap-2">
<form action={toggleAllChannels.bind(null, true)}> <form action={toggleAllChannels.bind(null, true)}>
<button type="submit" className="badge ok" style={{ border: "none", cursor: "pointer" }}> <Button type="submit" variant="outline" size="sm">
Tümünü Devam Ettir Tümünü Devam Ettir
</button> </Button>
</form> </form>
<form action={toggleAllChannels.bind(null, false)}> <form action={toggleAllChannels.bind(null, false)}>
<button type="submit" className="badge muted" style={{ border: "none", cursor: "pointer" }}> <Button type="submit" variant="outline" size="sm">
Tümünü Duraklat Tümünü Duraklat
</button> </Button>
</form> </form>
</div> </div>
<table> </CardHeader>
<thead> <CardContent>
<tr> <Table>
<th>Kanal</th> <TableHeader>
<th>Durum</th> <TableRow>
<th>Kayıt</th> <TableHead>Kanal</TableHead>
<th>Son Kontrol</th> <TableHead>Durum</TableHead>
<th></th> <TableHead>Kayıt</TableHead>
</tr> <TableHead>Son Kontrol</TableHead>
</thead> <TableHead className="text-right">Aksiyon</TableHead>
<tbody> </TableRow>
</TableHeader>
<TableBody>
{channels.map((c) => { {channels.map((c) => {
const recording = c.sessions.length > 0; const recording = c.sessions.length > 0;
const status = statuses.get(c.id); const status = statuses.get(c.id);
return ( return (
<tr key={c.id}> <TableRow key={c.id}>
<td> <TableCell>
<Link href={`/channels/${c.id}`}>{c.name}</Link>{" "} <Link href={`/channels/${c.id}`} className="font-medium hover:text-primary hover:underline">
<span className="mono">{c.youtubeHandle}</span> {c.name}
</td> </Link>{" "}
<td> <span className="font-mono-num text-xs text-muted-foreground">{c.youtubeHandle}</span>
<span className={`badge ${c.isActive ? "ok" : "muted"}`}> </TableCell>
{c.isActive ? "Aktif" : "Pasif"} <TableCell>
</span> <Badge variant={c.isActive ? "ok" : "muted"}>{c.isActive ? "Aktif" : "Pasif"}</Badge>
</td> </TableCell>
<td> <TableCell>
<span className={`badge ${recording ? "warn" : "muted"}`}> <div className="flex items-center gap-1.5">
{recording ? "🔴 Kayıtta" : "—"} <Badge variant={recording ? "live" : "muted"}>{recording ? "🔴 Kayıtta" : "—"}</Badge>
</span> {status?.lastPollError && <Badge variant="err">hata</Badge>}
{status?.lastPollError && ( </div>
<span className="badge err" style={{ marginLeft: "0.4rem" }}> </TableCell>
hata <TableCell className="font-mono-num text-xs text-muted-foreground">
</span>
)}
</td>
<td className="mono">
{c.lastCheckedAt ? new Date(c.lastCheckedAt).toLocaleString("tr-TR") : "—"} {c.lastCheckedAt ? new Date(c.lastCheckedAt).toLocaleString("tr-TR") : "—"}
</td> </TableCell>
<td style={{ display: "flex", gap: "0.35rem" }}> <TableCell>
<div className="flex justify-end gap-1.5">
<form action={toggleChannelActive.bind(null, c.id, !c.isActive)}> <form action={toggleChannelActive.bind(null, c.id, !c.isActive)}>
<button type="submit" className="badge muted" style={{ border: "none", cursor: "pointer" }}> <Button type="submit" variant="outline" size="sm">
{c.isActive ? "Duraklat" : "Devam Ettir"} {c.isActive ? "Duraklat" : "Devam Ettir"}
</button> </Button>
</form> </form>
<form action={checkChannelNow.bind(null, c.id)}> <form action={checkChannelNow.bind(null, c.id)}>
<button type="submit" className="badge muted" style={{ border: "none", cursor: "pointer" }}> <Button type="submit" variant="ghost" size="sm">
Şimdi Kontrol Et Şimdi Kontrol Et
</button> </Button>
</form> </form>
</td> </div>
</tr> </TableCell>
</TableRow>
); );
})} })}
</tbody> </TableBody>
</table> </Table>
</> </CardContent>
</Card>
)} )}
</> </div>
); );
} }
+63 -51
View File
@@ -1,11 +1,14 @@
import { prisma } from "@streamclipper/db"; import { prisma } from "@streamclipper/db";
import { deleteRawSegment, deleteCandidateSegment, retryRender, cancelRender } from "../actions"; import { deleteRawSegment, deleteCandidateSegment, retryRender, cancelRender } from "../actions";
import { DeleteButton } from "../components/DeleteButton";
import { ConfirmButton } from "../components/ConfirmButton"; import { ConfirmButton } from "../components/ConfirmButton";
import { Button } from "@/components/ui/button";
import { Badge, type badgeVariants } from "@/components/ui/badge";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import type { VariantProps } from "class-variance-authority";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
const STATUS_BADGE: Record<string, string> = { const STATUS_BADGE: Record<string, VariantProps<typeof badgeVariants>["variant"]> = {
PENDING: "muted", PENDING: "muted",
PROCESSED: "ok", PROCESSED: "ok",
DISCARDED: "err", DISCARDED: "err",
@@ -30,104 +33,113 @@ export default async function SegmentsPage() {
}); });
return ( return (
<> <div className="flex flex-col gap-6">
<h1>Segment & Aday Klip Kütüphanesi</h1> <h1 className="text-2xl font-semibold tracking-tight">Segment & Aday Klip Kütüphanesi</h1>
{segments.length === 0 ? (
<p className="empty">Henüz işlenmiş segment yok.</p>
) : (
segments.map((segment) => (
<div className="card" key={segment.id}>
<div className="card-head">
<span className="mono">{segment.filePath}</span>
<span className={`badge ${STATUS_BADGE[segment.status] ?? "muted"}`}>{segment.status}</span>
</div>
<div className="mono">
{segment.duration}sn · {new Date(segment.startedAt).toLocaleString("tr-TR")}
</div>
<video controls preload="metadata" style={{ width: "100%", maxWidth: "480px", marginTop: "0.5rem", borderRadius: "6px" }}> {segments.length === 0 ? (
<p className="text-sm text-muted-foreground">Henüz işlenmiş segment yok.</p>
) : (
<div className="flex flex-col gap-4">
{segments.map((segment) => (
<Card key={segment.id}>
<CardHeader className="flex-row items-center justify-between">
<div>
<span className="font-mono-num text-xs text-muted-foreground">{segment.filePath}</span>
<p className="font-mono-num text-xs text-muted-foreground">
{segment.duration}sn · {new Date(segment.startedAt).toLocaleString("tr-TR")}
</p>
</div>
<Badge variant={STATUS_BADGE[segment.status] ?? "muted"}>{segment.status}</Badge>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<video controls preload="metadata" className="w-full max-w-md rounded-md border border-border">
<source src={`/api/segments/${segment.id}/video`} type="video/mp4" /> <source src={`/api/segments/${segment.id}/video`} type="video/mp4" />
</video> </video>
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center", marginTop: "0.5rem" }}> <div className="flex items-center gap-2">
<a className="badge muted" href={`/api/segments/${segment.id}/video?download=1`}>İndir</a> <Button asChild variant="outline" size="sm">
<a href={`/api/segments/${segment.id}/video?download=1`}>İndir</a>
</Button>
<form action={deleteRawSegment.bind(null, segment.id)}> <form action={deleteRawSegment.bind(null, segment.id)}>
<DeleteButton confirmText="Bu segmenti ve içindeki tüm aday klipleri silmek istediğine emin misin? Geri alınamaz." /> <ConfirmButton
confirmText="Bu segmenti ve içindeki tüm aday klipleri silmek istediğine emin misin? Geri alınamaz."
label="Sil"
/>
</form> </form>
</div> </div>
{segment.candidates.length === 0 ? ( {segment.candidates.length === 0 ? (
<p className="empty" style={{ marginTop: "0.5rem" }}> <p className="text-sm text-muted-foreground">Bu segmentte aday klip bulunamadı.</p>
Bu segmentte aday klip bulunamadı.
</p>
) : ( ) : (
segment.candidates.map((c) => ( segment.candidates.map((c) => (
<div key={c.id} style={{ marginTop: "0.75rem", paddingLeft: "0.75rem", borderLeft: "2px solid var(--border)" }}> <div key={c.id} className="border-l-2 border-border pl-4">
<div className="card-head"> <div className="flex items-center justify-between gap-2">
<span className="mono"> <span className="font-mono-num text-xs text-muted-foreground">
{c.startSec}s {c.endSec}s {c.startSec}s {c.endSec}s
{c.audioPeakScore != null && ` · peak ${c.audioPeakScore.toFixed(1)}dB`} {c.audioPeakScore != null && ` · peak ${c.audioPeakScore.toFixed(1)}dB`}
{c.chatVelocityScore != null && " · chat spike"} {c.chatVelocityScore != null && " · chat spike"}
</span> </span>
<span className={`badge ${STATUS_BADGE[c.status] ?? "muted"}`}>{c.status}</span> <Badge variant={STATUS_BADGE[c.status] ?? "muted"}>{c.status}</Badge>
</div> </div>
{transcriptPreview(c.transcriptJson) && ( {transcriptPreview(c.transcriptJson) && (
<p className="transcript-preview">{transcriptPreview(c.transcriptJson)}</p> <p className="mt-1.5 text-sm leading-relaxed">{transcriptPreview(c.transcriptJson)}</p>
)} )}
{c.short && ( {c.short && (
<div style={{ marginTop: "0.5rem" }}> <div className="mt-2 flex flex-col items-start gap-2">
<span className={`badge ${STATUS_BADGE[c.short.status] ?? "muted"}`}> <Badge variant={STATUS_BADGE[c.short.status] ?? "muted"}>9:16: {c.short.status}</Badge>
9:16: {c.short.status}
</span>
{c.short.status === "READY" && ( {c.short.status === "READY" && (
<> <>
<video controls preload="metadata" style={{ width: "220px", marginTop: "0.4rem", borderRadius: "6px", display: "block" }}> <video controls preload="metadata" className="w-[220px] rounded-md border border-border">
<source src={`/api/shorts/${c.short.id}/video`} type="video/mp4" /> <source src={`/api/shorts/${c.short.id}/video`} type="video/mp4" />
</video> </video>
<a className="badge muted" style={{ marginTop: "0.3rem", display: "inline-block" }} href={`/api/shorts/${c.short.id}/video?download=1`}> <Button asChild variant="outline" size="sm">
İndir <a href={`/api/shorts/${c.short.id}/video?download=1`}>İndir</a>
</a> </Button>
</> </>
)} )}
{c.short.status === "FAILED" && c.short.errorMessage && (
<p className="mono" style={{ fontSize: "0.75rem", marginTop: "0.3rem" }}>{c.short.errorMessage}</p>
)}
{c.short.status === "FAILED" && ( {c.short.status === "FAILED" && (
<form action={retryRender.bind(null, c.id)} style={{ marginTop: "0.3rem" }}> <>
<button type="submit" className="badge ok" style={{ border: "none", cursor: "pointer" }}> {c.short.errorMessage && (
<p className="font-mono-num text-xs text-muted-foreground">{c.short.errorMessage}</p>
)}
<form action={retryRender.bind(null, c.id)}>
<Button type="submit" size="sm">
Tekrar Dene Tekrar Dene
</button> </Button>
</form> </form>
</>
)} )}
{c.short.status === "RENDERING" && ( {c.short.status === "RENDERING" && (
<form action={cancelRender.bind(null, c.id)} style={{ marginTop: "0.3rem" }}> <form action={cancelRender.bind(null, c.id)}>
<ConfirmButton <ConfirmButton
confirmText="Render'ı başarısız say ve kilidi aç? (Çalışan process'i öldürmez, sadece durumu sıfırlar)" confirmText="Render'ı başarısız say ve kilidi aç? (Çalışan process'i öldürmez, sadece durumu sıfırlar)"
label="Takıldıysa İptal Et" label="Takıldıysa İptal Et"
variant="muted" variant="outline"
/> />
</form> </form>
)} )}
</div> </div>
)} )}
{!c.short && c.status === "TRANSCRIBED" && ( {!c.short && c.status === "TRANSCRIBED" && (
<form action={retryRender.bind(null, c.id)} style={{ marginTop: "0.5rem" }}> <form action={retryRender.bind(null, c.id)} className="mt-2">
<button type="submit" className="badge ok" style={{ border: "none", cursor: "pointer" }}> <Button type="submit" size="sm">
9:16 Render Et 9:16 Render Et
</button> </Button>
</form> </form>
)} )}
<form action={deleteCandidateSegment.bind(null, c.id)} style={{ marginTop: "0.35rem" }}> <form action={deleteCandidateSegment.bind(null, c.id)} className="mt-2">
<DeleteButton confirmText="Bu aday klibi silmek istediğine emin misin?" /> <ConfirmButton confirmText="Bu aday klibi silmek istediğine emin misin?" label="Sil" />
</form> </form>
</div> </div>
)) ))
)} )}
</CardContent>
</Card>
))}
</div> </div>
))
)} )}
</> </div>
); );
} }
+72 -112
View File
@@ -1,5 +1,12 @@
import { prisma } from "@streamclipper/db"; import { prisma } from "@streamclipper/db";
import { updateYtdlpCookies, updateSystemSettings, updateNotificationPrefs } from "../actions"; import { updateYtdlpCookies, updateSystemSettings, updateNotificationPrefs } from "../actions";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from "@/components/ui/card";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -22,139 +29,92 @@ export default async function SettingsPage() {
const notifyRenderDone = notifyRender?.value !== "false"; const notifyRenderDone = notifyRender?.value !== "false";
return ( return (
<> <div className="flex flex-col gap-6">
<h1>Ayarlar</h1> <h1 className="text-2xl font-semibold tracking-tight">Ayarlar</h1>
<div className="card"> <Card>
<div className="card-head"> <CardHeader className="flex-row items-center justify-between">
<span>YouTube Cookie&apos;leri</span> <CardTitle className="text-sm font-medium text-muted-foreground">YouTube Cookie&apos;leri</CardTitle>
<span className="mono"> <span className="font-mono-num text-xs text-muted-foreground">
{setting ? `son güncelleme: ${setting.updatedAt.toLocaleString("tr-TR")}` : "hiç ayarlanmadı"} {setting ? `son güncelleme: ${setting.updatedAt.toLocaleString("tr-TR")}` : "hiç ayarlanmadı"}
</span> </span>
</div> </CardHeader>
<CardContent className="flex flex-col gap-3">
{cookieStale && ( {cookieStale && (
<p style={{ marginBottom: "0.5rem" }}> <Badge variant="warn" className="w-fit">
<span className="badge warn"> {Math.round(cookieAgeHours!)} saattir güncellenmedi büyük/popüler kanallarda bot-check hatası
{Math.round(cookieAgeHours!)} saattir güncellenmedi büyük/popüler kanallarda bot-check görülebilir
hatası görülebilir, taze bir cookies.txt ile güncelle </Badge>
</span>
</p>
)} )}
<p className="empty" style={{ marginBottom: "0.5rem" }}> <CardDescription>
YouTube canlı yayın kontrolü ve kayıt için kullanılıyor. Google, oturum çerezlerinin bir YouTube canlı yayın kontrolü ve kayıt için kullanılıyor. Google, oturum çerezlerinin bir kısmını birkaç
kısmını birkaç saatte bir yeniliyor burada eskidiğini fark edersen (kanal detay saatte bir yeniliyor burada eskidiğini fark edersen (kanal detay sayfasında &quot;son poll hatası&quot;
sayfasında &quot;son poll hatası&quot; olarak görürsün) taze bir cookies.txt ile olarak görürsün) taze bir cookies.txt ile güncelle. Ana hesabın yerine ayrı, önemsiz bir Google hesabı
güncelle. Ana hesabın yerine ayrı, önemsiz bir Google hesabı kullanman önerilir. kullanman önerilir.
</p> </CardDescription>
<form action={updateYtdlpCookies}> <form action={updateYtdlpCookies} className="flex flex-col gap-3">
<textarea <Textarea
name="cookies" name="cookies"
required required
placeholder="Netscape formatlı cookies.txt içeriğini buraya yapıştır" placeholder="Netscape formatlı cookies.txt içeriğini buraya yapıştır"
rows={10} rows={10}
style={{ className="font-mono-num text-xs"
width: "100%",
background: "var(--bg)",
border: "1px solid var(--border)",
borderRadius: "6px",
padding: "0.6rem 0.7rem",
color: "var(--text)",
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
fontSize: "0.75rem",
resize: "vertical",
}}
/> />
<button <Button type="submit" className="self-start">
type="submit"
style={{
marginTop: "0.6rem",
background: "var(--accent)",
border: "none",
borderRadius: "6px",
padding: "0.5rem 1rem",
color: "#fff",
fontWeight: 600,
fontSize: "0.9rem",
cursor: "pointer",
}}
>
Kaydet Kaydet
</button> </Button>
</form> </form>
</div> </CardContent>
</Card>
<div className="card"> <Card>
<div className="card-head"> <CardHeader>
<span>Sistem Parametreleri</span> <CardTitle className="text-sm font-medium text-muted-foreground">Sistem Parametreleri</CardTitle>
<CardDescription>Bir sonraki döngüden itibaren geçerli olur, redeploy gerekmez.</CardDescription>
</CardHeader>
<CardContent>
<form action={updateSystemSettings} className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<Label htmlFor="pollIntervalSec">Poll aralığı (saniye)</Label>
<Input id="pollIntervalSec" name="pollIntervalSec" type="number" min={5} defaultValue={pollIntervalSec} className="max-w-40" />
</div> </div>
<p className="empty" style={{ marginBottom: "0.5rem" }}> <div className="flex flex-col gap-1.5">
Bir sonraki döngüden itibaren geçerli olur, redeploy gerekmez. <Label htmlFor="ttlHours">Ham segment TTL (saat)</Label>
<Input id="ttlHours" name="ttlHours" type="number" min={1} defaultValue={ttlHours} className="max-w-40" />
</div>
<p className="max-w-prose text-xs text-muted-foreground">
Eşzamanlı capture limiti ve render eşzamanlılığı BullMQ worker&apos;ları başlatılırken sabitleniyor
bunları değiştirmek için Coolify&apos;deki <code className="font-mono-num">MAX_CONCURRENT_CAPTURES</code>{" "}
/ <code className="font-mono-num">VIDEO_RENDER_CONCURRENCY</code> env var&apos;larını güncelleyip
redeploy etmek gerekiyor.
</p> </p>
<form action={updateSystemSettings} style={{ display: "flex", flexDirection: "column", gap: "0.6rem" }}> <Button type="submit" className="self-start">
<label className="mono" style={{ fontSize: "0.8rem" }}>
Poll aralığı (saniye)
<input name="pollIntervalSec" type="number" min={5} defaultValue={pollIntervalSec} style={{ display: "block", marginTop: "0.25rem" }} />
</label>
<label className="mono" style={{ fontSize: "0.8rem" }}>
Ham segment TTL (saat)
<input name="ttlHours" type="number" min={1} defaultValue={ttlHours} style={{ display: "block", marginTop: "0.25rem" }} />
</label>
<p className="empty" style={{ fontSize: "0.75rem", margin: 0 }}>
Eşzamanlı capture limiti ve render eşzamanlılığı BullMQ worker&apos;ları başlatılırken
sabitleniyor bunları değiştirmek için Coolify&apos;deki `MAX_CONCURRENT_CAPTURES` /
`VIDEO_RENDER_CONCURRENCY` env var&apos;larını güncelleyip redeploy etmek gerekiyor.
</p>
<button
type="submit"
style={{
alignSelf: "flex-start",
background: "var(--accent)",
border: "none",
borderRadius: "6px",
padding: "0.5rem 1rem",
color: "#fff",
fontWeight: 600,
fontSize: "0.9rem",
cursor: "pointer",
}}
>
Kaydet Kaydet
</button> </Button>
</form> </form>
</div> </CardContent>
</Card>
<div className="card"> <Card>
<div className="card-head"> <CardHeader>
<span>Bildirimler</span> <CardTitle className="text-sm font-medium text-muted-foreground">Bildirimler</CardTitle>
</div> </CardHeader>
<form action={updateNotificationPrefs} style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}> <form action={updateNotificationPrefs}>
<label className="mono" style={{ fontSize: "0.85rem", display: "flex", alignItems: "center", gap: "0.4rem" }}> <CardContent className="flex flex-col gap-4">
<input type="checkbox" name="notifyStreamStart" defaultChecked={notifyStreamStart} /> <Label className="flex items-center justify-between gap-4 text-sm font-normal">
Yayın başladığında Telegram bildirimi Yayın başladığında Telegram bildirimi
</label> <Switch name="notifyStreamStart" defaultChecked={notifyStreamStart} />
<label className="mono" style={{ fontSize: "0.85rem", display: "flex", alignItems: "center", gap: "0.4rem" }}> </Label>
<input type="checkbox" name="notifyRenderDone" defaultChecked={notifyRenderDone} /> <Label className="flex items-center justify-between gap-4 text-sm font-normal">
9:16 render tamamlandığında Telegram bildirimi 9:16 render tamamlandığında Telegram bildirimi
</label> <Switch name="notifyRenderDone" defaultChecked={notifyRenderDone} />
<button </Label>
type="submit" </CardContent>
style={{ <CardFooter>
alignSelf: "flex-start", <Button type="submit">Kaydet</Button>
marginTop: "0.3rem", </CardFooter>
background: "var(--accent)",
border: "none",
borderRadius: "6px",
padding: "0.5rem 1rem",
color: "#fff",
fontWeight: 600,
fontSize: "0.9rem",
cursor: "pointer",
}}
>
Kaydet
</button>
</form> </form>
</Card>
</div> </div>
</>
); );
} }
+25 -34
View File
@@ -1,4 +1,5 @@
import { prisma } from "@streamclipper/db"; import { prisma } from "@streamclipper/db";
import { Card } from "@/components/ui/card";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -9,6 +10,15 @@ function fmt(n: number, digits = 1): string {
return n.toLocaleString("tr-TR", { minimumFractionDigits: digits, maximumFractionDigits: digits }); return n.toLocaleString("tr-TR", { minimumFractionDigits: digits, maximumFractionDigits: digits });
} }
function StatCard({ label, value }: { label: string; value: string }) {
return (
<Card className="gap-1 px-5 py-4">
<span className="font-mono-num text-xs tracking-wide text-muted-foreground uppercase">{label}</span>
<span className="font-mono-num text-2xl font-semibold">{value}</span>
</Card>
);
}
export default async function StatsPage() { export default async function StatsPage() {
const [transcribed, durationSum, readyShorts, failedShorts, totalCandidates] = await Promise.all([ const [transcribed, durationSum, readyShorts, failedShorts, totalCandidates] = await Promise.all([
prisma.candidateSegment.findMany({ prisma.candidateSegment.findMany({
@@ -30,43 +40,24 @@ export default async function StatsPage() {
const estimatedGb = captureHours * GB_PER_HOUR_ESTIMATE; const estimatedGb = captureHours * GB_PER_HOUR_ESTIMATE;
return ( return (
<> <div className="flex flex-col gap-6">
<h1>Kullanım & Maliyet</h1> <div>
<p className="empty" style={{ marginBottom: "1rem" }}> <h1 className="text-2xl font-semibold tracking-tight">Kullanım & Maliyet</h1>
Şimdiye kadarki toplam kullanım tüm zamanlar. STT maliyeti ve disk kullanımı tahmini <p className="mt-1 text-sm text-muted-foreground">
(bkz. maliyet analizi), gerçek faturayla küçük farklar olabilir. Şimdiye kadarki toplam kullanım tüm zamanlar. STT maliyeti ve disk kullanımı tahmini, gerçek faturayla
küçük farklar olabilir.
</p> </p>
</div>
<div className="card" style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))", gap: "1rem" }}> <div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
<div> <StatCard label="Toplam Kayıt Süresi" value={`${fmt(captureHours)} sa`} />
<div className="mono" style={{ fontSize: "0.75rem", opacity: 0.7 }}>TOPLAM KAYIT SÜRESİ</div> <StatCard label="Toplam STT Süresi" value={`${fmt(sttMinutes)} dk`} />
<div style={{ fontSize: "1.4rem", fontWeight: 600 }}>{fmt(captureHours)} sa</div> <StatCard label="Tahmini Whisper Maliyeti" value={`$${fmt(sttCost, 2)}`} />
</div> <StatCard label="Tahmini Disk Kullanımı" value={`${fmt(estimatedGb)} GB`} />
<div> <StatCard label="Üretilen 9:16 Klip" value={String(readyShorts)} />
<div className="mono" style={{ fontSize: "0.75rem", opacity: 0.7 }}>TOPLAM STT SÜRESİ</div> <StatCard label="Başarısız Render" value={String(failedShorts)} />
<div style={{ fontSize: "1.4rem", fontWeight: 600 }}>{fmt(sttMinutes)} dk</div> <StatCard label="Toplam Aday Klip" value={String(totalCandidates)} />
</div>
<div>
<div className="mono" style={{ fontSize: "0.75rem", opacity: 0.7 }}>TAHMİNİ WHISPER MALİYETİ</div>
<div style={{ fontSize: "1.4rem", fontWeight: 600 }}>${fmt(sttCost, 2)}</div>
</div>
<div>
<div className="mono" style={{ fontSize: "0.75rem", opacity: 0.7 }}>TAHMİNİ DİSK KULLANIMI</div>
<div style={{ fontSize: "1.4rem", fontWeight: 600 }}>{fmt(estimatedGb)} GB</div>
</div>
<div>
<div className="mono" style={{ fontSize: "0.75rem", opacity: 0.7 }}>ÜRETİLEN 9:16 KLİP</div>
<div style={{ fontSize: "1.4rem", fontWeight: 600 }}>{readyShorts}</div>
</div>
<div>
<div className="mono" style={{ fontSize: "0.75rem", opacity: 0.7 }}>BAŞARISIZ RENDER</div>
<div style={{ fontSize: "1.4rem", fontWeight: 600 }}>{failedShorts}</div>
</div>
<div>
<div className="mono" style={{ fontSize: "0.75rem", opacity: 0.7 }}>TOPLAM ADAY KLİP</div>
<div style={{ fontSize: "1.4rem", fontWeight: 600 }}>{totalCandidates}</div>
</div> </div>
</div> </div>
</>
); );
} }
+55 -45
View File
@@ -1,66 +1,76 @@
import { prisma } from "@streamclipper/db"; import { prisma } from "@streamclipper/db";
import { getSession } from "../../lib/auth"; import { getSession } from "../../lib/auth";
import { addUser, deleteUser } from "./actions"; import { addUser, deleteUser } from "./actions";
import { DeleteButton } from "../components/DeleteButton"; import { ConfirmButton } from "../components/ConfirmButton";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
const fieldStyle = {
background: "var(--bg)",
border: "1px solid var(--border)",
borderRadius: "6px",
padding: "0.5rem 0.7rem",
color: "var(--text)",
fontSize: "0.9rem",
};
export default async function UsersPage() { export default async function UsersPage() {
const [users, session] = await Promise.all([ const [users, session] = await Promise.all([prisma.user.findMany({ orderBy: { createdAt: "asc" } }), getSession()]);
prisma.user.findMany({ orderBy: { createdAt: "asc" } }),
getSession(),
]);
return ( return (
<> <div className="flex flex-col gap-6">
<h1>Kullanıcılar</h1> <h1 className="text-2xl font-semibold tracking-tight">Kullanıcılar</h1>
<form <Card>
action={addUser} <CardContent>
className="card add-channel-form" <form action={addUser} className="flex flex-wrap gap-2">
style={{ marginBottom: "1.5rem" }} <Input name="username" placeholder="Kullanıcı adı" required className="flex-1 min-w-[140px]" />
> <Input
<input name="username" placeholder="Kullanıcı adı" required style={fieldStyle} /> name="password"
<input name="password" type="password" placeholder="Şifre (en az 8 karakter)" required minLength={8} style={fieldStyle} /> type="password"
<button type="submit">Kullanıcı Ekle</button> placeholder="Şifre (en az 8 karakter)"
required
minLength={8}
className="flex-1 min-w-[160px]"
/>
<Button type="submit">Kullanıcı Ekle</Button>
</form> </form>
</CardContent>
</Card>
<table> <Card>
<thead> <CardContent>
<tr> <Table>
<th>Kullanıcı Adı</th> <TableHeader>
<th>Oluşturulma</th> <TableRow>
<th></th> <TableHead>Kullanıcı Adı</TableHead>
</tr> <TableHead>Oluşturulma</TableHead>
</thead> <TableHead className="text-right"></TableHead>
<tbody> </TableRow>
</TableHeader>
<TableBody>
{users.map((u) => ( {users.map((u) => (
<tr key={u.id}> <TableRow key={u.id}>
<td> <TableCell>
{u.username} {u.username}
{u.id === session?.sub && <span className="badge muted" style={{ marginLeft: "0.5rem" }}>sen</span>} {u.id === session?.sub && (
</td> <Badge variant="muted" className="ml-2">
<td className="mono">{u.createdAt.toLocaleString("tr-TR")}</td> sen
<td> </Badge>
)}
</TableCell>
<TableCell className="font-mono-num text-xs text-muted-foreground">
{u.createdAt.toLocaleString("tr-TR")}
</TableCell>
<TableCell className="text-right">
{u.id !== session?.sub && users.length > 1 && ( {u.id !== session?.sub && users.length > 1 && (
<form action={deleteUser.bind(null, u.id)}> <form action={deleteUser.bind(null, u.id)}>
<DeleteButton confirmText={`${u.username} kullanıcısını silmek istediğine emin misin?`} /> <ConfirmButton confirmText={`${u.username} kullanıcısını silmek istediğine emin misin?`} label="Sil" />
</form> </form>
)} )}
</td> </TableCell>
</tr> </TableRow>
))} ))}
</tbody> </TableBody>
</table> </Table>
</> </CardContent>
</Card>
</div>
); );
} }
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
@@ -0,0 +1,142 @@
"use client";
import * as React from "react";
import { AlertDialog as AlertDialogPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
function AlertDialog({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
}
function AlertDialogTrigger({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />;
}
function AlertDialogPortal({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return <AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />;
}
function AlertDialogOverlay({ className, ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
);
}
function AlertDialogContent({
className,
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
size?: "default" | "sm";
}) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
data-size={size}
className={cn(
"group/alert-dialog-content fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 data-[size=sm]:max-w-xs data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[size=default]:sm:max-w-lg",
className,
)}
{...props}
/>
</AlertDialogPortal>
);
}
function AlertDialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn(
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-6 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
className,
)}
{...props}
/>
);
}
function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
className,
)}
{...props}
/>
);
}
function AlertDialogTitle({ className, ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn(
"text-lg font-semibold sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
className,
)}
{...props}
/>
);
}
function AlertDialogDescription({ className, ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
);
}
function AlertDialogAction({
className,
variant = "default",
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action> & Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Action data-slot="alert-dialog-action" className={cn(className)} {...props} />
</Button>
);
}
function AlertDialogCancel({
className,
variant = "outline",
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> & Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Cancel data-slot="alert-dialog-cancel" className={cn(className)} {...props} />
</Button>
);
}
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
};
+41
View File
@@ -0,0 +1,41 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const alertVariants = cva(
"relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current",
warn: "bg-card text-warn *:data-[slot=alert-description]:text-warn/90 [&>svg]:text-current",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Alert({ className, variant, ...props }: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return <div data-slot="alert" role="alert" className={cn(alertVariants({ variant }), className)} {...props} />;
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="alert-title" className={cn("col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight", className)} {...props} />;
}
function AlertDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn("col-start-2 grid justify-items-start gap-1 text-sm text-muted-foreground [&_p]:leading-relaxed", className)}
{...props}
/>
);
}
export { Alert, AlertTitle, AlertDescription };
+45
View File
@@ -0,0 +1,45 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { Slot } from "radix-ui";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary: "bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
outline: "border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
link: "text-primary underline-offset-4 [a&]:hover:underline",
// Ürünün kendi durum sözlüğü (kayıt/render/segment durumları) —
// primary accent'ten bağımsız, sabit anlamları var.
ok: "bg-ok/15 text-ok",
warn: "bg-warn/15 text-warn",
err: "bg-err/15 text-err",
muted: "bg-muted text-muted-foreground",
live: "bg-live/15 text-live",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span";
return <Comp data-slot="badge" data-variant={variant} className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };
+62
View File
@@ -0,0 +1,62 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { Slot } from "radix-ui";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot.Root : "button";
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };
+57
View File
@@ -0,0 +1,57 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
className,
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className,
)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="card-title" className={cn("leading-none font-semibold", className)} {...props} />;
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="card-description" className={cn("text-sm text-muted-foreground", className)} {...props} />;
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="card-content" className={cn("px-6", className)} {...props} />;
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="card-footer" className={cn("flex items-center px-6 [.border-t]:pt-6", className)} {...props} />;
}
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent };
+21
View File
@@ -0,0 +1,21 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
className,
)}
{...props}
/>
);
}
export { Input };
+21
View File
@@ -0,0 +1,21 @@
"use client";
import * as React from "react";
import { Label as LabelPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className,
)}
{...props}
/>
);
}
export { Label };
+28
View File
@@ -0,0 +1,28 @@
"use client";
import * as React from "react";
import { Separator as SeparatorPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className,
)}
{...props}
/>
);
}
export { Separator };
+35
View File
@@ -0,0 +1,35 @@
"use client";
import * as React from "react";
import { Switch as SwitchPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function Switch({
className,
size = "default",
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
size?: "sm" | "default";
}) {
return (
<SwitchPrimitive.Root
data-slot="switch"
data-size={size}
className={cn(
"peer group/switch inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input dark:data-[state=unchecked]:bg-input/80",
className,
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0 dark:data-[state=checked]:bg-primary-foreground dark:data-[state=unchecked]:bg-foreground",
)}
/>
</SwitchPrimitive.Root>
);
}
export { Switch };
+76
View File
@@ -0,0 +1,76 @@
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div data-slot="table-container" className="relative w-full overflow-x-auto">
<table data-slot="table" className={cn("w-full caption-bottom text-sm", className)} {...props} />
</div>
);
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return <thead data-slot="table-header" className={cn("[&_tr]:border-b", className)} {...props} />;
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return <tbody data-slot="table-body" className={cn("[&_tr:last-child]:border-0", className)} {...props} />;
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", className)}
{...props}
/>
);
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
className,
)}
{...props}
/>
);
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className,
)}
{...props}
/>
);
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className,
)}
{...props}
/>
);
}
function TableCaption({ className, ...props }: React.ComponentProps<"caption">) {
return <caption data-slot="table-caption" className={cn("mt-4 text-sm text-muted-foreground", className)} {...props} />;
}
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:aria-invalid:ring-destructive/40",
className,
)}
{...props}
/>
);
}
export { Textarea };
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+9 -1
View File
@@ -9,11 +9,19 @@
}, },
"dependencies": { "dependencies": {
"@streamclipper/db": "workspace:*", "@streamclipper/db": "workspace:*",
"@tailwindcss/postcss": "^4.3.3",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"jose": "^5.9.6", "jose": "^5.9.6",
"lucide-react": "^1.38.0",
"next": "^15.1.0", "next": "^15.1.0",
"radix-ui": "^1.6.7",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0" "react-dom": "^19.0.0",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.3",
"tw-animate-css": "^1.4.0"
}, },
"devDependencies": { "devDependencies": {
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+2080 -2
View File
File diff suppressed because it is too large Load Diff