This commit is contained in:
2026-05-15 23:06:44 +03:30
parent 41789ec221
commit 97485b7a67
22 changed files with 1609 additions and 665 deletions

View File

@@ -1,48 +1,3 @@
# Codex Agent Handoff
This repo is a Laravel/Inertia React live streaming platform called Nyone.
For updating UI/UX, start by reading:
- `docs/ui-ux-handoff.md`
- `resources/js/pages/welcome.tsx`
- `resources/js/pages/dashboard.tsx`
- `resources/js/pages/channels/show.tsx`
- `resources/js/pages/admin/dashboard.tsx`
- `resources/js/components/app-header.tsx`
- `resources/js/components/app-sidebar.tsx`
## Project Shape
- Backend: Laravel 13, Fortify auth, Inertia, PostgreSQL intended for production, SQLite currently used in local `.env`.
- Frontend: React 19, TypeScript, Tailwind CSS 4, Radix UI primitives, lucide-react icons, hls.js.
- Streaming: MediaMTX receives OBS RTMP, validates publish requests via Laravel, serves HLS, and calls lifecycle hooks.
- Key user flows: public live directory, creator studio, channel watch page with chat/follow, basic admin moderation.
## Design Work Rules
- Focus on UI/UX in `resources/js` and `resources/css/app.css`.
- Keep the existing Laravel routes/controllers/models unless a UI change requires a small prop shape addition.
- Use existing UI primitives from `resources/js/components/ui`.
- Use lucide icons for controls.
- Avoid decorative landing-page marketing. The first screen should remain the live product experience.
- Keep operational/tooling files intact unless explicitly asked: `deploy/`, `routes/`, migrations, MediaMTX integration.
- Generated Wayfinder files live in `resources/js/actions`, `resources/js/routes`, and `resources/js/wayfinder`; do not hand-edit them.
## Verify Before Handing Back
Use the available Herd/Node binaries on this machine:
```powershell
& 'C:\Users\meghdad\.config\herd\bin\php84\php.exe' artisan test
& 'C:\Users\meghdad\.config\herd\bin\nvm\v25.2.1\npm.cmd' run format:check
& 'C:\Users\meghdad\.config\herd\bin\nvm\v25.2.1\npm.cmd' run lint:check
& 'C:\Users\meghdad\.config\herd\bin\nvm\v25.2.1\npm.cmd' run types:check
& 'C:\Users\meghdad\.config\herd\bin\nvm\v25.2.1\npm.cmd' run build
```
===
<laravel-boost-guidelines> <laravel-boost-guidelines>
=== foundation rules === === foundation rules ===

View File

@@ -6,6 +6,7 @@ use App\Models\Broadcast;
use App\Models\Channel; use App\Models\Channel;
use App\Models\User; use App\Models\User;
use App\Models\Vod; use App\Models\Vod;
use Illuminate\Support\Facades\Storage;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
@@ -34,6 +35,7 @@ class AdminDashboardController extends Controller
'id' => $broadcast->channel->id, 'id' => $broadcast->channel->id,
'slug' => $broadcast->channel->slug, 'slug' => $broadcast->channel->slug,
'display_name' => $broadcast->channel->display_name, 'display_name' => $broadcast->channel->display_name,
'thumbnail_url' => $this->mediaUrl($broadcast->channel->thumbnail_path),
'owner' => $broadcast->channel->user->name, 'owner' => $broadcast->channel->user->name,
], ],
]), ]),
@@ -46,6 +48,8 @@ class AdminDashboardController extends Controller
'id' => $channel->id, 'id' => $channel->id,
'slug' => $channel->slug, 'slug' => $channel->slug,
'display_name' => $channel->display_name, 'display_name' => $channel->display_name,
'avatar_url' => $this->mediaUrl($channel->avatar_path),
'thumbnail_url' => $this->mediaUrl($channel->thumbnail_path),
'is_live' => $channel->is_live, 'is_live' => $channel->is_live,
'suspended_at' => $channel->suspended_at?->toIso8601String(), 'suspended_at' => $channel->suspended_at?->toIso8601String(),
'owner' => [ 'owner' => [
@@ -55,4 +59,9 @@ class AdminDashboardController extends Controller
]), ]),
]); ]);
} }
private function mediaUrl(?string $path): ?string
{
return $path ? Storage::url($path) : null;
}
} }

View File

@@ -6,6 +6,7 @@ use App\Models\Channel;
use App\Models\ChatMessage; use App\Models\ChatMessage;
use App\Models\Vod; use App\Models\Vod;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
@@ -26,6 +27,9 @@ class ChannelController extends Controller
'slug' => $channel->slug, 'slug' => $channel->slug,
'display_name' => $channel->display_name, 'display_name' => $channel->display_name,
'description' => $channel->description, 'description' => $channel->description,
'avatar_url' => $this->mediaUrl($channel->avatar_path),
'banner_url' => $this->mediaUrl($channel->banner_path),
'thumbnail_url' => $this->mediaUrl($channel->thumbnail_path),
'is_live' => $channel->is_live, 'is_live' => $channel->is_live,
'viewer_count' => $channel->viewer_count, 'viewer_count' => $channel->viewer_count,
'followers_count' => $channel->follows_count, 'followers_count' => $channel->follows_count,
@@ -84,4 +88,9 @@ class ChannelController extends Controller
: false, : false,
]); ]);
} }
private function mediaUrl(?string $path): ?string
{
return $path ? Storage::url($path) : null;
}
} }

View File

@@ -8,6 +8,7 @@ use App\Models\Channel;
use App\Services\Streaming\StreamKeyManager; use App\Services\Streaming\StreamKeyManager;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
use Inertia\Inertia; use Inertia\Inertia;
@@ -165,6 +166,9 @@ class CreatorDashboardController extends Controller
'slug' => $channel->slug, 'slug' => $channel->slug,
'display_name' => $channel->display_name, 'display_name' => $channel->display_name,
'description' => $channel->description, 'description' => $channel->description,
'avatar_url' => $this->mediaUrl($channel->avatar_path),
'banner_url' => $this->mediaUrl($channel->banner_path),
'thumbnail_url' => $this->mediaUrl($channel->thumbnail_path),
'is_live' => $channel->is_live, 'is_live' => $channel->is_live,
'viewer_count' => $channel->viewer_count, 'viewer_count' => $channel->viewer_count,
'stream_key_last_used_at' => $channel->streamKey?->last_used_at?->toIso8601String(), 'stream_key_last_used_at' => $channel->streamKey?->last_used_at?->toIso8601String(),
@@ -179,4 +183,9 @@ class CreatorDashboardController extends Controller
] : null, ] : null,
]; ];
} }
private function mediaUrl(?string $path): ?string
{
return $path ? Storage::url($path) : null;
}
} }

View File

@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
use App\Models\Category; use App\Models\Category;
use App\Models\Channel; use App\Models\Channel;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
use Laravel\Fortify\Features; use Laravel\Fortify\Features;
@@ -46,6 +47,9 @@ class HomeController extends Controller
'display_name' => $channel->display_name, 'display_name' => $channel->display_name,
'description' => $channel->description, 'description' => $channel->description,
'thumbnail_path' => $channel->thumbnail_path, 'thumbnail_path' => $channel->thumbnail_path,
'thumbnail_url' => $this->mediaUrl($channel->thumbnail_path),
'avatar_url' => $this->mediaUrl($channel->avatar_path),
'banner_url' => $this->mediaUrl($channel->banner_path),
'viewer_count' => $channel->viewer_count, 'viewer_count' => $channel->viewer_count,
'followers_count' => $channel->follows_count ?? 0, 'followers_count' => $channel->follows_count ?? 0,
'category' => $channel->category ? [ 'category' => $channel->category ? [
@@ -64,4 +68,9 @@ class HomeController extends Controller
], ],
]; ];
} }
private function mediaUrl(?string $path): ?string
{
return $path ? Storage::url($path) : null;
}
} }

View File

@@ -62,74 +62,82 @@
} }
:root { :root {
--background: oklch(1 0 0); --background: oklch(0.982 0.012 315);
--foreground: oklch(0.145 0 0); --foreground: oklch(0.18 0.045 312);
--card: oklch(1 0 0); --card: oklch(0.996 0.006 315);
--card-foreground: oklch(0.145 0 0); --card-foreground: oklch(0.18 0.045 312);
--popover: oklch(1 0 0); --popover: oklch(0.996 0.006 315);
--popover-foreground: oklch(0.145 0 0); --popover-foreground: oklch(0.18 0.045 312);
--primary: oklch(0.205 0 0); --primary: oklch(0.57 0.245 348);
--primary-foreground: oklch(0.985 0 0); --primary-foreground: oklch(0.99 0.006 315);
--secondary: oklch(0.97 0 0); --secondary: oklch(0.935 0.03 314);
--secondary-foreground: oklch(0.205 0 0); --secondary-foreground: oklch(0.26 0.075 312);
--muted: oklch(0.97 0 0); --muted: oklch(0.945 0.022 315);
--muted-foreground: oklch(0.556 0 0); --muted-foreground: oklch(0.48 0.05 312);
--accent: oklch(0.97 0 0); --accent: oklch(0.925 0.045 333);
--accent-foreground: oklch(0.205 0 0); --accent-foreground: oklch(0.25 0.08 318);
--destructive: oklch(0.577 0.245 27.325); --destructive: oklch(0.56 0.22 24);
--destructive-foreground: oklch(0.577 0.245 27.325); --destructive-foreground: oklch(0.99 0.006 315);
--border: oklch(0.922 0 0); --border: oklch(0.878 0.032 314);
--input: oklch(0.922 0 0); --input: oklch(0.878 0.032 314);
--ring: oklch(0.87 0 0); --ring: oklch(0.64 0.18 348);
--chart-1: oklch(0.646 0.222 41.116); --chart-1: oklch(0.58 0.22 348);
--chart-2: oklch(0.6 0.118 184.704); --chart-2: oklch(0.58 0.14 205);
--chart-3: oklch(0.398 0.07 227.392); --chart-3: oklch(0.62 0.16 145);
--chart-4: oklch(0.828 0.189 84.429); --chart-4: oklch(0.7 0.16 78);
--chart-5: oklch(0.769 0.188 70.08); --chart-5: oklch(0.56 0.18 285);
--radius: 0.625rem; --radius: 0.5rem;
--sidebar: oklch(0.985 0 0); --sidebar: oklch(0.955 0.02 314);
--sidebar-foreground: oklch(0.145 0 0); --sidebar-foreground: oklch(0.19 0.05 312);
--sidebar-primary: oklch(0.205 0 0); --sidebar-primary: oklch(0.51 0.22 348);
--sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-primary-foreground: oklch(0.99 0.006 315);
--sidebar-accent: oklch(0.97 0 0); --sidebar-accent: oklch(0.91 0.04 318);
--sidebar-accent-foreground: oklch(0.205 0 0); --sidebar-accent-foreground: oklch(0.23 0.075 312);
--sidebar-border: oklch(0.922 0 0); --sidebar-border: oklch(0.86 0.032 314);
--sidebar-ring: oklch(0.87 0 0); --sidebar-ring: oklch(0.64 0.18 348);
--live: oklch(0.62 0.25 24);
--signal: oklch(0.67 0.16 155);
--warning: oklch(0.72 0.15 78);
--success: oklch(0.63 0.16 150);
} }
.dark { .dark {
--background: oklch(0.145 0 0); --background: oklch(0.145 0.04 315);
--foreground: oklch(0.985 0 0); --foreground: oklch(0.968 0.012 316);
--card: oklch(0.145 0 0); --card: oklch(0.18 0.045 315);
--card-foreground: oklch(0.985 0 0); --card-foreground: oklch(0.968 0.012 316);
--popover: oklch(0.145 0 0); --popover: oklch(0.18 0.045 315);
--popover-foreground: oklch(0.985 0 0); --popover-foreground: oklch(0.968 0.012 316);
--primary: oklch(0.985 0 0); --primary: oklch(0.68 0.245 348);
--primary-foreground: oklch(0.205 0 0); --primary-foreground: oklch(0.13 0.04 315);
--secondary: oklch(0.269 0 0); --secondary: oklch(0.245 0.055 314);
--secondary-foreground: oklch(0.985 0 0); --secondary-foreground: oklch(0.955 0.014 316);
--muted: oklch(0.269 0 0); --muted: oklch(0.235 0.048 314);
--muted-foreground: oklch(0.708 0 0); --muted-foreground: oklch(0.73 0.04 316);
--accent: oklch(0.269 0 0); --accent: oklch(0.27 0.075 326);
--accent-foreground: oklch(0.985 0 0); --accent-foreground: oklch(0.97 0.012 316);
--destructive: oklch(0.396 0.141 25.723); --destructive: oklch(0.62 0.22 24);
--destructive-foreground: oklch(0.637 0.237 25.331); --destructive-foreground: oklch(0.99 0.006 315);
--border: oklch(0.269 0 0); --border: oklch(0.31 0.055 315);
--input: oklch(0.269 0 0); --input: oklch(0.31 0.055 315);
--ring: oklch(0.439 0 0); --ring: oklch(0.68 0.22 348);
--chart-1: oklch(0.488 0.243 264.376); --chart-1: oklch(0.68 0.245 348);
--chart-2: oklch(0.696 0.17 162.48); --chart-2: oklch(0.7 0.16 198);
--chart-3: oklch(0.769 0.188 70.08); --chart-3: oklch(0.72 0.16 150);
--chart-4: oklch(0.627 0.265 303.9); --chart-4: oklch(0.78 0.16 80);
--chart-5: oklch(0.645 0.246 16.439); --chart-5: oklch(0.7 0.18 292);
--sidebar: oklch(0.205 0 0); --sidebar: oklch(0.17 0.045 315);
--sidebar-foreground: oklch(0.985 0 0); --sidebar-foreground: oklch(0.96 0.012 316);
--sidebar-primary: oklch(0.985 0 0); --sidebar-primary: oklch(0.68 0.245 348);
--sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-primary-foreground: oklch(0.13 0.04 315);
--sidebar-accent: oklch(0.269 0 0); --sidebar-accent: oklch(0.255 0.06 315);
--sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-accent-foreground: oklch(0.96 0.012 316);
--sidebar-border: oklch(0.269 0 0); --sidebar-border: oklch(0.31 0.055 315);
--sidebar-ring: oklch(0.439 0 0); --sidebar-ring: oklch(0.68 0.22 348);
--live: oklch(0.67 0.25 24);
--signal: oklch(0.73 0.17 155);
--warning: oklch(0.78 0.16 78);
--success: oklch(0.7 0.17 150);
} }
@layer base { @layer base {
@@ -138,6 +146,73 @@
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground antialiased;
}
::selection {
background: color-mix(in oklab, var(--primary) 28%, transparent);
}
}
@layer utilities {
.bg-live {
background-color: var(--live);
}
.text-live {
color: var(--live);
}
.border-live {
border-color: var(--live);
}
.bg-signal {
background-color: var(--signal);
}
.text-signal {
color: var(--signal);
}
.bg-warning {
background-color: var(--warning);
}
.text-warning {
color: var(--warning);
}
.bg-success {
background-color: var(--success);
}
.text-success {
color: var(--success);
}
.theatre-surface {
background: linear-gradient(
180deg,
color-mix(in oklab, var(--card) 95%, black 5%),
var(--background)
);
}
.live-pulse {
animation: live-pulse 1.8s ease-in-out infinite;
}
}
@keyframes live-pulse {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.55;
transform: scale(0.82);
} }
} }

View File

@@ -40,8 +40,7 @@ type Props = {
breadcrumbs?: BreadcrumbItem[]; breadcrumbs?: BreadcrumbItem[];
}; };
const activeItemStyles = const activeItemStyles = 'bg-accent text-accent-foreground';
'text-neutral-900 dark:bg-neutral-800 dark:text-neutral-100';
export function AppHeader({ breadcrumbs = [] }: Props) { export function AppHeader({ breadcrumbs = [] }: Props) {
const page = usePage(); const page = usePage();
@@ -71,7 +70,7 @@ export function AppHeader({ breadcrumbs = [] }: Props) {
return ( return (
<> <>
<div className="border-b border-sidebar-border/80"> <div className="border-b border-sidebar-border/80 bg-background/95 backdrop-blur">
<div className="mx-auto flex h-16 items-center px-4 md:max-w-7xl"> <div className="mx-auto flex h-16 items-center px-4 md:max-w-7xl">
{/* Mobile Menu */} {/* Mobile Menu */}
<div className="lg:hidden"> <div className="lg:hidden">
@@ -153,7 +152,7 @@ export function AppHeader({ breadcrumbs = [] }: Props) {
{item.title} {item.title}
</Link> </Link>
{isCurrentUrl(item.href) && ( {isCurrentUrl(item.href) && (
<div className="absolute bottom-0 left-0 h-0.5 w-full translate-y-px bg-black dark:bg-white"></div> <div className="absolute bottom-0 left-0 h-0.5 w-full translate-y-px bg-primary"></div>
)} )}
</NavigationMenuItem> </NavigationMenuItem>
))} ))}
@@ -203,7 +202,7 @@ export function AppHeader({ breadcrumbs = [] }: Props) {
src={auth.user?.avatar} src={auth.user?.avatar}
alt={auth.user?.name} alt={auth.user?.name}
/> />
<AvatarFallback className="rounded-lg bg-neutral-200 text-black dark:bg-neutral-700 dark:text-white"> <AvatarFallback className="rounded-md bg-secondary text-secondary-foreground">
{getInitials(auth.user?.name ?? '')} {getInitials(auth.user?.name ?? '')}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>

View File

@@ -3,8 +3,8 @@ import AppLogoIcon from '@/components/app-logo-icon';
export default function AppLogo() { export default function AppLogo() {
return ( return (
<> <>
<div className="flex aspect-square size-8 items-center justify-center rounded-md bg-sidebar-primary text-sidebar-primary-foreground"> <div className="flex aspect-square size-8 items-center justify-center rounded-md bg-sidebar-primary text-sidebar-primary-foreground shadow-sm">
<AppLogoIcon className="size-5 fill-current text-white dark:text-black" /> <AppLogoIcon className="size-5 fill-current" />
</div> </div>
<div className="ml-1 grid flex-1 text-left text-sm"> <div className="ml-1 grid flex-1 text-left text-sm">
<span className="mb-0.5 truncate leading-tight font-semibold"> <span className="mb-0.5 truncate leading-tight font-semibold">

View File

@@ -4,7 +4,7 @@ import * as React from "react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const alertVariants = cva( const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", "relative w-full rounded-md border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{ {
variants: { variants: {
variant: { variant: {

View File

@@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
<div <div
data-slot="card" data-slot="card"
className={cn( className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm", "bg-card text-card-foreground flex flex-col gap-6 rounded-md border py-6 shadow-sm",
className className
)} )}
{...props} {...props}

View File

@@ -55,7 +55,7 @@ function DialogContent({
<DialogPrimitive.Content <DialogPrimitive.Content
data-slot="dialog-content" data-slot="dialog-content"
className={cn( className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg", "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-md border p-6 shadow-lg duration-200 sm:max-w-lg",
className className
)} )}
{...props} {...props}

View File

@@ -20,19 +20,19 @@ export default function AuthCardLayout({
description?: string; description?: string;
}>) { }>) {
return ( return (
<div className="flex min-h-svh flex-col items-center justify-center gap-6 bg-muted p-6 md:p-10"> <div className="flex min-h-svh flex-col items-center justify-center gap-6 bg-background p-6 md:p-10">
<div className="flex w-full max-w-md flex-col gap-6"> <div className="flex w-full max-w-md flex-col gap-6">
<Link <Link
href={home()} href={home()}
className="flex items-center gap-2 self-center font-medium" className="flex items-center gap-2 self-center font-medium"
> >
<div className="flex h-9 w-9 items-center justify-center"> <div className="flex size-10 items-center justify-center rounded-md bg-primary text-primary-foreground">
<AppLogoIcon className="size-9 fill-current text-black dark:text-white" /> <AppLogoIcon className="size-6 fill-current" />
</div> </div>
</Link> </Link>
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<Card className="rounded-xl"> <Card className="rounded-md shadow-sm">
<CardHeader className="px-10 pt-8 pb-0 text-center"> <CardHeader className="px-10 pt-8 pb-0 text-center">
<CardTitle className="text-xl">{title}</CardTitle> <CardTitle className="text-xl">{title}</CardTitle>
<CardDescription>{description}</CardDescription> <CardDescription>{description}</CardDescription>

View File

@@ -10,21 +10,21 @@ export default function AuthSimpleLayout({
}: AuthLayoutProps) { }: AuthLayoutProps) {
return ( return (
<div className="flex min-h-svh flex-col items-center justify-center gap-6 bg-background p-6 md:p-10"> <div className="flex min-h-svh flex-col items-center justify-center gap-6 bg-background p-6 md:p-10">
<div className="w-full max-w-sm"> <div className="w-full max-w-sm rounded-md border bg-card p-6 shadow-sm">
<div className="flex flex-col gap-8"> <div className="flex flex-col gap-8">
<div className="flex flex-col items-center gap-4"> <div className="flex flex-col items-center gap-4">
<Link <Link
href={home()} href={home()}
className="flex flex-col items-center gap-2 font-medium" className="flex flex-col items-center gap-2 font-medium"
> >
<div className="mb-1 flex h-9 w-9 items-center justify-center rounded-md"> <div className="mb-1 flex size-10 items-center justify-center rounded-md bg-primary text-primary-foreground">
<AppLogoIcon className="size-9 fill-current text-[var(--foreground)] dark:text-white" /> <AppLogoIcon className="size-6 fill-current" />
</div> </div>
<span className="sr-only">{title}</span> <span className="sr-only">{title}</span>
</Link> </Link>
<div className="space-y-2 text-center"> <div className="space-y-2 text-center">
<h1 className="text-xl font-medium">{title}</h1> <h1 className="text-xl font-semibold">{title}</h1>
<p className="text-center text-sm text-muted-foreground"> <p className="text-center text-sm text-muted-foreground">
{description} {description}
</p> </p>

View File

@@ -1,4 +1,5 @@
import { Link } from '@inertiajs/react'; import { Link } from '@inertiajs/react';
import { Palette, ShieldCheck, UserRound } from 'lucide-react';
import type { PropsWithChildren } from 'react'; import type { PropsWithChildren } from 'react';
import Heading from '@/components/heading'; import Heading from '@/components/heading';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -14,17 +15,17 @@ const sidebarNavItems: NavItem[] = [
{ {
title: 'Profile', title: 'Profile',
href: edit(), href: edit(),
icon: null, icon: UserRound,
}, },
{ {
title: 'Security', title: 'Security',
href: editSecurity(), href: editSecurity(),
icon: null, icon: ShieldCheck,
}, },
{ {
title: 'Appearance', title: 'Appearance',
href: editAppearance(), href: editAppearance(),
icon: null, icon: Palette,
}, },
]; ];
@@ -38,7 +39,7 @@ export default function SettingsLayout({ children }: PropsWithChildren) {
description="Manage your profile and account settings" description="Manage your profile and account settings"
/> />
<div className="flex flex-col lg:flex-row lg:space-x-12"> <div className="flex flex-col gap-6 lg:flex-row">
<aside className="w-full max-w-xl lg:w-48"> <aside className="w-full max-w-xl lg:w-48">
<nav <nav
className="flex flex-col space-y-1 space-x-0" className="flex flex-col space-y-1 space-x-0"
@@ -51,7 +52,8 @@ export default function SettingsLayout({ children }: PropsWithChildren) {
variant="ghost" variant="ghost"
asChild asChild
className={cn('w-full justify-start', { className={cn('w-full justify-start', {
'bg-muted': isCurrentOrParentUrl(item.href), 'bg-accent text-accent-foreground':
isCurrentOrParentUrl(item.href),
})} })}
> >
<Link href={item.href}> <Link href={item.href}>
@@ -68,7 +70,7 @@ export default function SettingsLayout({ children }: PropsWithChildren) {
<Separator className="my-6 lg:hidden" /> <Separator className="my-6 lg:hidden" />
<div className="flex-1 md:max-w-2xl"> <div className="flex-1 md:max-w-2xl">
<section className="max-w-xl space-y-12"> <section className="max-w-xl space-y-12 rounded-md border bg-card p-5 shadow-sm">
{children} {children}
</section> </section>
</div> </div>

View File

@@ -1,6 +1,15 @@
import { Head, Link, router } from '@inertiajs/react'; import { Head, Link, router } from '@inertiajs/react';
import { Ban, Radio, Shield, StopCircle, Undo2 } from 'lucide-react'; import { Ban, Radio, Shield, StopCircle, Undo2, Users } from 'lucide-react';
import { useState } from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { dashboard as adminDashboard } from '@/routes/admin'; import { dashboard as adminDashboard } from '@/routes/admin';
import { stop as stopBroadcast } from '@/routes/admin/broadcasts'; import { stop as stopBroadcast } from '@/routes/admin/broadcasts';
import { restore, suspend } from '@/routes/admin/channels'; import { restore, suspend } from '@/routes/admin/channels';
@@ -14,6 +23,7 @@ type LiveBroadcast = {
id: number; id: number;
slug: string; slug: string;
display_name: string; display_name: string;
thumbnail_url: string | null;
owner: string; owner: string;
}; };
}; };
@@ -22,6 +32,8 @@ type AdminChannel = {
id: number; id: number;
slug: string; slug: string;
display_name: string; display_name: string;
avatar_url: string | null;
thumbnail_url: string | null;
is_live: boolean; is_live: boolean;
suspended_at: string | null; suspended_at: string | null;
owner: { owner: {
@@ -41,56 +53,112 @@ type Props = {
channels: AdminChannel[]; channels: AdminChannel[];
}; };
type ModerationAction =
| { kind: 'stop'; broadcast: LiveBroadcast }
| { kind: 'suspend'; channel: AdminChannel }
| { kind: 'restore'; channel: AdminChannel }
| null;
export default function AdminDashboard({ export default function AdminDashboard({
stats, stats,
liveBroadcasts, liveBroadcasts,
channels, channels,
}: Props) { }: Props) {
const [action, setAction] = useState<ModerationAction>(null);
function confirmAction() {
if (!action) {
return;
}
if (action.kind === 'stop') {
router.post(stopBroadcast(action.broadcast.id), undefined, {
onFinish: () => setAction(null),
});
return;
}
router.post(
action.kind === 'suspend'
? suspend(action.channel.slug)
: restore(action.channel.slug),
undefined,
{ onFinish: () => setAction(null) },
);
}
return ( return (
<> <>
<Head title="Admin" /> <Head title="Admin" />
<div className="grid gap-6 p-4 md:p-6"> <div className="grid gap-6 p-4 md:p-6">
<div className="flex flex-col gap-4 rounded-md border bg-card p-5 md:flex-row md:items-center md:justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="flex size-10 items-center justify-center rounded-md border"> <span className="flex size-10 items-center justify-center rounded-md bg-primary text-primary-foreground">
<Shield className="size-5" /> <Shield className="size-5" />
</span> </span>
<div> <div>
<h1 className="text-2xl font-semibold">Admin</h1> <h1 className="text-2xl font-semibold">
Moderation console
</h1>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Moderate live streams, channels, users, and Review live streams, suspended states, users,
recordings. and recordings.
</p> </p>
</div> </div>
</div> </div>
<div className="rounded-md border bg-background px-3 py-2 text-sm">
{liveBroadcasts.length} active broadcasts
</div>
</div>
<section className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4"> <section className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<Stat label="Users" value={stats.users} /> <Stat icon={Users} label="Users" value={stats.users} />
<Stat label="Channels" value={stats.channels} /> <Stat
<Stat label="Live channels" value={stats.live_channels} /> icon={Radio}
<Stat label="VODs" value={stats.vods} /> label="Channels"
value={stats.channels}
/>
<Stat
icon={Radio}
label="Live channels"
value={stats.live_channels}
live
/>
<Stat icon={Shield} label="VODs" value={stats.vods} />
</section> </section>
<section className="rounded-md border bg-card p-5"> <section className="rounded-md border bg-card p-5 shadow-sm">
<div className="mb-4 flex items-center gap-2"> <div className="mb-4 flex items-center justify-between gap-3">
<Radio className="size-5 text-red-600" /> <div className="flex items-center gap-2">
<Radio className="text-live size-5" />
<h2 className="font-semibold">Live broadcasts</h2> <h2 className="font-semibold">Live broadcasts</h2>
</div> </div>
<div className="grid gap-3"> <span className="text-sm text-muted-foreground">
{liveBroadcasts.length} rows
</span>
</div>
<div className="grid gap-2">
{liveBroadcasts.map((broadcast) => ( {liveBroadcasts.map((broadcast) => (
<div <div
key={broadcast.id} key={broadcast.id}
className="flex flex-col gap-3 rounded-md border p-3 md:flex-row md:items-center md:justify-between" className="grid gap-3 rounded-md border bg-background p-3 md:grid-cols-[72px_minmax(0,1fr)_auto]"
> >
<div> <Thumb url={broadcast.channel.thumbnail_url} />
<div className="font-medium"> <div className="min-w-0">
<div className="truncate font-medium">
{broadcast.title} {broadcast.title}
</div> </div>
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
{broadcast.channel.display_name} by{' '} {broadcast.channel.display_name} by{' '}
{broadcast.channel.owner} {broadcast.channel.owner}
</div> </div>
<div className="bg-live mt-2 inline-flex items-center gap-2 rounded-md px-2 py-1 text-xs font-semibold text-white">
<span className="live-pulse size-1.5 rounded-full bg-white" />
LIVE
</div> </div>
<div className="flex gap-2"> </div>
<div className="flex flex-wrap items-center gap-2">
<Button asChild variant="outline" size="sm"> <Button asChild variant="outline" size="sm">
<Link <Link
href={showChannel( href={showChannel(
@@ -104,9 +172,10 @@ export default function AdminDashboard({
variant="destructive" variant="destructive"
size="sm" size="sm"
onClick={() => onClick={() =>
router.post( setAction({
stopBroadcast(broadcast.id), kind: 'stop',
) broadcast,
})
} }
> >
<StopCircle className="size-4" /> <StopCircle className="size-4" />
@@ -116,31 +185,47 @@ export default function AdminDashboard({
</div> </div>
))} ))}
{liveBroadcasts.length === 0 && ( {liveBroadcasts.length === 0 && (
<div className="rounded-md border border-dashed p-6 text-sm text-muted-foreground"> <EmptyState text="No channels are live." />
No channels are live.
</div>
)} )}
</div> </div>
</section> </section>
<section className="rounded-md border bg-card p-5"> <section className="rounded-md border bg-card p-5 shadow-sm">
<h2 className="mb-4 font-semibold">Recent channels</h2> <div className="mb-4 flex items-center justify-between gap-3">
<div className="grid gap-3"> <h2 className="font-semibold">Recent channels</h2>
<span className="text-sm text-muted-foreground">
{channels.length} rows
</span>
</div>
<div className="grid gap-2">
{channels.map((channel) => ( {channels.map((channel) => (
<div <div
key={channel.id} key={channel.id}
className="flex flex-col gap-3 rounded-md border p-3 md:flex-row md:items-center md:justify-between" className="grid gap-3 rounded-md border bg-background p-3 md:grid-cols-[48px_minmax(0,1fr)_auto]"
> >
<div> <Avatar channel={channel} />
<div className="font-medium"> <div className="min-w-0">
<div className="truncate font-medium">
{channel.display_name} {channel.display_name}
</div> </div>
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
@{channel.slug} owned by{' '} @{channel.slug} owned by{' '}
{channel.owner.name} {channel.owner.name}
</div> </div>
<div className="mt-2 flex flex-wrap gap-2">
{channel.is_live && (
<span className="bg-live rounded-md px-2 py-1 text-xs font-semibold text-white">
LIVE
</span>
)}
{channel.suspended_at && (
<span className="rounded-md border border-destructive/40 bg-destructive/10 px-2 py-1 text-xs font-medium text-destructive">
SUSPENDED
</span>
)}
</div> </div>
<div className="flex gap-2"> </div>
<div className="flex flex-wrap items-center gap-2">
<Button asChild variant="outline" size="sm"> <Button asChild variant="outline" size="sm">
<Link href={showChannel(channel.slug)}> <Link href={showChannel(channel.slug)}>
Open Open
@@ -151,9 +236,10 @@ export default function AdminDashboard({
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => onClick={() =>
router.post( setAction({
restore(channel.slug), kind: 'restore',
) channel,
})
} }
> >
<Undo2 className="size-4" /> <Undo2 className="size-4" />
@@ -164,9 +250,10 @@ export default function AdminDashboard({
variant="destructive" variant="destructive"
size="sm" size="sm"
onClick={() => onClick={() =>
router.post( setAction({
suspend(channel.slug), kind: 'suspend',
) channel,
})
} }
> >
<Ban className="size-4" /> <Ban className="size-4" />
@@ -179,6 +266,38 @@ export default function AdminDashboard({
</div> </div>
</section> </section>
</div> </div>
<Dialog
open={action !== null}
onOpenChange={(open) => !open && setAction(null)}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{dialogTitle(action)}</DialogTitle>
<DialogDescription>
{dialogDescription(action)}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setAction(null)}
>
Cancel
</Button>
<Button
variant={
action?.kind === 'restore'
? 'default'
: 'destructive'
}
onClick={confirmAction}
>
Confirm
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</> </>
); );
} }
@@ -192,11 +311,86 @@ AdminDashboard.layout = {
], ],
}; };
function Stat({ label, value }: { label: string; value: number }) { function Stat({
icon: Icon,
label,
value,
live = false,
}: {
icon: typeof Radio;
label: string;
value: number;
live?: boolean;
}) {
return ( return (
<div className="rounded-md border bg-card p-4"> <div className="rounded-md border bg-card p-4 shadow-sm">
<div className="text-sm text-muted-foreground">{label}</div> <div className="flex items-center justify-between gap-3 text-sm text-muted-foreground">
<div className="mt-1 text-2xl font-semibold">{value}</div> <span>{label}</span>
<Icon className={live ? 'text-live size-4' : 'size-4'} />
</div>
<div className="mt-2 text-2xl font-semibold">{value}</div>
</div> </div>
); );
} }
function Thumb({ url }: { url: string | null }) {
return (
<div className="grid aspect-video overflow-hidden rounded-md bg-zinc-950 text-white md:aspect-square">
{url ? (
<img src={url} alt="" className="h-full w-full object-cover" />
) : (
<div className="grid place-items-center">
<Radio className="size-5 opacity-70" />
</div>
)}
</div>
);
}
function Avatar({ channel }: { channel: AdminChannel }) {
return (
<div className="flex size-12 items-center justify-center overflow-hidden rounded-md bg-secondary text-secondary-foreground">
{channel.avatar_url ? (
<img
src={channel.avatar_url}
alt=""
className="h-full w-full object-cover"
/>
) : (
<Radio className="size-5" />
)}
</div>
);
}
function EmptyState({ text }: { text: string }) {
return (
<div className="rounded-md border border-dashed bg-background p-6 text-sm text-muted-foreground">
{text}
</div>
);
}
function dialogTitle(action: ModerationAction): string {
if (action?.kind === 'stop') {
return 'Stop live broadcast?';
}
if (action?.kind === 'restore') {
return 'Restore channel?';
}
return 'Suspend channel?';
}
function dialogDescription(action: ModerationAction): string {
if (action?.kind === 'stop') {
return 'The live session will end immediately and the channel will move offline.';
}
if (action?.kind === 'restore') {
return 'The channel will be visible again if the owning account is active.';
}
return 'The channel will be hidden from public browsing and watch pages.';
}

View File

@@ -9,9 +9,18 @@ import {
Video, Video,
} from 'lucide-react'; } from 'lucide-react';
import type { FormEvent } from 'react'; import type { FormEvent } from 'react';
import { useState } from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@/components/ui/sheet';
import { VideoPlayer } from '@/components/video-player'; import { VideoPlayer } from '@/components/video-player';
import { cn } from '@/lib/utils';
import { home, login } from '@/routes'; import { home, login } from '@/routes';
import { follow, unfollow } from '@/routes/channels'; import { follow, unfollow } from '@/routes/channels';
import { store as storeChat } from '@/routes/channels/chat'; import { store as storeChat } from '@/routes/channels/chat';
@@ -38,6 +47,7 @@ export default function ChannelShow({
isFollowing, isFollowing,
}: Props) { }: Props) {
const { auth } = usePage().props; const { auth } = usePage().props;
const [chatOpen, setChatOpen] = useState(false);
const chatForm = useForm({ body: '' }); const chatForm = useForm({ body: '' });
function submitChat(event: FormEvent) { function submitChat(event: FormEvent) {
@@ -48,34 +58,45 @@ export default function ChannelShow({
}); });
} }
function toggleFollow() {
if (isFollowing) {
router.delete(unfollow(channel.slug), {
preserveScroll: true,
});
return;
}
router.post(follow(channel.slug), undefined, {
preserveScroll: true,
});
}
return ( return (
<> <>
<Head title={channel.display_name} /> <Head title={channel.display_name} />
<div className="grid min-h-[calc(100vh-4rem)] gap-0 xl:grid-cols-[1fr_360px]"> <div className="theatre-surface grid min-h-[calc(100vh-4rem)] gap-0 xl:grid-cols-[minmax(0,1fr)_380px]">
<main className="min-w-0"> <main className="min-w-0">
<div className="bg-black"> <section className="border-b bg-zinc-950 text-white">
<div className="mx-auto w-full max-w-[1600px]">
{currentBroadcast?.hls_url ? (
<VideoPlayer <VideoPlayer
src={currentBroadcast?.hls_url ?? null} src={currentBroadcast.hls_url}
title={ title={currentBroadcast.title}
currentBroadcast?.title ?? channel.display_name
}
/> />
) : (
<OfflinePlayer channel={channel} />
)}
</div> </div>
</section>
<section className="grid gap-6 p-4 md:p-6"> <section className="grid gap-6 p-4 md:p-6">
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between"> <div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="flex min-w-0 gap-4">
<Avatar channel={channel} />
<div className="min-w-0 space-y-2"> <div className="min-w-0 space-y-2">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{channel.is_live ? ( <LiveState channel={channel} />
<span className="inline-flex items-center gap-2 rounded-md bg-red-600 px-2 py-1 text-xs font-medium text-white">
<Radio className="size-3" />
LIVE
</span>
) : (
<span className="rounded-md border px-2 py-1 text-xs text-muted-foreground">
Offline
</span>
)}
{channel.category && ( {channel.category && (
<span className="rounded-md border px-2 py-1 text-xs"> <span className="rounded-md border px-2 py-1 text-xs">
{channel.category.name} {channel.category.name}
@@ -102,30 +123,54 @@ export default function ChannelShow({
</p> </p>
)} )}
</div> </div>
</div>
<div className="flex flex-wrap gap-2">
<Sheet
open={chatOpen}
onOpenChange={setChatOpen}
>
<SheetTrigger asChild>
<Button
variant="outline"
className="xl:hidden"
>
<MessageSquare className="size-4" />
Chat
</Button>
</SheetTrigger>
<SheetContent
side="bottom"
className="h-[82svh] rounded-t-md"
>
<SheetHeader>
<SheetTitle>Live chat</SheetTitle>
</SheetHeader>
<ChatPanel
channel={channel}
authUser={Boolean(auth.user)}
messages={chatMessages}
form={chatForm}
onSubmit={submitChat}
compact
/>
</SheetContent>
</Sheet>
{auth.user ? ( {auth.user ? (
<Button <Button
variant={ variant={
isFollowing ? 'outline' : 'default' isFollowing ? 'outline' : 'default'
} }
onClick={() => onClick={toggleFollow}
isFollowing
? router.delete(
unfollow(channel.slug),
{
preserveScroll: true,
},
)
: router.post(
follow(channel.slug),
undefined,
{
preserveScroll: true,
},
)
}
> >
<Heart className="size-4" /> <Heart
className={cn(
'size-4',
isFollowing &&
'fill-current text-primary',
)}
/>
{isFollowing ? 'Following' : 'Follow'} {isFollowing ? 'Following' : 'Follow'}
</Button> </Button>
) : ( ) : (
@@ -137,51 +182,28 @@ export default function ChannelShow({
</Button> </Button>
)} )}
</div> </div>
</div>
<section className="grid gap-3"> <section className="grid gap-3">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Video className="size-5" /> <Video className="size-5 text-primary" />
<h2 className="font-semibold">Recent VODs</h2> <h2 className="font-semibold">
Recent VODs
</h2>
</div>
<span className="text-sm text-muted-foreground">
{vods.length} available
</span>
</div> </div>
{vods.length > 0 ? ( {vods.length > 0 ? (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3"> <div className="grid gap-3 md:grid-cols-2 2xl:grid-cols-4">
{vods.map((vod) => ( {vods.map((vod) => (
<div <VodCard key={vod.id} vod={vod} />
key={vod.id}
className="rounded-md border p-4"
>
<div className="font-medium">
{vod.title}
</div>
<div className="mt-1 text-sm text-muted-foreground">
Expires{' '}
{vod.expires_at
? new Date(
vod.expires_at,
).toLocaleDateString()
: 'later'}
</div>
{vod.playback_url ? (
<Button
asChild
variant="outline"
size="sm"
className="mt-3"
>
<a href={vod.playback_url}>
Watch VOD
</a>
</Button>
) : (
<div className="mt-3 text-sm text-muted-foreground">
Processing recording
</div>
)}
</div>
))} ))}
</div> </div>
) : ( ) : (
<div className="rounded-md border border-dashed p-6 text-sm text-muted-foreground"> <div className="rounded-md border border-dashed bg-card p-6 text-sm text-muted-foreground">
No public recordings yet. No public recordings yet.
</div> </div>
)} )}
@@ -189,68 +211,14 @@ export default function ChannelShow({
</section> </section>
</main> </main>
<aside className="flex min-h-[480px] flex-col border-l bg-card"> <aside className="hidden min-h-[480px] border-l bg-card xl:flex">
<div className="flex h-14 items-center gap-2 border-b px-4"> <ChatPanel
<MessageSquare className="size-5" /> channel={channel}
<h2 className="font-semibold">Live chat</h2> authUser={Boolean(auth.user)}
</div> messages={chatMessages}
<div className="flex-1 space-y-3 overflow-y-auto p-4"> form={chatForm}
{chatMessages.map((message) => ( onSubmit={submitChat}
<div key={message.id} className="text-sm">
<span className="font-medium">
{message.user.name}
</span>
<span className="ml-2 text-muted-foreground">
{message.body}
</span>
</div>
))}
{chatMessages.length === 0 && (
<div className="text-sm text-muted-foreground">
{channel.is_live
? 'No messages yet.'
: 'Chat appears when the channel is live.'}
</div>
)}
</div>
<div className="border-t p-4">
{auth.user ? (
<form onSubmit={submitChat} className="flex gap-2">
<Input
value={chatForm.data.body}
onChange={(event) =>
chatForm.setData(
'body',
event.target.value,
)
}
placeholder={
channel.is_live
? 'Send a message'
: 'Channel is offline'
}
disabled={!channel.is_live}
/> />
<Button
type="submit"
size="icon"
disabled={
!channel.is_live || chatForm.processing
}
>
<Send className="size-4" />
</Button>
</form>
) : (
<Button
asChild
variant="outline"
className="w-full"
>
<Link href={login()}>Log in to chat</Link>
</Button>
)}
</div>
</aside> </aside>
</div> </div>
</> </>
@@ -265,3 +233,185 @@ ChannelShow.layout = {
}, },
], ],
}; };
function OfflinePlayer({ channel }: { channel: ChannelDetail }) {
return (
<div className="relative grid aspect-video min-h-[280px] place-items-center overflow-hidden">
{channel.banner_url ? (
<img
src={channel.banner_url}
alt=""
className="absolute inset-0 h-full w-full object-cover opacity-40"
/>
) : null}
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_35%,oklch(0.28_0.1_326),oklch(0.1_0.04_315)_60%)]" />
<div className="relative grid justify-items-center gap-3 px-6 text-center">
<span className="flex size-14 items-center justify-center rounded-md border border-white/20 bg-white/10">
<Radio className="size-6" />
</span>
<div>
<div className="text-xl font-semibold">
{channel.display_name} is offline
</div>
<p className="mt-1 text-sm text-white/70">
Recent recordings remain available below when the
creator publishes VODs.
</p>
</div>
</div>
</div>
);
}
function ChatPanel({
channel,
authUser,
messages,
form,
onSubmit,
compact = false,
}: {
channel: ChannelDetail;
authUser: boolean;
messages: ChatMessage[];
form: ReturnType<typeof useForm<{ body: string }>>;
onSubmit: (event: FormEvent) => void;
compact?: boolean;
}) {
return (
<div className="flex min-h-0 w-full flex-col">
<div
className={cn(
'flex h-14 shrink-0 items-center justify-between gap-2 border-b px-4',
compact && 'hidden',
)}
>
<div className="flex items-center gap-2">
<MessageSquare className="size-5 text-primary" />
<h2 className="font-semibold">Live chat</h2>
</div>
<LiveState channel={channel} compact />
</div>
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto p-4">
{messages.map((message) => (
<div key={message.id} className="text-sm">
<span className="font-medium">{message.user.name}</span>
<span className="ml-2 text-muted-foreground">
{message.body}
</span>
</div>
))}
{messages.length === 0 && (
<div className="rounded-md border border-dashed p-4 text-sm text-muted-foreground">
{channel.is_live
? 'No messages yet.'
: 'Chat appears when the channel is live.'}
</div>
)}
</div>
<div className="shrink-0 border-t p-4">
{authUser ? (
<form onSubmit={onSubmit} className="flex gap-2">
<Input
value={form.data.body}
onChange={(event) =>
form.setData('body', event.target.value)
}
placeholder={
channel.is_live
? 'Send a message'
: 'Channel is offline'
}
disabled={!channel.is_live}
/>
<Button
type="submit"
size="icon"
disabled={!channel.is_live || form.processing}
>
<Send className="size-4" />
</Button>
</form>
) : (
<Button asChild variant="outline" className="w-full">
<Link href={login()}>Log in to chat</Link>
</Button>
)}
</div>
</div>
);
}
function VodCard({ vod }: { vod: VodSummary }) {
return (
<div className="overflow-hidden rounded-md border bg-card">
<div className="grid aspect-video place-items-center bg-zinc-950 text-white">
<Video className="size-6 opacity-70" />
</div>
<div className="grid gap-3 p-4">
<div>
<div className="line-clamp-2 font-medium">{vod.title}</div>
<div className="mt-1 text-sm text-muted-foreground">
Expires{' '}
{vod.expires_at
? new Date(vod.expires_at).toLocaleDateString()
: 'later'}
</div>
</div>
{vod.playback_url ? (
<Button asChild variant="outline" size="sm">
<a href={vod.playback_url}>Watch VOD</a>
</Button>
) : (
<div className="text-sm text-muted-foreground">
Processing recording
</div>
)}
</div>
</div>
);
}
function LiveState({
channel,
compact = false,
}: {
channel: ChannelDetail;
compact?: boolean;
}) {
if (channel.is_live) {
return (
<span
className={cn(
'bg-live inline-flex items-center gap-2 rounded-md px-2 py-1 text-xs font-semibold text-white',
compact && 'gap-1.5 px-1.5',
)}
>
<span className="live-pulse size-1.5 rounded-full bg-white" />
LIVE
</span>
);
}
return (
<span className="rounded-md border px-2 py-1 text-xs text-muted-foreground">
Offline
</span>
);
}
function Avatar({ channel }: { channel: ChannelDetail }) {
return (
<div className="flex size-12 shrink-0 items-center justify-center overflow-hidden rounded-md bg-secondary text-secondary-foreground">
{channel.avatar_url ? (
<img
src={channel.avatar_url}
alt=""
className="h-full w-full object-cover"
/>
) : (
<Radio className="size-5" />
)}
</div>
);
}

View File

@@ -1,18 +1,30 @@
import { Head, Link, router, useForm } from '@inertiajs/react'; import { Head, Link, router, useForm } from '@inertiajs/react';
import { import {
Check,
Copy, Copy,
KeyRound, Eye,
Radio, Radio,
RotateCw, RotateCw,
Save, Save,
ShieldAlert,
StopCircle, StopCircle,
Video, Video,
} from 'lucide-react'; } from 'lucide-react';
import type { FormEvent } from 'react'; import type { FormEvent, ReactNode } from 'react';
import { useState } from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { useClipboard } from '@/hooks/use-clipboard'; import { useClipboard } from '@/hooks/use-clipboard';
import { cn } from '@/lib/utils';
import { dashboard as dashboardRoute } from '@/routes'; import { dashboard as dashboardRoute } from '@/routes';
import { show as showChannel } from '@/routes/channels'; import { show as showChannel } from '@/routes/channels';
import { import {
@@ -31,6 +43,9 @@ type CreatorChannel = {
slug: string; slug: string;
display_name: string; display_name: string;
description: string | null; description: string | null;
avatar_url: string | null;
banner_url: string | null;
thumbnail_url: string | null;
is_live: boolean; is_live: boolean;
viewer_count: number; viewer_count: number;
stream_key_last_used_at: string | null; stream_key_last_used_at: string | null;
@@ -56,8 +71,12 @@ export default function Dashboard({
categories, categories,
recentBroadcasts, recentBroadcasts,
}: Props) { }: Props) {
const [, copy] = useClipboard(); const [copiedText, copy] = useClipboard();
const [stopTarget, setStopTarget] = useState<BroadcastSummary | null>(null);
const liveBroadcast = channel?.live_broadcast ?? null; const liveBroadcast = channel?.live_broadcast ?? null;
const pendingBroadcast =
recentBroadcasts.find((broadcast) => broadcast.status === 'pending') ??
null;
const createForm = useForm({ const createForm = useForm({
display_name: '', display_name: '',
slug: '', slug: '',
@@ -92,24 +111,48 @@ export default function Dashboard({
}); });
} }
function stopBroadcast() {
if (!stopTarget) {
return;
}
router.post(stopBroadcastRoute(stopTarget.id), undefined, {
onFinish: () => setStopTarget(null),
});
}
return ( return (
<> <>
<Head title="Creator Studio" /> <Head title="Creator Studio" />
<div className="flex flex-1 flex-col gap-6 p-4 md:p-6"> <div className="flex flex-1 flex-col gap-6 p-4 md:p-6">
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between"> <div className="flex flex-col gap-4 rounded-md border bg-card p-5 md:flex-row md:items-center md:justify-between">
<div> <div className="flex min-w-0 items-center gap-4">
<h1 className="text-2xl font-semibold"> <ChannelAvatar channel={channel} />
Creator Studio <div className="min-w-0">
<div className="mb-2 flex flex-wrap items-center gap-2">
<StatusPill
live={channel?.is_live ?? false}
viewers={channel?.viewer_count ?? 0}
/>
{pendingBroadcast && !liveBroadcast && (
<span className="border-warning/40 bg-warning/10 text-warning rounded-md border px-2 py-1 text-xs font-medium">
READY
</span>
)}
</div>
<h1 className="truncate text-2xl font-semibold">
{channel?.display_name ?? 'Creator Studio'}
</h1> </h1>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Manage your channel, stream key, and OBS broadcast Prepare broadcasts, secure your stream key, and
sessions. monitor the current session.
</p> </p>
</div> </div>
</div>
{channel && ( {channel && (
<Button asChild variant="outline"> <Button asChild variant="outline">
<Link href={showChannel(channel.slug)}> <Link href={showChannel(channel.slug)}>
<Radio className="size-4" /> <Eye className="size-4" />
View channel View channel
</Link> </Link>
</Button> </Button>
@@ -117,17 +160,15 @@ export default function Dashboard({
</div> </div>
{!channel ? ( {!channel ? (
<section className="max-w-2xl rounded-md border bg-card p-5"> <section className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_340px]">
<div className="mb-5"> <Panel
<h2 className="font-semibold"> title="Create your channel"
Create your channel description="Each account can own one public channel in this version."
</h2> >
<p className="text-sm text-muted-foreground"> <form
Each account can own one channel in this onSubmit={createChannel}
version. className="grid gap-4"
</p> >
</div>
<form onSubmit={createChannel} className="grid gap-4">
<Field <Field
label="Display name" label="Display name"
error={createForm.errors.display_name} error={createForm.errors.display_name}
@@ -143,7 +184,10 @@ export default function Dashboard({
placeholder="Nyone Live" placeholder="Nyone Live"
/> />
</Field> </Field>
<Field label="Slug" error={createForm.errors.slug}> <Field
label="Slug"
error={createForm.errors.slug}
>
<Input <Input
value={createForm.data.slug} value={createForm.data.slug}
onChange={(event) => onChange={(event) =>
@@ -160,7 +204,10 @@ export default function Dashboard({
categories={categories} categories={categories}
value={createForm.data.category_id} value={createForm.data.category_id}
onChange={(value) => onChange={(value) =>
createForm.setData('category_id', value) createForm.setData(
'category_id',
value,
)
} }
/> />
</Field> </Field>
@@ -187,30 +234,119 @@ export default function Dashboard({
Create channel Create channel
</Button> </Button>
</form> </form>
</Panel>
<Panel title="OBS readiness">
<ReadinessRow done label="Channel metadata" />
<ReadinessRow label="Private stream key" />
<ReadinessRow label="Prepared broadcast" />
<ReadinessRow label="Live ingest signal" />
</Panel>
</section> </section>
) : ( ) : (
<div className="grid gap-6 xl:grid-cols-[1fr_360px]"> <div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_380px]">
<div className="grid gap-6"> <div className="grid gap-6">
<section className="rounded-md border bg-card p-5"> <section className="grid gap-4 md:grid-cols-3">
<div className="mb-5 flex items-center justify-between gap-4"> <MetricCard
<div> label="Broadcast state"
<h2 className="font-semibold"> value={
Channel details channel.is_live
</h2> ? 'Live'
<p className="text-sm text-muted-foreground"> : pendingBroadcast
Public metadata used in the ? 'Ready'
directory and channel page. : 'Offline'
</p> }
</div> />
<span className="rounded-md border px-2 py-1 text-xs"> <MetricCard
{channel.is_live label="Current viewers"
? `${channel.viewer_count} viewers` value={String(channel.viewer_count)}
: 'Offline'} />
</span> <MetricCard
label="Stream key used"
value={
channel.stream_key_last_used_at
? new Date(
channel.stream_key_last_used_at,
).toLocaleDateString()
: 'Never'
}
/>
</section>
<Panel
title="Broadcast prep"
description="Create a pending session before starting OBS."
>
<form
onSubmit={createBroadcast}
className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_160px_auto]"
>
<Field
label="Stream title"
error={broadcastForm.errors.title}
>
<Input
value={broadcastForm.data.title}
onChange={(event) =>
broadcastForm.setData(
'title',
event.target.value,
)
}
placeholder="Building Nyone live"
disabled={
Boolean(liveBroadcast) ||
Boolean(pendingBroadcast)
}
/>
</Field>
<label className="mt-6 flex h-9 items-center gap-2 rounded-md border bg-background px-3 text-sm">
<input
type="checkbox"
checked={
broadcastForm.data
.recording_enabled
}
onChange={(event) =>
broadcastForm.setData(
'recording_enabled',
event.target.checked,
)
}
disabled={
Boolean(liveBroadcast) ||
Boolean(pendingBroadcast)
}
/>
Record VOD
</label>
<Button
type="submit"
disabled={
broadcastForm.processing ||
Boolean(liveBroadcast) ||
Boolean(pendingBroadcast)
}
className="mt-6"
>
<Video className="size-4" />
Prepare
</Button>
</form>
{(liveBroadcast ?? pendingBroadcast) && (
<div className="border-warning/40 bg-warning/10 text-warning mt-4 rounded-md border p-3 text-sm">
Finish the current broadcast before
preparing another session.
</div> </div>
)}
</Panel>
<Panel
title="Channel setup"
description="Public metadata used in the directory and watch page."
>
<form <form
onSubmit={updateChannel} onSubmit={updateChannel}
className="grid gap-4" className="grid gap-4 md:grid-cols-2"
> >
<Field <Field
label="Display name" label="Display name"
@@ -257,6 +393,7 @@ export default function Dashboard({
<Field <Field
label="Description" label="Description"
error={channelForm.errors.description} error={channelForm.errors.description}
className="md:col-span-2"
> >
<textarea <textarea
value={ value={
@@ -275,129 +412,49 @@ export default function Dashboard({
<Button <Button
type="submit" type="submit"
disabled={channelForm.processing} disabled={channelForm.processing}
className="md:col-span-2 md:w-fit"
> >
<Save className="size-4" /> <Save className="size-4" />
Save channel Save channel
</Button> </Button>
</form> </form>
</section> </Panel>
<section className="rounded-md border bg-card p-5"> <Panel title="Recent broadcasts">
<div className="mb-5">
<h2 className="font-semibold">
Broadcast setup
</h2>
<p className="text-sm text-muted-foreground">
Create a pending broadcast, then start
OBS with your stream key.
</p>
</div>
<form
onSubmit={createBroadcast}
className="grid gap-4 md:grid-cols-[1fr_auto]"
>
<Field
label="Stream title"
error={broadcastForm.errors.title}
>
<Input
value={broadcastForm.data.title}
onChange={(event) =>
broadcastForm.setData(
'title',
event.target.value,
)
}
placeholder="Building Nyone live"
/>
</Field>
<label className="mt-6 flex h-9 items-center gap-2 rounded-md border px-3 text-sm">
<input
type="checkbox"
checked={
broadcastForm.data
.recording_enabled
}
onChange={(event) =>
broadcastForm.setData(
'recording_enabled',
event.target.checked,
)
}
/>
Record VOD
</label>
<Button
type="submit"
disabled={broadcastForm.processing}
className="md:col-span-2"
>
<Video className="size-4" />
Prepare broadcast
</Button>
</form>
</section>
<section className="rounded-md border bg-card p-5">
<h2 className="mb-4 font-semibold">
Recent broadcasts
</h2>
<div className="grid gap-2"> <div className="grid gap-2">
{recentBroadcasts.map((broadcast) => ( {recentBroadcasts.map((broadcast) => (
<div <BroadcastRow
key={broadcast.id} key={broadcast.id}
className="flex flex-col gap-3 rounded-md border p-3 md:flex-row md:items-center md:justify-between" broadcast={broadcast}
> onStop={() =>
<div> setStopTarget(broadcast)
<div className="font-medium">
{broadcast.title}
</div>
<div className="text-sm text-muted-foreground">
{broadcast.status}{' '}
{broadcast.recording_enabled
? 'with recording'
: 'live only'}
</div>
</div>
{broadcast.status !== 'ended' && (
<Button
variant="outline"
size="sm"
onClick={() =>
router.post(
stopBroadcastRoute(
broadcast.id,
),
)
} }
> />
<StopCircle className="size-4" />
Stop
</Button>
)}
</div>
))} ))}
{recentBroadcasts.length === 0 && ( {recentBroadcasts.length === 0 && (
<div className="rounded-md border border-dashed p-6 text-center text-sm text-muted-foreground"> <EmptyState text="No broadcasts yet." />
No broadcasts yet.
</div>
)} )}
</div> </div>
</section> </Panel>
</div> </div>
<aside className="grid content-start gap-6"> <aside className="grid content-start gap-6">
<section className="rounded-md border bg-card p-5"> <Panel title="OBS stream key">
<div className="mb-4 flex items-center gap-2"> <div className="border-warning/40 bg-warning/10 text-warning mb-4 flex items-start gap-3 rounded-md border p-3 text-sm">
<KeyRound className="size-5 text-amber-600" /> <ShieldAlert className="mt-0.5 size-4 shrink-0" />
<h2 className="font-semibold"> <span>
OBS stream key Stream keys can publish to this channel.
</h2> Rotate immediately if it was exposed.
</span>
</div> </div>
<div className="grid gap-3 text-sm"> <div className="grid gap-3 text-sm">
<CopyRow <CopyRow
label="Server" label="Server"
value={streaming.ingestServer} value={streaming.ingestServer}
copied={
copiedText ===
streaming.ingestServer
}
onCopy={copy} onCopy={copy}
/> />
<CopyRow <CopyRow
@@ -406,11 +463,15 @@ export default function Dashboard({
streaming.tokenizedPath ?? streaming.tokenizedPath ??
'Rotate the key to reveal it once.' 'Rotate the key to reveal it once.'
} }
copied={
copiedText ===
streaming.tokenizedPath
}
onCopy={copy} onCopy={copy}
muted={!streaming.tokenizedPath} muted={!streaming.tokenizedPath}
/> />
{plainStreamKey && ( {plainStreamKey && (
<div className="rounded-md border border-amber-300 bg-amber-50 p-3 text-amber-900 dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-200"> <div className="border-warning/40 bg-warning/10 text-warning rounded-md border p-3">
This key is shown once. Store it in This key is shown once. Store it in
OBS before leaving this page. OBS before leaving this page.
</div> </div>
@@ -425,43 +486,84 @@ export default function Dashboard({
Rotate key Rotate key
</Button> </Button>
</div> </div>
</section> </Panel>
{liveBroadcast && ( <Panel title="Current session">
<section className="rounded-md border bg-card p-5"> {liveBroadcast ? (
<h2 className="mb-3 font-semibold"> <div className="grid gap-3">
Current live session <StatusPill
</h2> live
<div className="space-y-2 text-sm"> viewers={channel.viewer_count}
/>
<div>
<div className="font-medium"> <div className="font-medium">
{liveBroadcast.title} {liveBroadcast.title}
</div> </div>
<div className="text-muted-foreground"> <div className="text-sm text-muted-foreground">
{liveBroadcast.recording_enabled {liveBroadcast.recording_enabled
? 'Recording enabled' ? 'Recording enabled'
: 'Live only'} : 'Live only'}
</div> </div>
</div>
<Button <Button
variant="destructive" variant="destructive"
size="sm" size="sm"
onClick={() => onClick={() =>
router.post( setStopTarget(liveBroadcast)
stopBroadcastRoute(
liveBroadcast.id,
),
)
} }
> >
<StopCircle className="size-4" /> <StopCircle className="size-4" />
Stop broadcast Stop broadcast
</Button> </Button>
</div> </div>
</section> ) : pendingBroadcast ? (
<div className="grid gap-3">
<StatusPill ready />
<div>
<div className="font-medium">
{pendingBroadcast.title}
</div>
<div className="text-sm text-muted-foreground">
Start OBS to push the session
live.
</div>
</div>
</div>
) : (
<EmptyState text="No active session." />
)} )}
</Panel>
</aside> </aside>
</div> </div>
)} )}
</div> </div>
<Dialog
open={stopTarget !== null}
onOpenChange={(open) => !open && setStopTarget(null)}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Stop broadcast?</DialogTitle>
<DialogDescription>
This ends the live session and clears the channel's
live state. Viewers will see the offline screen.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setStopTarget(null)}
>
Cancel
</Button>
<Button variant="destructive" onClick={stopBroadcast}>
<StopCircle className="size-4" />
Stop broadcast
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</> </>
); );
} }
@@ -475,17 +577,43 @@ Dashboard.layout = {
], ],
}; };
function Panel({
title,
description,
children,
}: {
title: string;
description?: string;
children: ReactNode;
}) {
return (
<section className="rounded-md border bg-card p-5 shadow-sm">
<div className="mb-5">
<h2 className="font-semibold">{title}</h2>
{description && (
<p className="text-sm text-muted-foreground">
{description}
</p>
)}
</div>
{children}
</section>
);
}
function Field({ function Field({
label, label,
error, error,
className,
children, children,
}: { }: {
label: string; label: string;
error?: string; error?: string;
children: React.ReactNode; className?: string;
children: ReactNode;
}) { }) {
return ( return (
<label className="grid gap-2"> <label className={cn('grid gap-2', className)}>
<Label>{label}</Label> <Label>{label}</Label>
{children} {children}
{error && <span className="text-sm text-destructive">{error}</span>} {error && <span className="text-sm text-destructive">{error}</span>}
@@ -521,11 +649,13 @@ function CategorySelect({
function CopyRow({ function CopyRow({
label, label,
value, value,
copied,
muted = false, muted = false,
onCopy, onCopy,
}: { }: {
label: string; label: string;
value: string; value: string;
copied: boolean;
muted?: boolean; muted?: boolean;
onCopy: (text: string) => Promise<boolean>; onCopy: (text: string) => Promise<boolean>;
}) { }) {
@@ -536,19 +666,143 @@ function CopyRow({
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<code <code
className={`min-w-0 flex-1 rounded-md border px-3 py-2 text-xs ${muted ? 'text-muted-foreground' : ''}`} className={cn(
'min-w-0 flex-1 overflow-hidden rounded-md border bg-background px-3 py-2 text-xs text-ellipsis',
muted && 'text-muted-foreground',
)}
> >
{value} {value}
</code> </code>
<Button <Button
type="button" type="button"
variant="outline" variant={copied ? 'default' : 'outline'}
size="icon" size="icon"
onClick={() => void onCopy(value)} onClick={() => void onCopy(value)}
> >
{copied ? (
<Check className="size-4" />
) : (
<Copy className="size-4" /> <Copy className="size-4" />
)}
</Button> </Button>
</div> </div>
</div> </div>
); );
} }
function MetricCard({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-md border bg-card p-4 shadow-sm">
<div className="text-sm text-muted-foreground">{label}</div>
<div className="mt-1 text-2xl font-semibold">{value}</div>
</div>
);
}
function BroadcastRow({
broadcast,
onStop,
}: {
broadcast: BroadcastSummary;
onStop: () => void;
}) {
const active = broadcast.status !== 'ended';
return (
<div className="flex flex-col gap-3 rounded-md border bg-background p-3 md:flex-row md:items-center md:justify-between">
<div className="min-w-0">
<div className="truncate font-medium">{broadcast.title}</div>
<div className="text-sm text-muted-foreground">
{broadcast.status ?? 'unknown'} -{' '}
{broadcast.recording_enabled ? 'recording' : 'live only'}
{broadcast.has_vod ? ' - VOD ready' : ''}
</div>
</div>
{active && (
<Button variant="outline" size="sm" onClick={onStop}>
<StopCircle className="size-4" />
Stop
</Button>
)}
</div>
);
}
function StatusPill({
live = false,
ready = false,
viewers = 0,
}: {
live?: boolean;
ready?: boolean;
viewers?: number;
}) {
if (live) {
return (
<span className="bg-live inline-flex items-center gap-2 rounded-md px-2 py-1 text-xs font-semibold text-white">
<span className="live-pulse size-1.5 rounded-full bg-white" />
LIVE - {viewers} viewers
</span>
);
}
if (ready) {
return (
<span className="border-warning/40 bg-warning/10 text-warning rounded-md border px-2 py-1 text-xs font-medium">
READY
</span>
);
}
return (
<span className="rounded-md border px-2 py-1 text-xs text-muted-foreground">
OFFLINE
</span>
);
}
function ChannelAvatar({ channel }: { channel: CreatorChannel | null }) {
return (
<div className="flex size-12 shrink-0 items-center justify-center overflow-hidden rounded-md bg-secondary text-secondary-foreground">
{channel?.avatar_url ? (
<img
src={channel.avatar_url}
alt=""
className="h-full w-full object-cover"
/>
) : (
<Radio className="size-5" />
)}
</div>
);
}
function ReadinessRow({
done = false,
label,
}: {
done?: boolean;
label: string;
}) {
return (
<div className="flex items-center gap-3 border-b py-3 last:border-b-0">
<span
className={cn(
'flex size-6 items-center justify-center rounded-md border',
done && 'border-success/40 bg-success/10 text-success',
)}
>
{done && <Check className="size-3" />}
</span>
<span className="text-sm">{label}</span>
</div>
);
}
function EmptyState({ text }: { text: string }) {
return (
<div className="rounded-md border border-dashed bg-background p-6 text-center text-sm text-muted-foreground">
{text}
</div>
);
}

View File

@@ -1,5 +1,7 @@
import { Head, Link, usePage } from '@inertiajs/react'; import { Head, Link, usePage } from '@inertiajs/react';
import { import {
ArrowRight,
Compass,
LayoutDashboard, LayoutDashboard,
Radio, Radio,
Search, Search,
@@ -10,6 +12,7 @@ import {
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { cn } from '@/lib/utils';
import { dashboard, home, login, register } from '@/routes'; import { dashboard, home, login, register } from '@/routes';
import { show as showChannel } from '@/routes/channels'; import { show as showChannel } from '@/routes/channels';
import type { Category, ChannelCard } from '@/types'; import type { Category, ChannelCard } from '@/types';
@@ -29,6 +32,7 @@ export default function Welcome({
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [category, setCategory] = useState<string>('all'); const [category, setCategory] = useState<string>('all');
const featuredChannel = liveChannels[0] ?? null;
const filteredChannels = useMemo(() => { const filteredChannels = useMemo(() => {
const normalizedQuery = query.trim().toLowerCase(); const normalizedQuery = query.trim().toLowerCase();
@@ -51,24 +55,41 @@ export default function Welcome({
<> <>
<Head title="Live" /> <Head title="Live" />
<div className="min-h-screen bg-background text-foreground"> <div className="min-h-screen bg-background text-foreground">
<header className="border-b"> <header className="sticky top-0 z-40 border-b bg-background/90 backdrop-blur">
<div className="mx-auto flex h-16 w-full max-w-7xl items-center gap-4 px-4"> <div className="mx-auto flex h-16 w-full max-w-7xl items-center gap-4 px-4">
<Link <Link
href={home()} href={home()}
className="flex items-center gap-2 font-semibold" className="flex items-center gap-2 font-semibold"
> >
<span className="flex size-9 items-center justify-center rounded-md bg-neutral-950 text-white dark:bg-white dark:text-neutral-950"> <span className="flex size-9 items-center justify-center rounded-md bg-primary text-primary-foreground">
<Radio className="size-4" /> <Radio className="size-4" />
</span> </span>
<span>Nyone</span> <span>Nyone</span>
</Link> </Link>
<nav className="hidden items-center gap-1 text-sm text-muted-foreground md:flex">
<Link
href={home()}
className="rounded-md px-3 py-2 text-foreground"
>
Live
</Link>
{auth.user && (
<Link
href={dashboard()}
className="rounded-md px-3 py-2 hover:bg-accent hover:text-accent-foreground"
>
Studio
</Link>
)}
</nav>
<div className="ml-auto flex items-center gap-2"> <div className="ml-auto flex items-center gap-2">
{auth.user ? ( {auth.user ? (
<Button asChild size="sm"> <Button asChild size="sm">
<Link href={dashboard()}> <Link href={dashboard()}>
<LayoutDashboard className="size-4" /> <LayoutDashboard className="size-4" />
Dashboard Studio
</Link> </Link>
</Button> </Button>
) : ( ) : (
@@ -90,38 +111,109 @@ export default function Welcome({
</div> </div>
</header> </header>
<main className="mx-auto flex w-full max-w-7xl flex-col gap-8 px-4 py-8"> <main className="mx-auto flex w-full max-w-7xl flex-col gap-8 px-4 py-6 md:py-8">
<section className="grid gap-6 border-b pb-8 lg:grid-cols-[1fr_320px]"> <section className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_360px]">
{featuredChannel ? (
<Link
href={showChannel(featuredChannel.slug)}
className="group overflow-hidden rounded-md border bg-card text-card-foreground shadow-sm transition hover:border-primary/50"
>
<div className="grid md:grid-cols-[minmax(0,1.35fr)_minmax(280px,0.65fr)]">
<Thumbnail
channel={featuredChannel}
className="aspect-video md:min-h-[360px]"
label="Featured live"
/>
<div className="grid content-between gap-6 p-5">
<div className="space-y-4"> <div className="space-y-4">
<div className="inline-flex items-center gap-2 rounded-md border px-3 py-1 text-sm text-muted-foreground"> <LiveBadge />
<Video className="size-4 text-red-600" /> <div className="space-y-2">
{liveChannels.length} live now <h1 className="text-2xl font-semibold md:text-4xl">
</div> {featuredChannel.broadcast
<div className="max-w-3xl space-y-3"> ?.title ??
<h1 className="text-3xl font-semibold tracking-normal md:text-5xl"> featuredChannel.display_name}
Live streams from independent channels
</h1> </h1>
<p className="text-base text-muted-foreground md:text-lg"> <p className="line-clamp-3 text-sm leading-6 text-muted-foreground">
Watch public live broadcasts, follow {featuredChannel.description ??
channels, and chat while creators stream `${featuredChannel.display_name} is live now with ${featuredChannel.viewer_count} viewers.`}
from OBS.
</p> </p>
</div> </div>
</div> </div>
<div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
<span className="font-medium text-foreground">
{featuredChannel.display_name}
</span>
<span>
{featuredChannel.category
?.name ?? 'Uncategorized'}
</span>
<span className="inline-flex items-center gap-1">
<Users className="size-4" />
{
featuredChannel.viewer_count
}{' '}
viewers
</span>
<span className="ml-auto inline-flex items-center gap-1 text-primary">
Watch
<ArrowRight className="size-4" />
</span>
</div>
</div>
</div>
</Link>
) : (
<div className="grid min-h-[360px] content-center gap-4 rounded-md border border-dashed bg-card p-6">
<div className="flex size-12 items-center justify-center rounded-md bg-secondary text-secondary-foreground">
<Video className="size-5" />
</div>
<div className="max-w-xl space-y-2">
<h1 className="text-2xl font-semibold md:text-4xl">
The live floor is quiet
</h1>
<p className="text-sm leading-6 text-muted-foreground md:text-base">
Creators can prepare a broadcast in the
studio and go live from OBS when ready.
</p>
</div>
</div>
)}
<div className="grid content-start gap-3 rounded-md border p-4"> <aside className="grid content-start gap-3 rounded-md border bg-card p-4">
<div className="flex items-center gap-3"> <div className="flex items-center justify-between gap-3 border-b pb-3">
<Users className="size-5 text-sky-700 dark:text-sky-400" />
<div> <div>
<div className="text-sm font-medium"> <div className="text-sm font-medium">
Creator access Live signal
</div> </div>
<div className="text-sm text-muted-foreground"> <div className="text-xs text-muted-foreground">
One channel per account with private Public channels broadcasting now
stream keys.
</div> </div>
</div> </div>
<div className="bg-live rounded-md px-2 py-1 text-xs font-medium text-white">
{liveChannels.length}
</div> </div>
</div>
<Metric
icon={Radio}
label="Live channels"
value={String(liveChannels.length)}
/>
<Metric
icon={Users}
label="Viewers"
value={String(
liveChannels.reduce(
(total, item) =>
total + item.viewer_count,
0,
),
)}
/>
<Metric
icon={Compass}
label="Categories"
value={String(categories.length)}
/>
<Button asChild variant="outline"> <Button asChild variant="outline">
<Link <Link
href={auth.user ? dashboard() : register()} href={auth.user ? dashboard() : register()}
@@ -131,14 +223,19 @@ export default function Welcome({
: 'Start a channel'} : 'Start a channel'}
</Link> </Link>
</Button> </Button>
</div> </aside>
</section> </section>
<section className="flex flex-col gap-4"> <section className="flex flex-col gap-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between"> <div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
<div>
<h2 className="text-xl font-semibold"> <h2 className="text-xl font-semibold">
Live directory Live directory
</h2> </h2>
<p className="text-sm text-muted-foreground">
Browse active channels by category or title.
</p>
</div>
<div className="flex flex-col gap-2 sm:flex-row"> <div className="flex flex-col gap-2 sm:flex-row">
<div className="relative"> <div className="relative">
<Search className="absolute top-2.5 left-3 size-4 text-muted-foreground" /> <Search className="absolute top-2.5 left-3 size-4 text-muted-foreground" />
@@ -147,7 +244,7 @@ export default function Welcome({
onChange={(event) => onChange={(event) =>
setQuery(event.target.value) setQuery(event.target.value)
} }
placeholder="Search channels" placeholder="Search live channels"
className="pl-9 sm:w-72" className="pl-9 sm:w-72"
/> />
</div> </div>
@@ -169,53 +266,25 @@ export default function Welcome({
</div> </div>
{filteredChannels.length > 0 ? ( {filteredChannels.length > 0 ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{filteredChannels.map((channel) => ( {filteredChannels.map((channel) => (
<Link <StreamCard
key={channel.id} key={channel.id}
href={showChannel(channel.slug)} channel={channel}
className="group overflow-hidden rounded-md border bg-card text-card-foreground transition-colors hover:border-neutral-400 dark:hover:border-neutral-600" />
>
<div className="flex aspect-video items-center justify-center bg-neutral-950 text-white">
<div className="flex items-center gap-2 text-sm">
<span className="size-2 rounded-full bg-red-500" />
Live preview
</div>
</div>
<div className="grid gap-3 p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate font-medium">
{channel.broadcast
?.title ??
channel.display_name}
</div>
<div className="truncate text-sm text-muted-foreground">
{channel.display_name}{' '}
by {channel.owner.name}
</div>
</div>
<div className="shrink-0 rounded-md bg-red-600 px-2 py-1 text-xs font-medium text-white">
LIVE
</div>
</div>
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>
{channel.category?.name ??
'Uncategorized'}
</span>
<span>
{channel.viewer_count}{' '}
viewers
</span>
</div>
</div>
</Link>
))} ))}
</div> </div>
) : ( ) : (
<div className="flex min-h-64 items-center justify-center rounded-md border border-dashed text-sm text-muted-foreground"> <div className="grid min-h-64 content-center justify-items-center gap-3 rounded-md border border-dashed bg-card p-6 text-center">
No live channels match this view. <Search className="size-8 text-muted-foreground" />
<div>
<div className="font-medium">
No streams match this view
</div>
<div className="text-sm text-muted-foreground">
Clear search or switch categories.
</div>
</div>
</div> </div>
)} )}
</section> </section>
@@ -224,3 +293,106 @@ export default function Welcome({
</> </>
); );
} }
function StreamCard({ channel }: { channel: ChannelCard }) {
return (
<Link
href={showChannel(channel.slug)}
className="group overflow-hidden rounded-md border bg-card text-card-foreground shadow-sm transition hover:border-primary/50"
>
<Thumbnail channel={channel} className="aspect-video" />
<div className="grid gap-3 p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate font-medium">
{channel.broadcast?.title ?? channel.display_name}
</div>
<div className="truncate text-sm text-muted-foreground">
{channel.display_name} by {channel.owner.name}
</div>
</div>
<LiveBadge compact />
</div>
<div className="flex items-center justify-between gap-3 text-sm text-muted-foreground">
<span className="truncate">
{channel.category?.name ?? 'Uncategorized'}
</span>
<span className="shrink-0">
{channel.viewer_count} viewers
</span>
</div>
</div>
</Link>
);
}
function Thumbnail({
channel,
className,
label = 'Live preview',
}: {
channel: ChannelCard;
className?: string;
label?: string;
}) {
return (
<div
className={cn(
'relative flex items-center justify-center overflow-hidden bg-zinc-950 text-white',
className,
)}
>
{channel.thumbnail_url ? (
<img
src={channel.thumbnail_url}
alt=""
className="h-full w-full object-cover transition duration-300 group-hover:scale-[1.02]"
/>
) : (
<div className="grid h-full w-full place-items-center bg-[radial-gradient(circle_at_40%_30%,oklch(0.34_0.12_326),oklch(0.14_0.04_315)_55%)]">
<div className="flex items-center gap-2 text-sm font-medium">
<span className="bg-live live-pulse size-2 rounded-full" />
{label}
</div>
</div>
)}
<div className="absolute top-3 left-3">
<LiveBadge compact />
</div>
</div>
);
}
function LiveBadge({ compact = false }: { compact?: boolean }) {
return (
<span
className={cn(
'bg-live inline-flex items-center gap-2 rounded-md px-2 py-1 text-xs font-semibold text-white',
compact && 'gap-1.5 px-1.5',
)}
>
<span className="live-pulse size-1.5 rounded-full bg-white" />
LIVE
</span>
);
}
function Metric({
icon: Icon,
label,
value,
}: {
icon: typeof Radio;
label: string;
value: string;
}) {
return (
<div className="flex items-center justify-between gap-3 rounded-md border bg-background px-3 py-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Icon className="size-4 text-primary" />
{label}
</div>
<div className="font-semibold">{value}</div>
</div>
);
}

View File

@@ -10,6 +10,9 @@ export type ChannelCard = {
display_name: string; display_name: string;
description: string | null; description: string | null;
thumbnail_path: string | null; thumbnail_path: string | null;
thumbnail_url: string | null;
avatar_url: string | null;
banner_url: string | null;
viewer_count: number; viewer_count: number;
followers_count: number; followers_count: number;
category: Category | null; category: Category | null;
@@ -29,6 +32,9 @@ export type ChannelDetail = {
slug: string; slug: string;
display_name: string; display_name: string;
description: string | null; description: string | null;
avatar_url: string | null;
banner_url: string | null;
thumbnail_url: string | null;
is_live: boolean; is_live: boolean;
viewer_count: number; viewer_count: number;
followers_count: number; followers_count: number;

View File

@@ -0,0 +1,37 @@
<?php
namespace Tests\Feature;
use App\Models\Broadcast;
use App\Models\Channel;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\TestCase;
class AdminDashboardTest extends TestCase
{
use RefreshDatabase;
public function test_admin_dashboard_exposes_channel_display_media_props(): void
{
$admin = User::factory()->create(['is_admin' => true]);
$channel = Channel::factory()->create([
'avatar_path' => 'channels/demo/avatar.jpg',
'thumbnail_path' => 'channels/demo/thumb.jpg',
'is_live' => true,
]);
$broadcast = Broadcast::factory()->for($channel)->live()->create();
$channel->forceFill(['live_broadcast_id' => $broadcast->id])->save();
$this->actingAs($admin)
->get(route('admin.dashboard'))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('admin/dashboard')
->where('liveBroadcasts.0.channel.thumbnail_url', '/storage/channels/demo/thumb.jpg')
->where('channels.0.avatar_url', '/storage/channels/demo/avatar.jpg')
->where('channels.0.thumbnail_url', '/storage/channels/demo/thumb.jpg'),
);
}
}

View File

@@ -2,8 +2,11 @@
namespace Tests\Feature; namespace Tests\Feature;
use App\Models\Broadcast;
use App\Models\Channel;
use App\Models\User; use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\TestCase; use Tests\TestCase;
class DashboardTest extends TestCase class DashboardTest extends TestCase
@@ -24,4 +27,45 @@ class DashboardTest extends TestCase
$response = $this->get(route('dashboard')); $response = $this->get(route('dashboard'));
$response->assertOk(); $response->assertOk();
} }
public function test_home_page_exposes_channel_display_media_props(): void
{
$channel = Channel::factory()->create([
'is_live' => true,
'avatar_path' => 'channels/demo/avatar.jpg',
'banner_path' => 'channels/demo/banner.jpg',
'thumbnail_path' => 'channels/demo/thumb.jpg',
]);
$broadcast = Broadcast::factory()->for($channel)->live()->create();
$channel->forceFill(['live_broadcast_id' => $broadcast->id])->save();
$this->get(route('home'))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('welcome')
->where('liveChannels.0.avatar_url', '/storage/channels/demo/avatar.jpg')
->where('liveChannels.0.banner_url', '/storage/channels/demo/banner.jpg')
->where('liveChannels.0.thumbnail_url', '/storage/channels/demo/thumb.jpg'),
);
}
public function test_creator_dashboard_exposes_channel_display_media_props(): void
{
$user = User::factory()->create();
Channel::factory()->for($user)->create([
'avatar_path' => 'channels/demo/avatar.jpg',
'banner_path' => 'channels/demo/banner.jpg',
'thumbnail_path' => 'channels/demo/thumb.jpg',
]);
$this->actingAs($user)
->get(route('dashboard'))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('dashboard')
->where('channel.avatar_url', '/storage/channels/demo/avatar.jpg')
->where('channel.banner_url', '/storage/channels/demo/banner.jpg')
->where('channel.thumbnail_url', '/storage/channels/demo/thumb.jpg'),
);
}
} }

View File

@@ -6,6 +6,7 @@ use App\Models\Broadcast;
use App\Models\Channel; use App\Models\Channel;
use App\Models\User; use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\TestCase; use Tests\TestCase;
class ViewerInteractionTest extends TestCase class ViewerInteractionTest extends TestCase
@@ -58,4 +59,23 @@ class ViewerInteractionTest extends TestCase
'body' => 'No auth', 'body' => 'No auth',
])->assertRedirect(route('login')); ])->assertRedirect(route('login'));
} }
public function test_channel_page_exposes_display_media_props(): void
{
$channel = Channel::factory()->create([
'slug' => 'demo',
'avatar_path' => 'channels/demo/avatar.jpg',
'banner_path' => 'channels/demo/banner.jpg',
'thumbnail_path' => 'channels/demo/thumb.jpg',
]);
$this->get(route('channels.show', $channel))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('channels/show')
->where('channel.avatar_url', '/storage/channels/demo/avatar.jpg')
->where('channel.banner_url', '/storage/channels/demo/banner.jpg')
->where('channel.thumbnail_url', '/storage/channels/demo/thumb.jpg'),
);
}
} }