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