3 seviyeli border-left girinti (oturum > segment > aday klip) yerine
gerçek kutu (soft-card) blokları — her segment kendi arka planıyla
ayrışıyor, adaylar onun içinde daha da net bir alt kutuda. Segment
başlığı artık ham dosya yolu değil ("15sn segment · 02.09.2026 20:14"
gibi) insan-okur bir etiket — dosya yolu küçük/soluk referans olarak
altta kaldı. "Canlı Durum" kartındaki karışık bölümler de (hata/canlı
önizleme/log/segment ayarı/manuel başlat) Separator ile ayrıştırıldı.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
330 lines
15 KiB
TypeScript
330 lines
15 KiB
TypeScript
import { notFound } from "next/navigation";
|
||
import Link from "next/link";
|
||
import { prisma } from "@streamclipper/db";
|
||
import { fetchChannelStatuses } from "../../../lib/apiDaemon";
|
||
import { formatTr } from "../../../lib/formatDate";
|
||
import {
|
||
deleteRawSegment,
|
||
deleteCandidateSegment,
|
||
deleteChannel,
|
||
stopSession,
|
||
forceStartCapture,
|
||
retryRender,
|
||
cancelRender,
|
||
updateChannelSegmentTime,
|
||
} from "../../actions";
|
||
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 { Label } from "@/components/ui/label";
|
||
import { Badge, type badgeVariants } from "@/components/ui/badge";
|
||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||
import { Separator } from "@/components/ui/separator";
|
||
import type { VariantProps } from "class-variance-authority";
|
||
|
||
export const dynamic = "force-dynamic";
|
||
|
||
const STATUS_BADGE: Record<string, VariantProps<typeof badgeVariants>["variant"]> = {
|
||
PENDING: "muted",
|
||
PROCESSED: "ok",
|
||
DISCARDED: "err",
|
||
PENDING_STT: "warn",
|
||
TRANSCRIBED: "ok",
|
||
RENDERING: "warn",
|
||
READY: "ok",
|
||
FAILED: "err",
|
||
};
|
||
|
||
function transcriptPreview(transcriptJson: unknown): string | null {
|
||
if (!transcriptJson || typeof transcriptJson !== "object") return null;
|
||
const text = (transcriptJson as { text?: string }).text;
|
||
return text ? text.slice(0, 240) : null;
|
||
}
|
||
|
||
export default async function ChannelDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||
const { id } = await params;
|
||
|
||
const [channel, statuses] = await Promise.all([
|
||
prisma.channel.findUnique({
|
||
where: { id },
|
||
include: {
|
||
sessions: {
|
||
orderBy: { startedAt: "desc" },
|
||
include: {
|
||
segments: {
|
||
orderBy: { createdAt: "desc" },
|
||
include: { candidates: { orderBy: { createdAt: "desc" }, include: { short: true } } },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
}),
|
||
fetchChannelStatuses(),
|
||
]);
|
||
|
||
if (!channel) notFound();
|
||
|
||
const status = statuses.get(channel.id);
|
||
const activeSession = channel.sessions.find((s) => s.endedAt === null);
|
||
|
||
return (
|
||
<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="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)}>
|
||
<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>
|
||
|
||
<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 && (
|
||
<>
|
||
{" · "}
|
||
<SessionTimer startedAt={activeSession.startedAt.toISOString()} />
|
||
</>
|
||
)}
|
||
</Badge>
|
||
{status?.recording && activeSession && (
|
||
<form action={stopSession.bind(null, activeSession.id)}>
|
||
<ConfirmButton confirmText="Bu kaydı şimdi durdurmak istediğine emin misin?" label="Durdur" />
|
||
</form>
|
||
)}
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent className="flex flex-col gap-4">
|
||
<p className="font-mono-num text-xs text-muted-foreground">
|
||
Aktif: {channel.isActive ? "evet" : "hayır"} · Son kontrol:{" "}
|
||
{channel.lastCheckedAt ? formatTr(channel.lastCheckedAt) : "—"}
|
||
</p>
|
||
|
||
{status === undefined && (
|
||
<p className="text-sm text-muted-foreground">
|
||
api-daemon'dan canlı durum alınamadı (servis erişilemez olabilir).
|
||
</p>
|
||
)}
|
||
|
||
{status?.lastPollError && (
|
||
<div className="flex flex-col gap-1.5 rounded-lg border border-err/30 bg-err/5 p-3">
|
||
<div className="flex items-center gap-2">
|
||
<Badge variant="err" className="w-fit">
|
||
son poll hatası
|
||
</Badge>
|
||
<span className="font-mono-num text-xs text-muted-foreground">
|
||
{formatTr(status.lastPollError.at)}
|
||
</span>
|
||
</div>
|
||
<pre className="font-mono-num whitespace-pre-wrap text-[0.7rem] text-muted-foreground">
|
||
{status.lastPollError.message}
|
||
</pre>
|
||
</div>
|
||
)}
|
||
|
||
{status?.recording && (
|
||
<div className="flex flex-col gap-3">
|
||
{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"
|
||
/>
|
||
)}
|
||
{activeSession && <LiveLogViewer sessionId={activeSession.id} />}
|
||
</div>
|
||
)}
|
||
|
||
<Separator />
|
||
|
||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||
<form
|
||
action={updateChannelSegmentTime.bind(null, channel.id)}
|
||
className="flex flex-wrap items-end gap-2"
|
||
>
|
||
<div className="flex flex-col gap-1.5">
|
||
<Label htmlFor="segmentTimeMin" className="text-xs text-muted-foreground">
|
||
Bu kanal için segment süresi (dakika)
|
||
</Label>
|
||
<Input
|
||
id="segmentTimeMin"
|
||
name="segmentTimeMin"
|
||
type="number"
|
||
min={1}
|
||
max={60}
|
||
step={0.5}
|
||
placeholder="varsayılan"
|
||
defaultValue={channel.segmentTimeSec ? channel.segmentTimeSec / 60 : ""}
|
||
className="w-32"
|
||
/>
|
||
</div>
|
||
<Button type="submit" variant="outline" size="sm">
|
||
Kaydet
|
||
</Button>
|
||
</form>
|
||
|
||
<details className="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="w-64" />
|
||
<Button type="submit">Başlat</Button>
|
||
</form>
|
||
</details>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<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">
|
||
{formatTr(session.startedAt)}
|
||
{session.endedAt ? ` – ${formatTr(session.endedAt)}` : " (devam ediyor)"}
|
||
</span>
|
||
<Badge variant="muted">{session.totalSegments} segment</Badge>
|
||
</CardHeader>
|
||
|
||
{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="rounded-lg border border-border bg-muted/20 p-4">
|
||
<div className="flex items-center justify-between gap-2">
|
||
<span className="text-sm font-medium">
|
||
{segment.duration}sn segment · {formatTr(segment.startedAt)}
|
||
</span>
|
||
<Badge variant={STATUS_BADGE[segment.status] ?? "muted"}>{segment.status}</Badge>
|
||
</div>
|
||
<span className="font-mono-num text-[0.7rem] text-muted-foreground">{segment.filePath}</span>
|
||
|
||
<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.length === 0 && segment.status !== "PENDING" && (
|
||
<p className="mt-3 text-xs text-muted-foreground">
|
||
Bu segmentte aday klip bulunamadı (sinyal analizi ilginç bir an tespit etmedi).
|
||
</p>
|
||
)}
|
||
|
||
{segment.candidates.length > 0 && (
|
||
<div className="mt-3 flex flex-col gap-2">
|
||
{segment.candidates.map((c) => (
|
||
<div key={c.id} className="rounded-md border border-border bg-background/60 p-3">
|
||
<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>
|
||
)}
|
||
</div>
|
||
))}
|
||
</CardContent>
|
||
)}
|
||
</Card>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|