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:
@@ -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="/">← 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'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'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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'leri</span>
|
<CardTitle className="text-sm font-medium text-muted-foreground">YouTube Cookie'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 "son poll hatası"
|
||||||
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 "son poll hatası" 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'ları başlatılırken sabitleniyor —
|
||||||
Eşzamanlı capture limiti ve render eşzamanlılığı BullMQ worker'ları başlatılırken
|
bunları değiştirmek için Coolify'deki <code className="font-mono-num">MAX_CONCURRENT_CAPTURES</code>{" "}
|
||||||
sabitleniyor — bunları değiştirmek için Coolify'deki `MAX_CONCURRENT_CAPTURES` /
|
/ <code className="font-mono-num">VIDEO_RENDER_CONCURRENCY</code> env var'larını güncelleyip
|
||||||
`VIDEO_RENDER_CONCURRENCY` env var'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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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,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",
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
Generated
+2080
-2
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user