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
+182 -148
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> </CardHeader>
<div className="mono"> <CardContent className="flex flex-col gap-3">
Aktif: {channel.isActive ? "evet" : "hayır"} ·{" "} <p className="font-mono-num text-xs text-muted-foreground">
Son kontrol: {channel.lastCheckedAt ? new Date(channel.lastCheckedAt).toLocaleString("tr-TR") : ""} Aktif: {channel.isActive ? "evet" : "hayır"} · Son kontrol:{" "}
</div> {channel.lastCheckedAt ? new Date(channel.lastCheckedAt).toLocaleString("tr-TR") : "—"}
{status === undefined && (
<p className="empty" style={{ marginTop: "0.5rem" }}>
api-daemon&apos;dan canlı durum alınamadı (servis erişilemez olabilir).
</p> </p>
)}
{status?.lastPollError && (
<div style={{ marginTop: "0.5rem" }}>
<span className="badge err">son poll hatası</span>
<pre className="mono" style={{ whiteSpace: "pre-wrap", marginTop: "0.4rem" }}>
{status.lastPollError}
</pre>
</div>
)}
{status?.recording && activeSession?.liveVideoId && activeSession.liveVideoId !== "forced" && (
<iframe
src={`https://www.youtube.com/embed/${activeSession.liveVideoId}`}
title="Canlı yayın önizleme"
style={{ width: "100%", maxWidth: "480px", aspectRatio: "16/9", marginTop: "0.6rem", border: "none", borderRadius: "6px" }}
allow="autoplay; encrypted-media"
/>
)}
{status?.recording && activeSession && <LiveLogViewer sessionId={activeSession.id} />}
<details style={{ marginTop: "0.75rem" }}> {status === undefined && (
<summary className="mono" style={{ cursor: "pointer", fontSize: "0.8rem" }}> <p className="text-sm text-muted-foreground">
Manuel URL ile kayıt başlat api-daemon&apos;dan canlı durum alınamadı (servis erişilemez olabilir).
</summary> </p>
<form action={forceStartCapture.bind(null, channel.id)} style={{ display: "flex", gap: "0.4rem", marginTop: "0.5rem" }}> )}
<input name="url" placeholder="https://www.youtube.com/watch?v=..." required style={{ flex: 1 }} />
<button type="submit" className="badge ok" style={{ border: "none", cursor: "pointer" }}>
Başlat
</button>
</form>
</details>
</div>
<h1 style={{ marginTop: "2rem" }}>Yayın Geçmişi</h1> {status?.lastPollError && (
{channel.sessions.length === 0 ? ( <div className="flex flex-col gap-1.5">
<p className="empty">Bu kanal için henüz bir kayıt oturumu yok.</p> <Badge variant="err" className="w-fit">
) : ( son poll hatası
channel.sessions.map((session) => ( </Badge>
<div className="card" key={session.id}> <pre className="font-mono-num whitespace-pre-wrap rounded-md border border-border bg-background/60 p-3 text-[0.7rem] text-muted-foreground">
<div className="card-head"> {status.lastPollError}
<span className="mono"> </pre>
{new Date(session.startedAt).toLocaleString("tr-TR")}
{session.endedAt ? ` ${new Date(session.endedAt).toLocaleString("tr-TR")}` : " (devam ediyor)"}
</span>
<span className="badge muted">{session.totalSegments} segment</span>
</div> </div>
)}
{session.segments.length === 0 ? ( {status?.recording && activeSession?.liveVideoId && activeSession.liveVideoId !== "forced" && (
<p className="empty" style={{ marginTop: "0.5rem" }}>Henüz segment yok.</p> <iframe
) : ( src={`https://www.youtube.com/embed/${activeSession.liveVideoId}`}
session.segments.map((segment) => ( title="Canlı yayın önizleme"
<div key={segment.id} style={{ marginTop: "0.75rem", paddingLeft: "0.75rem", borderLeft: "2px solid var(--border)" }}> className="aspect-video w-full max-w-md rounded-md border border-border"
<div className="card-head"> allow="autoplay; encrypted-media"
<span className="mono">{segment.filePath}</span> />
<span className={`badge ${STATUS_BADGE[segment.status] ?? "muted"}`}>{segment.status}</span> )}
</div>
<video controls preload="metadata" style={{ width: "100%", maxWidth: "480px", marginTop: "0.5rem", borderRadius: "6px" }}> {status?.recording && activeSession && <LiveLogViewer sessionId={activeSession.id} />}
<source src={`/api/segments/${segment.id}/video`} type="video/mp4" />
</video>
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center", marginTop: "0.5rem" }}> <details className="mt-1 text-sm">
<a className="badge muted" href={`/api/segments/${segment.id}/video?download=1`}>İndir</a> <summary className="cursor-pointer font-mono-num text-xs text-muted-foreground">
<form action={deleteRawSegment.bind(null, segment.id)}> Manuel URL ile kayıt başlat
<DeleteButton confirmText="Bu segmenti ve içindeki tüm aday klipleri silmek istediğine emin misin? Geri alınamaz." /> </summary>
</form> <form action={forceStartCapture.bind(null, channel.id)} className="mt-2 flex gap-2">
</div> <Input name="url" placeholder="https://www.youtube.com/watch?v=..." required className="flex-1" />
<Button type="submit">Başlat</Button>
</form>
</details>
</CardContent>
</Card>
{segment.candidates.map((c) => ( <div>
<div key={c.id} style={{ marginTop: "0.5rem", paddingLeft: "0.75rem", borderLeft: "2px solid var(--border)" }}> <h2 className="mb-3 text-lg font-semibold tracking-tight">Yayın Geçmişi</h2>
<div className="card-head"> {channel.sessions.length === 0 ? (
<span className="mono"> <p className="text-sm text-muted-foreground">Bu kanal için henüz bir kayıt oturumu yok.</p>
{c.startSec}s {c.endSec}s ) : (
{c.audioPeakScore != null && ` · peak ${c.audioPeakScore.toFixed(1)}dB`} <div className="flex flex-col gap-4">
</span> {channel.sessions.map((session) => (
<span className={`badge ${STATUS_BADGE[c.status] ?? "muted"}`}>{c.status}</span> <Card key={session.id}>
</div> <CardHeader className="flex-row items-center justify-between">
{transcriptPreview(c.transcriptJson) && ( <span className="font-mono-num text-xs text-muted-foreground">
<p className="transcript-preview">{transcriptPreview(c.transcriptJson)}</p> {new Date(session.startedAt).toLocaleString("tr-TR")}
)} {session.endedAt ? ` ${new Date(session.endedAt).toLocaleString("tr-TR")}` : " (devam ediyor)"}
</span>
<Badge variant="muted">{session.totalSegments} segment</Badge>
</CardHeader>
{c.short && ( {session.segments.length === 0 ? (
<div style={{ marginTop: "0.5rem" }}> <CardContent>
<span className={`badge ${STATUS_BADGE[c.short.status] ?? "muted"}`}> <p className="text-sm text-muted-foreground">Henüz segment yok.</p>
9:16: {c.short.status} </CardContent>
</span> ) : (
{c.short.status === "READY" && ( <CardContent className="flex flex-col gap-4">
<> {session.segments.map((segment) => (
<video controls preload="metadata" style={{ width: "220px", marginTop: "0.4rem", borderRadius: "6px", display: "block" }}> <div key={segment.id} className="border-l-2 border-border pl-4">
<source src={`/api/shorts/${c.short.id}/video`} type="video/mp4" /> <div className="flex items-center justify-between gap-2">
</video> <span className="font-mono-num text-xs text-muted-foreground">{segment.filePath}</span>
<a className="badge muted" style={{ marginTop: "0.3rem", display: "inline-block" }} href={`/api/shorts/${c.short.id}/video?download=1`}> <Badge variant={STATUS_BADGE[segment.status] ?? "muted"}>{segment.status}</Badge>
İndir
</a>
</>
)}
{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" && (
<form action={retryRender.bind(null, c.id)} style={{ marginTop: "0.3rem" }}>
<button type="submit" className="badge ok" style={{ border: "none", cursor: "pointer" }}>
Tekrar Dene
</button>
</form>
)}
{c.short.status === "RENDERING" && (
<form action={cancelRender.bind(null, c.id)} style={{ marginTop: "0.3rem" }}>
<ConfirmButton
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"
variant="muted"
/>
</form>
)}
</div> </div>
)}
{!c.short && c.status === "TRANSCRIBED" && (
<form action={retryRender.bind(null, c.id)} style={{ marginTop: "0.5rem" }}>
<button type="submit" className="badge ok" style={{ border: "none", cursor: "pointer" }}>
9:16 Render Et
</button>
</form>
)}
<form action={deleteCandidateSegment.bind(null, c.id)} style={{ marginTop: "0.35rem" }}> <video controls preload="metadata" className="mt-2 w-full max-w-md rounded-md border border-border">
<DeleteButton confirmText="Bu aday klibi silmek istediğine emin misin?" /> <source src={`/api/segments/${segment.id}/video`} type="video/mp4" />
</form> </video>
</div>
))} <div className="mt-2 flex items-center gap-2">
</div> <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)}>
<ConfirmButton
confirmText="Bu segmenti ve içindeki tüm aday klipleri silmek istediğine emin misin? Geri alınamaz."
label="Sil"
/>
</form>
</div>
{segment.candidates.map((c) => (
<div key={c.id} className="mt-3 border-l-2 border-border pl-4">
<div className="flex items-center justify-between gap-2">
<span className="font-mono-num text-xs text-muted-foreground">
{c.startSec}s {c.endSec}s
{c.audioPeakScore != null && ` · peak ${c.audioPeakScore.toFixed(1)}dB`}
</span>
<Badge variant={STATUS_BADGE[c.status] ?? "muted"}>{c.status}</Badge>
</div>
{transcriptPreview(c.transcriptJson) && (
<p className="mt-1.5 text-sm leading-relaxed">{transcriptPreview(c.transcriptJson)}</p>
)}
{c.short && (
<div className="mt-2 flex flex-col items-start gap-2">
<Badge variant={STATUS_BADGE[c.short.status] ?? "muted"}>9:16: {c.short.status}</Badge>
{c.short.status === "READY" && (
<>
<video controls preload="metadata" className="w-[220px] rounded-md border border-border">
<source src={`/api/shorts/${c.short.id}/video`} type="video/mp4" />
</video>
<Button asChild variant="outline" size="sm">
<a href={`/api/shorts/${c.short.id}/video?download=1`}>İndir</a>
</Button>
</>
)}
{c.short.status === "FAILED" && (
<>
{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
</Button>
</form>
</>
)}
{c.short.status === "RENDERING" && (
<form action={cancelRender.bind(null, c.id)}>
<ConfirmButton
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"
variant="outline"
/>
</form>
)}
</div>
)}
{!c.short && c.status === "TRANSCRIBED" && (
<form action={retryRender.bind(null, c.id)} className="mt-2">
<Button type="submit" size="sm">
9:16 Render Et
</Button>
</form>
)}
<form action={deleteCandidateSegment.bind(null, c.id)} className="mt-2">
<ConfirmButton confirmText="Bu aday klibi silmek istediğine emin misin?" label="Sil" />
</form>
</div>
))}
</div>
))}
</CardContent>
)}
</Card>
))}
</div> </div>
)) )}
)} </div>
</> </div>
); );
} }
+45 -12
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>
{label} <AlertDialogTitle>Emin misin?</AlertDialogTitle>
</button> <AlertDialogDescription>{confirmText}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Vazgeç</AlertDialogCancel>
<AlertDialogAction
variant={variant}
onClick={() => triggerRef.current?.closest("form")?.requestSubmit()}
>
{label}
</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>;
} }
+83 -143
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 {
box-sizing: border-box; --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);
} }
body { @layer base {
margin: 0; * {
background: var(--bg); @apply border-border outline-ring/50;
color: var(--text); }
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; body {
@apply bg-background text-foreground;
font-family: var(--font-sans), ui-sans-serif, system-ui, 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>
); );
+21 -58
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>
{isBootstrap && ( <CardTitle className="text-xl">{isBootstrap ? "İlk Yönetici Hesabını Oluştur" : "Giriş Yap"}</CardTitle>
<p className="empty" style={{ marginBottom: "1rem" }}> {isBootstrap && (
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.</CardDescription>
</p> )}
)} </CardHeader>
<CardContent>
{!isBootstrap && error && ( {!isBootstrap && error && <p className="mb-3 text-sm text-destructive">Kullanıcı adı veya şifre hatalı.</p>}
<p style={{ color: "var(--err)", fontSize: "0.9rem", marginBottom: "1rem" }}> <form action={isBootstrap ? bootstrapAdmin : login} className="flex flex-col gap-3">
Kullanıcı adı veya şifre hatalı. <Input name="username" placeholder="Kullanıcı adı" required />
</p> <Input name="password" type="password" placeholder="Şifre" required minLength={isBootstrap ? 8 : undefined} />
)} <Button type="submit">{isBootstrap ? "Hesabı Oluştur" : "Giriş Yap"}</Button>
</form>
<form </CardContent>
action={isBootstrap ? bootstrapAdmin : login} </Card>
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>
</div> </div>
); );
} }
+94 -79
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> <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>
</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">
<form action={toggleAllChannels.bind(null, true)}> <CardTitle className="text-sm font-medium text-muted-foreground">{channels.length} kanal</CardTitle>
<button type="submit" className="badge ok" style={{ border: "none", cursor: "pointer" }}> <div className="flex gap-2">
Tümünü Devam Ettir <form action={toggleAllChannels.bind(null, true)}>
</button> <Button type="submit" variant="outline" size="sm">
</form> Tümünü Devam Ettir
<form action={toggleAllChannels.bind(null, false)}> </Button>
<button type="submit" className="badge muted" style={{ border: "none", cursor: "pointer" }}> </form>
Tümünü Duraklat <form action={toggleAllChannels.bind(null, false)}>
</button> <Button type="submit" variant="outline" size="sm">
</form> Tümünü Duraklat
</div> </Button>
<table> </form>
<thead> </div>
<tr> </CardHeader>
<th>Kanal</th> <CardContent>
<th>Durum</th> <Table>
<th>Kayıt</th> <TableHeader>
<th>Son Kontrol</th> <TableRow>
<th></th> <TableHead>Kanal</TableHead>
</tr> <TableHead>Durum</TableHead>
</thead> <TableHead>Kayıt</TableHead>
<tbody> <TableHead>Son Kontrol</TableHead>
{channels.map((c) => { <TableHead className="text-right">Aksiyon</TableHead>
const recording = c.sessions.length > 0; </TableRow>
const status = statuses.get(c.id); </TableHeader>
return ( <TableBody>
<tr key={c.id}> {channels.map((c) => {
<td> const recording = c.sessions.length > 0;
<Link href={`/channels/${c.id}`}>{c.name}</Link>{" "} const status = statuses.get(c.id);
<span className="mono">{c.youtubeHandle}</span> return (
</td> <TableRow key={c.id}>
<td> <TableCell>
<span className={`badge ${c.isActive ? "ok" : "muted"}`}> <Link href={`/channels/${c.id}`} className="font-medium hover:text-primary hover:underline">
{c.isActive ? "Aktif" : "Pasif"} {c.name}
</span> </Link>{" "}
</td> <span className="font-mono-num text-xs text-muted-foreground">{c.youtubeHandle}</span>
<td> </TableCell>
<span className={`badge ${recording ? "warn" : "muted"}`}> <TableCell>
{recording ? "🔴 Kayıtta" : "—"} <Badge variant={c.isActive ? "ok" : "muted"}>{c.isActive ? "Aktif" : "Pasif"}</Badge>
</span> </TableCell>
{status?.lastPollError && ( <TableCell>
<span className="badge err" style={{ marginLeft: "0.4rem" }}> <div className="flex items-center gap-1.5">
hata <Badge variant={recording ? "live" : "muted"}>{recording ? "🔴 Kayıtta" : "—"}</Badge>
</span> {status?.lastPollError && <Badge variant="err">hata</Badge>}
)} </div>
</td> </TableCell>
<td className="mono"> <TableCell className="font-mono-num text-xs text-muted-foreground">
{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>
<form action={toggleChannelActive.bind(null, c.id, !c.isActive)}> <div className="flex justify-end gap-1.5">
<button type="submit" className="badge muted" style={{ border: "none", cursor: "pointer" }}> <form action={toggleChannelActive.bind(null, c.id, !c.isActive)}>
{c.isActive ? "Duraklat" : "Devam Ettir"} <Button type="submit" variant="outline" size="sm">
</button> {c.isActive ? "Duraklat" : "Devam Ettir"}
</form> </Button>
<form action={checkChannelNow.bind(null, c.id)}> </form>
<button type="submit" className="badge muted" style={{ border: "none", cursor: "pointer" }}> <form action={checkChannelNow.bind(null, c.id)}>
Şimdi Kontrol Et <Button type="submit" variant="ghost" size="sm">
</button> Şimdi Kontrol Et
</form> </Button>
</td> </form>
</tr> </div>
); </TableCell>
})} </TableRow>
</tbody> );
</table> })}
</> </TableBody>
</Table>
</CardContent>
</Card>
)} )}
</> </div>
); );
} }
+107 -95
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 ? ( {segments.length === 0 ? (
<p className="empty">Henüz işlenmiş segment yok.</p> <p className="text-sm text-muted-foreground">Henüz işlenmiş segment yok.</p>
) : ( ) : (
segments.map((segment) => ( <div className="flex flex-col gap-4">
<div className="card" key={segment.id}> {segments.map((segment) => (
<div className="card-head"> <Card key={segment.id}>
<span className="mono">{segment.filePath}</span> <CardHeader className="flex-row items-center justify-between">
<span className={`badge ${STATUS_BADGE[segment.status] ?? "muted"}`}>{segment.status}</span> <div>
</div> <span className="font-mono-num text-xs text-muted-foreground">{segment.filePath}</span>
<div className="mono"> <p className="font-mono-num text-xs text-muted-foreground">
{segment.duration}sn · {new Date(segment.startedAt).toLocaleString("tr-TR")} {segment.duration}sn · {new Date(segment.startedAt).toLocaleString("tr-TR")}
</div> </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" />
</video>
<video controls preload="metadata" style={{ width: "100%", maxWidth: "480px", marginTop: "0.5rem", borderRadius: "6px" }}> <div className="flex items-center gap-2">
<source src={`/api/segments/${segment.id}/video`} type="video/mp4" /> <Button asChild variant="outline" size="sm">
</video> <a href={`/api/segments/${segment.id}/video?download=1`}>İndir</a>
</Button>
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center", marginTop: "0.5rem" }}> <form action={deleteRawSegment.bind(null, segment.id)}>
<a className="badge muted" href={`/api/segments/${segment.id}/video?download=1`}>İndir</a> <ConfirmButton
<form action={deleteRawSegment.bind(null, segment.id)}> confirmText="Bu segmenti ve içindeki tüm aday klipleri silmek istediğine emin misin? Geri alınamaz."
<DeleteButton confirmText="Bu segmenti ve içindeki tüm aday klipleri silmek istediğine emin misin? Geri alınamaz." /> label="Sil"
</form> />
</div>
{segment.candidates.length === 0 ? (
<p className="empty" style={{ marginTop: "0.5rem" }}>
Bu segmentte aday klip bulunamadı.
</p>
) : (
segment.candidates.map((c) => (
<div key={c.id} style={{ marginTop: "0.75rem", paddingLeft: "0.75rem", borderLeft: "2px solid var(--border)" }}>
<div className="card-head">
<span className="mono">
{c.startSec}s {c.endSec}s
{c.audioPeakScore != null && ` · peak ${c.audioPeakScore.toFixed(1)}dB`}
{c.chatVelocityScore != null && " · chat spike"}
</span>
<span className={`badge ${STATUS_BADGE[c.status] ?? "muted"}`}>{c.status}</span>
</div>
{transcriptPreview(c.transcriptJson) && (
<p className="transcript-preview">{transcriptPreview(c.transcriptJson)}</p>
)}
{c.short && (
<div style={{ marginTop: "0.5rem" }}>
<span className={`badge ${STATUS_BADGE[c.short.status] ?? "muted"}`}>
9:16: {c.short.status}
</span>
{c.short.status === "READY" && (
<>
<video controls preload="metadata" style={{ width: "220px", marginTop: "0.4rem", borderRadius: "6px", display: "block" }}>
<source src={`/api/shorts/${c.short.id}/video`} type="video/mp4" />
</video>
<a className="badge muted" style={{ marginTop: "0.3rem", display: "inline-block" }} href={`/api/shorts/${c.short.id}/video?download=1`}>
İndir
</a>
</>
)}
{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" && (
<form action={retryRender.bind(null, c.id)} style={{ marginTop: "0.3rem" }}>
<button type="submit" className="badge ok" style={{ border: "none", cursor: "pointer" }}>
Tekrar Dene
</button>
</form>
)}
{c.short.status === "RENDERING" && (
<form action={cancelRender.bind(null, c.id)} style={{ marginTop: "0.3rem" }}>
<ConfirmButton
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"
variant="muted"
/>
</form>
)}
</div>
)}
{!c.short && c.status === "TRANSCRIBED" && (
<form action={retryRender.bind(null, c.id)} style={{ marginTop: "0.5rem" }}>
<button type="submit" className="badge ok" style={{ border: "none", cursor: "pointer" }}>
9:16 Render Et
</button>
</form>
)}
<form action={deleteCandidateSegment.bind(null, c.id)} style={{ marginTop: "0.35rem" }}>
<DeleteButton confirmText="Bu aday klibi silmek istediğine emin misin?" />
</form> </form>
</div> </div>
))
)} {segment.candidates.length === 0 ? (
</div> <p className="text-sm text-muted-foreground">Bu segmentte aday klip bulunamadı.</p>
)) ) : (
segment.candidates.map((c) => (
<div key={c.id} className="border-l-2 border-border pl-4">
<div className="flex items-center justify-between gap-2">
<span className="font-mono-num text-xs text-muted-foreground">
{c.startSec}s {c.endSec}s
{c.audioPeakScore != null && ` · peak ${c.audioPeakScore.toFixed(1)}dB`}
{c.chatVelocityScore != null && " · chat spike"}
</span>
<Badge variant={STATUS_BADGE[c.status] ?? "muted"}>{c.status}</Badge>
</div>
{transcriptPreview(c.transcriptJson) && (
<p className="mt-1.5 text-sm leading-relaxed">{transcriptPreview(c.transcriptJson)}</p>
)}
{c.short && (
<div className="mt-2 flex flex-col items-start gap-2">
<Badge variant={STATUS_BADGE[c.short.status] ?? "muted"}>9:16: {c.short.status}</Badge>
{c.short.status === "READY" && (
<>
<video controls preload="metadata" className="w-[220px] rounded-md border border-border">
<source src={`/api/shorts/${c.short.id}/video`} type="video/mp4" />
</video>
<Button asChild variant="outline" size="sm">
<a href={`/api/shorts/${c.short.id}/video?download=1`}>İndir</a>
</Button>
</>
)}
{c.short.status === "FAILED" && (
<>
{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
</Button>
</form>
</>
)}
{c.short.status === "RENDERING" && (
<form action={cancelRender.bind(null, c.id)}>
<ConfirmButton
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"
variant="outline"
/>
</form>
)}
</div>
)}
{!c.short && c.status === "TRANSCRIBED" && (
<form action={retryRender.bind(null, c.id)} className="mt-2">
<Button type="submit" size="sm">
9:16 Render Et
</Button>
</form>
)}
<form action={deleteCandidateSegment.bind(null, c.id)} className="mt-2">
<ConfirmButton confirmText="Bu aday klibi silmek istediğine emin misin?" label="Sil" />
</form>
</div>
))
)}
</CardContent>
</Card>
))}
</div>
)} )}
</> </div>
); );
} }
+88 -128
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>
{cookieStale && ( <CardContent className="flex flex-col gap-3">
<p style={{ marginBottom: "0.5rem" }}> {cookieStale && (
<span className="badge warn"> <Badge variant="warn" className="w-fit">
{Math.round(cookieAgeHours!)} saattir güncellenmedi büyük/popüler kanallarda bot-check {Math.round(cookieAgeHours!)} saattir güncellenmedi büyük/popüler kanallarda bot-check hatası
hatası görülebilir, taze bir cookies.txt ile güncelle görülebilir
</span> </Badge>
</p> )}
)} <CardDescription>
<p className="empty" style={{ marginBottom: "0.5rem" }}> YouTube canlı yayın kontrolü ve kayıt için kullanılıyor. Google, oturum çerezlerinin bir kısmını birkaç
YouTube canlı yayın kontrolü ve kayıt için kullanılıyor. Google, oturum çerezlerinin bir saatte bir yeniliyor burada eskidiğini fark edersen (kanal detay sayfasında &quot;son poll hatası&quot;
kısmını birkaç saatte bir yeniliyor burada eskidiğini fark edersen (kanal detay olarak görürsün) taze bir cookies.txt ile güncelle. Ana hesabın yerine ayrı, önemsiz bir Google hesabı
sayfasında &quot;son poll hatası&quot; olarak görürsün) taze bir cookies.txt ile kullanman önerilir.
güncelle. Ana hesabın yerine ayrı, önemsiz bir Google hesabı kullanman önerilir. </CardDescription>
</p> <form action={updateYtdlpCookies} className="flex flex-col gap-3">
<form action={updateYtdlpCookies}> <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} className="font-mono-num text-xs"
style={{ />
width: "100%", <Button type="submit" className="self-start">
background: "var(--bg)", Kaydet
border: "1px solid var(--border)", </Button>
borderRadius: "6px", </form>
padding: "0.6rem 0.7rem", </CardContent>
color: "var(--text)", </Card>
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
fontSize: "0.75rem",
resize: "vertical",
}}
/>
<button
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
</button>
</form>
</div>
<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>
</div> <CardDescription>Bir sonraki döngüden itibaren geçerli olur, redeploy gerekmez.</CardDescription>
<p className="empty" style={{ marginBottom: "0.5rem" }}> </CardHeader>
Bir sonraki döngüden itibaren geçerli olur, redeploy gerekmez. <CardContent>
</p> <form action={updateSystemSettings} className="flex flex-col gap-4">
<form action={updateSystemSettings} style={{ display: "flex", flexDirection: "column", gap: "0.6rem" }}> <div className="flex flex-col gap-1.5">
<label className="mono" style={{ fontSize: "0.8rem" }}> <Label htmlFor="pollIntervalSec">Poll aralığı (saniye)</Label>
Poll aralığı (saniye) <Input id="pollIntervalSec" name="pollIntervalSec" type="number" min={5} defaultValue={pollIntervalSec} className="max-w-40" />
<input name="pollIntervalSec" type="number" min={5} defaultValue={pollIntervalSec} style={{ display: "block", marginTop: "0.25rem" }} /> </div>
</label> <div className="flex flex-col gap-1.5">
<label className="mono" style={{ fontSize: "0.8rem" }}> <Label htmlFor="ttlHours">Ham segment TTL (saat)</Label>
Ham segment TTL (saat) <Input id="ttlHours" name="ttlHours" type="number" min={1} defaultValue={ttlHours} className="max-w-40" />
<input name="ttlHours" type="number" min={1} defaultValue={ttlHours} style={{ display: "block", marginTop: "0.25rem" }} /> </div>
</label> <p className="max-w-prose text-xs text-muted-foreground">
<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
Eşzamanlı capture limiti ve render eşzamanlılığı BullMQ worker&apos;ları başlatılırken bunları değiştirmek için Coolify&apos;deki <code className="font-mono-num">MAX_CONCURRENT_CAPTURES</code>{" "}
sabitleniyor bunları değiştirmek için Coolify&apos;deki `MAX_CONCURRENT_CAPTURES` / / <code className="font-mono-num">VIDEO_RENDER_CONCURRENCY</code> env var&apos;larını güncelleyip
`VIDEO_RENDER_CONCURRENCY` env var&apos;larını güncelleyip redeploy etmek gerekiyor. redeploy etmek gerekiyor.
</p> </p>
<button <Button type="submit" className="self-start">
type="submit" Kaydet
style={{ </Button>
alignSelf: "flex-start", </form>
background: "var(--accent)", </CardContent>
border: "none", </Card>
borderRadius: "6px",
padding: "0.5rem 1rem",
color: "#fff",
fontWeight: 600,
fontSize: "0.9rem",
cursor: "pointer",
}}
>
Kaydet
</button>
</form>
</div>
<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>
</div> </Card>
</> </div>
); );
} }
+28 -37
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
</p> küçük farklar olabilir.
</p>
<div className="card" style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))", gap: "1rem" }}>
<div>
<div className="mono" style={{ fontSize: "0.75rem", opacity: 0.7 }}>TOPLAM KAYIT SÜRESİ</div>
<div style={{ fontSize: "1.4rem", fontWeight: 600 }}>{fmt(captureHours)} sa</div>
</div>
<div>
<div className="mono" style={{ fontSize: "0.75rem", opacity: 0.7 }}>TOPLAM STT SÜRESİ</div>
<div style={{ fontSize: "1.4rem", fontWeight: 600 }}>{fmt(sttMinutes)} dk</div>
</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 className="grid grid-cols-2 gap-3 sm:grid-cols-3">
<StatCard label="Toplam Kayıt Süresi" value={`${fmt(captureHours)} sa`} />
<StatCard label="Toplam STT Süresi" value={`${fmt(sttMinutes)} dk`} />
<StatCard label="Tahmini Whisper Maliyeti" value={`$${fmt(sttCost, 2)}`} />
<StatCard label="Tahmini Disk Kullanımı" value={`${fmt(estimatedGb)} GB`} />
<StatCard label="Üretilen 9:16 Klip" value={String(readyShorts)} />
<StatCard label="Başarısız Render" value={String(failedShorts)} />
<StatCard label="Toplam Aday Klip" value={String(totalCandidates)} />
</div>
</div>
); );
} }
+63 -53
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)"
</form> required
minLength={8}
className="flex-1 min-w-[160px]"
/>
<Button type="submit">Kullanıcı Ekle</Button>
</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>
{users.map((u) => ( </TableHeader>
<tr key={u.id}> <TableBody>
<td> {users.map((u) => (
{u.username} <TableRow key={u.id}>
{u.id === session?.sub && <span className="badge muted" style={{ marginLeft: "0.5rem" }}>sen</span>} <TableCell>
</td> {u.username}
<td className="mono">{u.createdAt.toLocaleString("tr-TR")}</td> {u.id === session?.sub && (
<td> <Badge variant="muted" className="ml-2">
{u.id !== session?.sub && users.length > 1 && ( sen
<form action={deleteUser.bind(null, u.id)}> </Badge>
<DeleteButton confirmText={`${u.username} kullanıcısını silmek istediğine emin misin?`} /> )}
</form> </TableCell>
)} <TableCell className="font-mono-num text-xs text-muted-foreground">
</td> {u.createdAt.toLocaleString("tr-TR")}
</tr> </TableCell>
))} <TableCell className="text-right">
</tbody> {u.id !== session?.sub && users.length > 1 && (
</table> <form action={deleteUser.bind(null, u.id)}>
</> <ConfirmButton confirmText={`${u.username} kullanıcısını silmek istediğine emin misin?`} label="Sil" />
</form>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</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