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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user