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,
cancelRender,
} from "../../actions";
import { DeleteButton } from "../../components/DeleteButton";
import { ConfirmButton } from "../../components/ConfirmButton";
import { LiveLogViewer } from "../../components/LiveLogViewer";
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";
const STATUS_BADGE: Record<string, string> = {
const STATUS_BADGE: Record<string, VariantProps<typeof badgeVariants>["variant"]> = {
PENDING: "muted",
PROCESSED: "ok",
DISCARDED: "err",
@@ -62,22 +66,33 @@ export default async function ChannelDetailPage({ params }: { params: Promise<{
const activeSession = channel.sessions.find((s) => s.endedAt === null);
return (
<>
<p><Link href="/">&larr; Kanal Durumu</Link></p>
<div className="flex flex-col gap-6">
<div>
<Link href="/" className="text-sm text-muted-foreground hover:text-foreground">
Kanal Durumu
</Link>
</div>
<div className="card-head">
<h1>{channel.name}</h1>
<div className="flex items-center justify-between">
<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)}>
<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>
</div>
<p className="mono">{channel.youtubeHandle} · {channel.channelId}</p>
<div className="card">
<div className="card-head">
<span>Canlı Durum</span>
<span style={{ display: "flex", gap: "0.4rem", alignItems: "center" }}>
<span className={`badge ${status?.recording ? "warn" : "muted"}`}>
<Card>
<CardHeader className="flex-row items-center justify-between">
<CardTitle className="text-sm font-medium text-muted-foreground">Canlı Durum</CardTitle>
<div className="flex items-center gap-2">
<Badge variant={status?.recording ? "live" : "muted"}>
{status?.recording ? "🔴 Kayıtta" : "Kayıtta değil"}
{status?.recording && activeSession && (
<>
@@ -85,157 +100,176 @@ export default async function ChannelDetailPage({ params }: { params: Promise<{
<SessionTimer startedAt={activeSession.startedAt.toISOString()} />
</>
)}
</span>
</Badge>
{status?.recording && activeSession && (
<form action={stopSession.bind(null, activeSession.id)}>
<ConfirmButton confirmText="Bu kaydı şimdi durdurmak istediğine emin misin?" label="Durdur" />
</form>
)}
</span>
</div>
<div className="mono">
Aktif: {channel.isActive ? "evet" : "hayır"} ·{" "}
Son kontrol: {channel.lastCheckedAt ? new Date(channel.lastCheckedAt).toLocaleString("tr-TR") : ""}
</div>
{status === undefined && (
<p className="empty" style={{ marginTop: "0.5rem" }}>
api-daemon&apos;dan canlı durum alınamadı (servis erişilemez olabilir).
</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?.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" }}>
<summary className="mono" style={{ cursor: "pointer", fontSize: "0.8rem" }}>
Manuel URL ile kayıt başlat
</summary>
<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>
{status === undefined && (
<p className="text-sm text-muted-foreground">
api-daemon&apos;dan canlı durum alınamadı (servis erişilemez olabilir).
</p>
)}
<h1 style={{ marginTop: "2rem" }}>Yayın Geçmişi</h1>
{channel.sessions.length === 0 ? (
<p className="empty">Bu kanal için henüz bir kayıt oturumu yok.</p>
) : (
channel.sessions.map((session) => (
<div className="card" key={session.id}>
<div className="card-head">
<span className="mono">
{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>
{status?.lastPollError && (
<div className="flex flex-col gap-1.5">
<Badge variant="err" className="w-fit">
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}
</pre>
</div>
)}
{session.segments.length === 0 ? (
<p className="empty" style={{ marginTop: "0.5rem" }}>Henüz segment yok.</p>
) : (
session.segments.map((segment) => (
<div key={segment.id} style={{ marginTop: "0.75rem", paddingLeft: "0.75rem", borderLeft: "2px solid var(--border)" }}>
<div className="card-head">
<span className="mono">{segment.filePath}</span>
<span className={`badge ${STATUS_BADGE[segment.status] ?? "muted"}`}>{segment.status}</span>
</div>
{status?.recording && activeSession?.liveVideoId && activeSession.liveVideoId !== "forced" && (
<iframe
src={`https://www.youtube.com/embed/${activeSession.liveVideoId}`}
title="Canlı yayın önizleme"
className="aspect-video w-full max-w-md rounded-md border border-border"
allow="autoplay; encrypted-media"
/>
)}
<video controls preload="metadata" style={{ width: "100%", maxWidth: "480px", marginTop: "0.5rem", borderRadius: "6px" }}>
<source src={`/api/segments/${segment.id}/video`} type="video/mp4" />
</video>
{status?.recording && activeSession && <LiveLogViewer sessionId={activeSession.id} />}
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center", marginTop: "0.5rem" }}>
<a className="badge muted" href={`/api/segments/${segment.id}/video?download=1`}>İndir</a>
<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." />
</form>
</div>
<details className="mt-1 text-sm">
<summary className="cursor-pointer font-mono-num text-xs text-muted-foreground">
Manuel URL ile kayıt başlat
</summary>
<form action={forceStartCapture.bind(null, channel.id)} className="mt-2 flex gap-2">
<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 key={c.id} style={{ marginTop: "0.5rem", 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`}
</span>
<span className={`badge ${STATUS_BADGE[c.status] ?? "muted"}`}>{c.status}</span>
</div>
{transcriptPreview(c.transcriptJson) && (
<p className="transcript-preview">{transcriptPreview(c.transcriptJson)}</p>
)}
<div>
<h2 className="mb-3 text-lg font-semibold tracking-tight">Yayın Geçmişi</h2>
{channel.sessions.length === 0 ? (
<p className="text-sm text-muted-foreground">Bu kanal için henüz bir kayıt oturumu yok.</p>
) : (
<div className="flex flex-col gap-4">
{channel.sessions.map((session) => (
<Card key={session.id}>
<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")}
{session.endedAt ? ` ${new Date(session.endedAt).toLocaleString("tr-TR")}` : " (devam ediyor)"}
</span>
<Badge variant="muted">{session.totalSegments} segment</Badge>
</CardHeader>
{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>
)}
{session.segments.length === 0 ? (
<CardContent>
<p className="text-sm text-muted-foreground">Henüz segment yok.</p>
</CardContent>
) : (
<CardContent className="flex flex-col gap-4">
{session.segments.map((segment) => (
<div key={segment.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">{segment.filePath}</span>
<Badge variant={STATUS_BADGE[segment.status] ?? "muted"}>{segment.status}</Badge>
</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>
</div>
))}
</div>
))
)}
<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" />
</video>
<div className="mt-2 flex items-center gap-2">
<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>
);
}
+45 -12
View File
@@ -1,24 +1,57 @@
"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({
confirmText,
label,
variant = "err",
triggerLabel,
variant = "destructive",
}: {
confirmText: string;
label: string;
variant?: "err" | "warn" | "muted" | "ok";
triggerLabel?: string;
variant?: VariantProps<typeof buttonVariants>["variant"];
}) {
const triggerRef = useRef<HTMLButtonElement>(null);
return (
<button
type="submit"
className={`badge ${variant}`}
style={{ border: "none", cursor: "pointer" }}
onClick={(e) => {
if (!confirm(confirmText)) e.preventDefault();
}}
>
{label}
</button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button ref={triggerRef} type="button" variant={variant} size="sm">
{triggerLabel ?? label}
</Button>
</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}
</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 (
<pre
ref={boxRef}
className="mono"
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",
}}
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"
>
{lines.length > 0 ? lines.join("\n") : "Log bekleniyor…"}
</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 <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 {
color-scheme: light dark;
--bg: #0b0d12;
--panel: #141822;
--border: #262c3a;
--text: #e6e9ef;
--muted: #8b93a7;
--accent: #4f8cff;
--radius: 0.625rem;
--background: #0a0e15;
--foreground: #e8ecf2;
--card: #121722;
--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;
--warn: #f2b84b;
--err: #ef5b5b;
--live: #ff5c5c;
}
* {
box-sizing: border-box;
@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);
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
font-family: var(--font-sans), ui-sans-serif, system-ui, sans-serif;
}
}
.nav {
display: flex;
gap: 1.5rem;
padding: 1rem 2rem;
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;
@layer utilities {
.font-mono-num {
font-family: var(--font-mono), ui-monospace, SFMono-Regular, Menlo, monospace;
font-variant-numeric: tabular-nums;
}
}
+30 -16
View File
@@ -1,8 +1,22 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Hanken_Grotesk, JetBrains_Mono } from "next/font/google";
import "./globals.css";
import { getSession } from "../lib/auth";
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 = {
title: "StreamClipper AI — Panel",
@@ -12,29 +26,29 @@ export default async function RootLayout({ children }: { children: React.ReactNo
const session = await getSession();
return (
<html lang="tr">
<html lang="tr" className={`${hankenGrotesk.variable} ${jetbrainsMono.variable}`}>
<body>
{session && (
<nav className="nav">
<Link href="/">Kanal Durumu</Link>
<Link href="/segments">Segment & Aday Kütüphanesi</Link>
<Link href="/stats">İstatistik</Link>
<Link href="/settings">Ayarlar</Link>
<Link href="/users">Kullanıcılar</Link>
<span style={{ marginLeft: "auto", display: "flex", gap: "1rem", alignItems: "center" }}>
<span className="mono">{session.username}</span>
<nav className="flex items-center gap-6 border-b border-border px-6 py-4">
<span className="mr-2 text-sm font-semibold tracking-tight text-foreground">
StreamClipper<span className="text-primary">.</span>
</span>
<NavLink href="/">Kanal Durumu</NavLink>
<NavLink href="/segments">Segment & Aday Kütüphanesi</NavLink>
<NavLink href="/stats">İstatistik</NavLink>
<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}>
<button
type="submit"
style={{ background: "none", border: "none", color: "var(--muted)", cursor: "pointer", font: "inherit" }}
>
<Button type="submit" variant="ghost" size="sm" className="text-muted-foreground">
Çıkış Yap
</button>
</Button>
</form>
</span>
</nav>
)}
<main>{children}</main>
<main className="mx-auto max-w-4xl px-6 py-10">{children}</main>
</body>
</html>
);
+21 -58
View File
@@ -1,71 +1,34 @@
import { prisma } from "@streamclipper/db";
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";
const fieldStyle = {
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 }>;
}) {
export default async function LoginPage({ searchParams }: { searchParams: Promise<{ error?: string }> }) {
const { error } = await searchParams;
const userCount = await prisma.user.count();
const isBootstrap = userCount === 0;
return (
<div style={{ maxWidth: "360px", margin: "4rem auto" }}>
<h1>{isBootstrap ? "İlk Yönetici Hesabını Oluştur" : "Giriş Yap"}</h1>
{isBootstrap && (
<p className="empty" style={{ marginBottom: "1rem" }}>
Henüz kullanıcı yok panele erişecek ilk admin hesabını burada oluştur.
</p>
)}
{!isBootstrap && error && (
<p style={{ color: "var(--err)", fontSize: "0.9rem", marginBottom: "1rem" }}>
Kullanıcı adı veya şifre hatalı.
</p>
)}
<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>
<div className="flex min-h-[60vh] items-center justify-center">
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle className="text-xl">{isBootstrap ? "İlk Yönetici Hesabını Oluştur" : "Giriş Yap"}</CardTitle>
{isBootstrap && (
<CardDescription>Henüz kullanıcı yok panele erişecek ilk admin hesabını burada oluştur.</CardDescription>
)}
</CardHeader>
<CardContent>
{!isBootstrap && error && <p className="mb-3 text-sm text-destructive">Kullanıcı adı veya şifre hatalı.</p>}
<form action={isBootstrap ? bootstrapAdmin : login} className="flex flex-col gap-3">
<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>
</CardContent>
</Card>
</div>
);
}
+94 -79
View File
@@ -2,6 +2,11 @@ import Link from "next/link";
import { prisma } from "@streamclipper/db";
import { addChannel, toggleChannelActive, toggleAllChannels, checkChannelNow } from "./actions";
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";
@@ -21,89 +26,99 @@ export default async function DashboardPage() {
]);
return (
<>
<h1>Kanal Durumu</h1>
<div className="flex flex-col gap-6">
<h1 className="text-2xl font-semibold tracking-tight">Kanal Durumu</h1>
<form action={addChannel} className="card add-channel-form">
<input name="name" placeholder="Kanal adı" required />
<input name="youtubeHandle" placeholder="@handle" required />
<input name="channelId" placeholder="channel_id (UC...)" required />
<button type="submit">Kanal Ekle</button>
</form>
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium text-muted-foreground">Yeni Kanal Ekle</CardTitle>
</CardHeader>
<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>
</CardContent>
</Card>
{channels.length === 0 ? (
<p className="empty">Henüz kanal eklenmedi.</p>
<p className="text-sm text-muted-foreground">Henüz kanal eklenmedi.</p>
) : (
<>
<div style={{ display: "flex", gap: "0.5rem", marginBottom: "0.6rem" }}>
<form action={toggleAllChannels.bind(null, true)}>
<button type="submit" className="badge ok" style={{ border: "none", cursor: "pointer" }}>
Tümünü Devam Ettir
</button>
</form>
<form action={toggleAllChannels.bind(null, false)}>
<button type="submit" className="badge muted" style={{ border: "none", cursor: "pointer" }}>
Tümünü Duraklat
</button>
</form>
</div>
<table>
<thead>
<tr>
<th>Kanal</th>
<th>Durum</th>
<th>Kayıt</th>
<th>Son Kontrol</th>
<th></th>
</tr>
</thead>
<tbody>
{channels.map((c) => {
const recording = c.sessions.length > 0;
const status = statuses.get(c.id);
return (
<tr key={c.id}>
<td>
<Link href={`/channels/${c.id}`}>{c.name}</Link>{" "}
<span className="mono">{c.youtubeHandle}</span>
</td>
<td>
<span className={`badge ${c.isActive ? "ok" : "muted"}`}>
{c.isActive ? "Aktif" : "Pasif"}
</span>
</td>
<td>
<span className={`badge ${recording ? "warn" : "muted"}`}>
{recording ? "🔴 Kayıtta" : "—"}
</span>
{status?.lastPollError && (
<span className="badge err" style={{ marginLeft: "0.4rem" }}>
hata
</span>
)}
</td>
<td className="mono">
{c.lastCheckedAt ? new Date(c.lastCheckedAt).toLocaleString("tr-TR") : "—"}
</td>
<td style={{ display: "flex", gap: "0.35rem" }}>
<form action={toggleChannelActive.bind(null, c.id, !c.isActive)}>
<button type="submit" className="badge muted" style={{ border: "none", cursor: "pointer" }}>
{c.isActive ? "Duraklat" : "Devam Ettir"}
</button>
</form>
<form action={checkChannelNow.bind(null, c.id)}>
<button type="submit" className="badge muted" style={{ border: "none", cursor: "pointer" }}>
Şimdi Kontrol Et
</button>
</form>
</td>
</tr>
);
})}
</tbody>
</table>
</>
<Card>
<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)}>
<Button type="submit" variant="outline" size="sm">
Tümünü Devam Ettir
</Button>
</form>
<form action={toggleAllChannels.bind(null, false)}>
<Button type="submit" variant="outline" size="sm">
Tümünü Duraklat
</Button>
</form>
</div>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Kanal</TableHead>
<TableHead>Durum</TableHead>
<TableHead>Kayıt</TableHead>
<TableHead>Son Kontrol</TableHead>
<TableHead className="text-right">Aksiyon</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{channels.map((c) => {
const recording = c.sessions.length > 0;
const status = statuses.get(c.id);
return (
<TableRow key={c.id}>
<TableCell>
<Link href={`/channels/${c.id}`} className="font-medium hover:text-primary hover:underline">
{c.name}
</Link>{" "}
<span className="font-mono-num text-xs text-muted-foreground">{c.youtubeHandle}</span>
</TableCell>
<TableCell>
<Badge variant={c.isActive ? "ok" : "muted"}>{c.isActive ? "Aktif" : "Pasif"}</Badge>
</TableCell>
<TableCell>
<div className="flex items-center gap-1.5">
<Badge variant={recording ? "live" : "muted"}>{recording ? "🔴 Kayıtta" : "—"}</Badge>
{status?.lastPollError && <Badge variant="err">hata</Badge>}
</div>
</TableCell>
<TableCell className="font-mono-num text-xs text-muted-foreground">
{c.lastCheckedAt ? new Date(c.lastCheckedAt).toLocaleString("tr-TR") : "—"}
</TableCell>
<TableCell>
<div className="flex justify-end gap-1.5">
<form action={toggleChannelActive.bind(null, c.id, !c.isActive)}>
<Button type="submit" variant="outline" size="sm">
{c.isActive ? "Duraklat" : "Devam Ettir"}
</Button>
</form>
<form action={checkChannelNow.bind(null, c.id)}>
<Button type="submit" variant="ghost" size="sm">
Şimdi Kontrol Et
</Button>
</form>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
)}
</>
</div>
);
}
+107 -95
View File
@@ -1,11 +1,14 @@
import { prisma } from "@streamclipper/db";
import { deleteRawSegment, deleteCandidateSegment, retryRender, cancelRender } from "../actions";
import { DeleteButton } from "../components/DeleteButton";
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";
const STATUS_BADGE: Record<string, string> = {
const STATUS_BADGE: Record<string, VariantProps<typeof badgeVariants>["variant"]> = {
PENDING: "muted",
PROCESSED: "ok",
DISCARDED: "err",
@@ -30,104 +33,113 @@ export default async function SegmentsPage() {
});
return (
<>
<h1>Segment & Aday Klip Kütüphanesi</h1>
<div className="flex flex-col gap-6">
<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>
<p className="text-sm text-muted-foreground">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>
<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" />
</video>
<video controls preload="metadata" style={{ width: "100%", maxWidth: "480px", marginTop: "0.5rem", borderRadius: "6px" }}>
<source src={`/api/segments/${segment.id}/video`} type="video/mp4" />
</video>
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center", marginTop: "0.5rem" }}>
<a className="badge muted" href={`/api/segments/${segment.id}/video?download=1`}>İndir</a>
<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." />
</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?" />
<div className="flex items-center gap-2">
<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>
))
)}
</div>
))
{segment.candidates.length === 0 ? (
<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 { 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";
@@ -22,139 +29,92 @@ export default async function SettingsPage() {
const notifyRenderDone = notifyRender?.value !== "false";
return (
<>
<h1>Ayarlar</h1>
<div className="flex flex-col gap-6">
<h1 className="text-2xl font-semibold tracking-tight">Ayarlar</h1>
<div className="card">
<div className="card-head">
<span>YouTube Cookie&apos;leri</span>
<span className="mono">
<Card>
<CardHeader className="flex-row items-center justify-between">
<CardTitle className="text-sm font-medium text-muted-foreground">YouTube Cookie&apos;leri</CardTitle>
<span className="font-mono-num text-xs text-muted-foreground">
{setting ? `son güncelleme: ${setting.updatedAt.toLocaleString("tr-TR")}` : "hiç ayarlanmadı"}
</span>
</div>
{cookieStale && (
<p style={{ marginBottom: "0.5rem" }}>
<span className="badge warn">
{Math.round(cookieAgeHours!)} saattir güncellenmedi büyük/popüler kanallarda bot-check
hatası görülebilir, taze bir cookies.txt ile güncelle
</span>
</p>
)}
<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ç saatte bir yeniliyor burada eskidiğini fark edersen (kanal detay
sayfasında &quot;son poll hatası&quot; olarak görürsün) taze bir cookies.txt ile
güncelle. Ana hesabın yerine ayrı, önemsiz bir Google hesabı kullanman önerilir.
</p>
<form action={updateYtdlpCookies}>
<textarea
name="cookies"
required
placeholder="Netscape formatlı cookies.txt içeriğini buraya yapıştır"
rows={10}
style={{
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
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>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{cookieStale && (
<Badge variant="warn" className="w-fit">
{Math.round(cookieAgeHours!)} saattir güncellenmedi büyük/popüler kanallarda bot-check hatası
görülebilir
</Badge>
)}
<CardDescription>
YouTube canlı yayın kontrolü ve kayıt için kullanılıyor. Google, oturum çerezlerinin bir kısmını birkaç
saatte bir yeniliyor burada eskidiğini fark edersen (kanal detay sayfasında &quot;son poll hatası&quot;
olarak görürsün) taze bir cookies.txt ile güncelle. Ana hesabın yerine ayrı, önemsiz bir Google hesabı
kullanman önerilir.
</CardDescription>
<form action={updateYtdlpCookies} className="flex flex-col gap-3">
<Textarea
name="cookies"
required
placeholder="Netscape formatlı cookies.txt içeriğini buraya yapıştır"
rows={10}
className="font-mono-num text-xs"
/>
<Button type="submit" className="self-start">
Kaydet
</Button>
</form>
</CardContent>
</Card>
<div className="card">
<div className="card-head">
<span>Sistem Parametreleri</span>
</div>
<p className="empty" style={{ marginBottom: "0.5rem" }}>
Bir sonraki döngüden itibaren geçerli olur, redeploy gerekmez.
</p>
<form action={updateSystemSettings} style={{ display: "flex", flexDirection: "column", gap: "0.6rem" }}>
<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
</button>
</form>
</div>
<Card>
<CardHeader>
<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 className="flex flex-col gap-1.5">
<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>
<Button type="submit" className="self-start">
Kaydet
</Button>
</form>
</CardContent>
</Card>
<div className="card">
<div className="card-head">
<span>Bildirimler</span>
</div>
<form action={updateNotificationPrefs} style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
<label className="mono" style={{ fontSize: "0.85rem", display: "flex", alignItems: "center", gap: "0.4rem" }}>
<input type="checkbox" name="notifyStreamStart" defaultChecked={notifyStreamStart} />
Yayın başladığında Telegram bildirimi
</label>
<label className="mono" style={{ fontSize: "0.85rem", display: "flex", alignItems: "center", gap: "0.4rem" }}>
<input type="checkbox" name="notifyRenderDone" defaultChecked={notifyRenderDone} />
9:16 render tamamlandığında Telegram bildirimi
</label>
<button
type="submit"
style={{
alignSelf: "flex-start",
marginTop: "0.3rem",
background: "var(--accent)",
border: "none",
borderRadius: "6px",
padding: "0.5rem 1rem",
color: "#fff",
fontWeight: 600,
fontSize: "0.9rem",
cursor: "pointer",
}}
>
Kaydet
</button>
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium text-muted-foreground">Bildirimler</CardTitle>
</CardHeader>
<form action={updateNotificationPrefs}>
<CardContent className="flex flex-col gap-4">
<Label className="flex items-center justify-between gap-4 text-sm font-normal">
Yayın başladığında Telegram bildirimi
<Switch name="notifyStreamStart" defaultChecked={notifyStreamStart} />
</Label>
<Label className="flex items-center justify-between gap-4 text-sm font-normal">
9:16 render tamamlandığında Telegram bildirimi
<Switch name="notifyRenderDone" defaultChecked={notifyRenderDone} />
</Label>
</CardContent>
<CardFooter>
<Button type="submit">Kaydet</Button>
</CardFooter>
</form>
</div>
</>
</Card>
</div>
);
}
+28 -37
View File
@@ -1,4 +1,5 @@
import { prisma } from "@streamclipper/db";
import { Card } from "@/components/ui/card";
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 });
}
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() {
const [transcribed, durationSum, readyShorts, failedShorts, totalCandidates] = await Promise.all([
prisma.candidateSegment.findMany({
@@ -30,43 +40,24 @@ export default async function StatsPage() {
const estimatedGb = captureHours * GB_PER_HOUR_ESTIMATE;
return (
<>
<h1>Kullanım & Maliyet</h1>
<p className="empty" style={{ marginBottom: "1rem" }}>
Şimdiye kadarki toplam kullanım tüm zamanlar. STT maliyeti ve disk kullanımı tahmini
(bkz. maliyet analizi), gerçek faturayla 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 className="flex flex-col gap-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Kullanım & Maliyet</h1>
<p className="mt-1 text-sm text-muted-foreground">
Şimdiye kadarki toplam kullanım tüm zamanlar. STT maliyeti ve disk kullanımı tahmini, gerçek faturayla
küçük farklar olabilir.
</p>
</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 { getSession } from "../../lib/auth";
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";
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() {
const [users, session] = await Promise.all([
prisma.user.findMany({ orderBy: { createdAt: "asc" } }),
getSession(),
]);
const [users, session] = await Promise.all([prisma.user.findMany({ orderBy: { createdAt: "asc" } }), getSession()]);
return (
<>
<h1>Kullanıcılar</h1>
<div className="flex flex-col gap-6">
<h1 className="text-2xl font-semibold tracking-tight">Kullanıcılar</h1>
<form
action={addUser}
className="card add-channel-form"
style={{ marginBottom: "1.5rem" }}
>
<input name="username" placeholder="Kullanıcı adı" required style={fieldStyle} />
<input name="password" type="password" placeholder="Şifre (en az 8 karakter)" required minLength={8} style={fieldStyle} />
<button type="submit">Kullanıcı Ekle</button>
</form>
<Card>
<CardContent>
<form action={addUser} className="flex flex-wrap gap-2">
<Input name="username" placeholder="Kullanıcı adı" required className="flex-1 min-w-[140px]" />
<Input
name="password"
type="password"
placeholder="Şifre (en az 8 karakter)"
required
minLength={8}
className="flex-1 min-w-[160px]"
/>
<Button type="submit">Kullanıcı Ekle</Button>
</form>
</CardContent>
</Card>
<table>
<thead>
<tr>
<th>Kullanıcı Adı</th>
<th>Oluşturulma</th>
<th></th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id}>
<td>
{u.username}
{u.id === session?.sub && <span className="badge muted" style={{ marginLeft: "0.5rem" }}>sen</span>}
</td>
<td className="mono">{u.createdAt.toLocaleString("tr-TR")}</td>
<td>
{u.id !== session?.sub && users.length > 1 && (
<form action={deleteUser.bind(null, u.id)}>
<DeleteButton confirmText={`${u.username} kullanıcısını silmek istediğine emin misin?`} />
</form>
)}
</td>
</tr>
))}
</tbody>
</table>
</>
<Card>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Kullanıcı Adı</TableHead>
<TableHead>Oluşturulma</TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((u) => (
<TableRow key={u.id}>
<TableCell>
{u.username}
{u.id === session?.sub && (
<Badge variant="muted" className="ml-2">
sen
</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 && (
<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": {
"@streamclipper/db": "workspace:*",
"@tailwindcss/postcss": "^4.3.3",
"bcryptjs": "^2.4.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"jose": "^5.9.6",
"lucide-react": "^1.38.0",
"next": "^15.1.0",
"radix-ui": "^1.6.7",
"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": {
"@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