Admin can now control channel creation from the admin dashboard

This commit is contained in:
2026-05-16 03:43:25 +03:30
parent 34445f3032
commit f46f32aa4c
14 changed files with 639 additions and 95 deletions

View File

@@ -1,6 +1,23 @@
import { Head, Link, router } from '@inertiajs/react';
import { Ban, Radio, Shield, StopCircle, Undo2, Users } from 'lucide-react';
import {
Ban,
KeyRound,
Lock,
Radio,
Shield,
StopCircle,
Undo2,
Unlock,
UserCheck,
Users,
UserX,
} from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { useState } from 'react';
import {
updateDefault as updateChannelCreationDefault,
updateUser as updateUserCreatorAccess,
} from '@/actions/App/Http/Controllers/AdminChannelCreationController';
import { Button } from '@/components/ui/button';
import {
Dialog,
@@ -42,15 +59,35 @@ type AdminChannel = {
};
};
type AdminUser = {
id: number;
name: string;
email: string;
is_admin: boolean;
can_create_channel: boolean;
suspended_at: string | null;
created_at: string | null;
channel: {
id: number;
slug: string;
display_name: string;
} | null;
};
type Props = {
stats: {
users: number;
channels: number;
live_channels: number;
vods: number;
creator_access_grants: number;
};
channelCreationPolicy: {
channel_creation_open: boolean;
};
liveBroadcasts: LiveBroadcast[];
channels: AdminChannel[];
users: AdminUser[];
};
type ModerationAction =
@@ -61,11 +98,37 @@ type ModerationAction =
export default function AdminDashboard({
stats,
channelCreationPolicy,
liveBroadcasts,
channels,
users,
}: Props) {
const [action, setAction] = useState<ModerationAction>(null);
function setChannelCreationDefault(channelCreationOpen: boolean) {
router.patch(
updateChannelCreationDefault(),
{
channel_creation_open: channelCreationOpen,
},
{
preserveScroll: true,
},
);
}
function setUserCreatorAccess(user: AdminUser, canCreateChannel: boolean) {
router.patch(
updateUserCreatorAccess(user.id),
{
can_create_channel: canCreateChannel,
},
{
preserveScroll: true,
},
);
}
function confirmAction() {
if (!action) {
return;
@@ -112,7 +175,7 @@ export default function AdminDashboard({
</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-5">
<Stat icon={Users} label="Users" value={stats.users} />
<Stat
icon={Radio}
@@ -125,9 +188,73 @@ export default function AdminDashboard({
value={stats.live_channels}
live
/>
<Stat
icon={UserCheck}
label="Creator grants"
value={stats.creator_access_grants}
/>
<Stat icon={Shield} label="VODs" value={stats.vods} />
</section>
<section className="rounded-md border bg-card p-5 shadow-sm">
<div className="mb-4 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="flex items-start gap-2">
<KeyRound className="mt-0.5 size-5 text-primary" />
<div>
<h2 className="font-semibold">
Channel creation access
</h2>
<p className="text-sm text-muted-foreground">
Control who can create a creator channel.
</p>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button
variant={
channelCreationPolicy.channel_creation_open
? 'outline'
: 'default'
}
size="sm"
onClick={() => setChannelCreationDefault(false)}
>
<Lock className="size-4" />
Approval only
</Button>
<Button
variant={
channelCreationPolicy.channel_creation_open
? 'default'
: 'outline'
}
size="sm"
onClick={() => setChannelCreationDefault(true)}
>
<Unlock className="size-4" />
Open to all
</Button>
</div>
</div>
<div className="grid gap-2">
{users.map((user) => (
<CreatorAccessRow
key={user.id}
user={user}
defaultOpen={
channelCreationPolicy.channel_creation_open
}
onChange={(canCreateChannel) =>
setUserCreatorAccess(user, canCreateChannel)
}
/>
))}
{users.length === 0 && (
<EmptyState text="No users found." />
)}
</div>
</section>
<section className="rounded-md border bg-card p-5 shadow-sm">
<div className="mb-4 flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
@@ -317,7 +444,7 @@ function Stat({
value,
live = false,
}: {
icon: typeof Radio;
icon: LucideIcon;
label: string;
value: number;
live?: boolean;
@@ -333,6 +460,78 @@ function Stat({
);
}
function CreatorAccessRow({
user,
defaultOpen,
onChange,
}: {
user: AdminUser;
defaultOpen: boolean;
onChange: (canCreateChannel: boolean) => void;
}) {
const effectiveAccess = defaultOpen || user.can_create_channel;
return (
<div className="grid gap-3 rounded-md border bg-background p-3 md:grid-cols-[minmax(0,1fr)_auto] md:items-center">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate font-medium">{user.name}</span>
{user.is_admin && (
<span className="rounded-md border px-2 py-0.5 text-xs text-muted-foreground">
ADMIN
</span>
)}
{user.suspended_at && (
<span className="rounded-md border border-destructive/40 bg-destructive/10 px-2 py-0.5 text-xs font-medium text-destructive">
SUSPENDED
</span>
)}
<span
className={
effectiveAccess
? 'border-success/40 bg-success/10 text-success rounded-md border px-2 py-0.5 text-xs font-medium'
: 'border-warning/40 bg-warning/10 text-warning rounded-md border px-2 py-0.5 text-xs font-medium'
}
>
{effectiveAccess ? 'CAN CREATE' : 'NEEDS APPROVAL'}
</span>
{defaultOpen && !user.can_create_channel && (
<span className="rounded-md border px-2 py-0.5 text-xs text-muted-foreground">
DEFAULT
</span>
)}
</div>
<div className="mt-1 truncate text-sm text-muted-foreground">
{user.email}
{user.channel && (
<>
{' - '}
<Link
href={showChannel(user.channel.slug)}
className="text-primary hover:underline"
>
{user.channel.display_name}
</Link>
</>
)}
</div>
</div>
<Button
variant={user.can_create_channel ? 'outline' : 'default'}
size="sm"
onClick={() => onChange(!user.can_create_channel)}
>
{user.can_create_channel ? (
<UserX className="size-4" />
) : (
<UserCheck className="size-4" />
)}
{user.can_create_channel ? 'Remove grant' : 'Grant'}
</Button>
</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">

View File

@@ -56,6 +56,7 @@ type CreatorChannel = {
type Props = {
channel: CreatorChannel | null;
canCreateChannel: boolean;
plainStreamKey: string | null;
streaming: {
ingestServer: string;
@@ -67,6 +68,7 @@ type Props = {
export default function Dashboard({
channel,
canCreateChannel,
plainStreamKey,
streaming,
categories,
@@ -190,88 +192,112 @@ export default function Dashboard({
</div>
{!channel ? (
<section className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_340px]">
<Panel
title="Create your channel"
description="Each account can own one public channel in this version."
>
<form
onSubmit={createChannel}
className="grid gap-4"
canCreateChannel ? (
<section className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_340px]">
<Panel
title="Create your channel"
description="Each account can own one public channel in this version."
>
<Field
label="Display name"
error={createForm.errors.display_name}
<form
onSubmit={createChannel}
className="grid gap-4"
>
<Input
value={createForm.data.display_name}
onChange={(event) =>
createForm.setData(
'display_name',
event.target.value,
)
}
placeholder="Nyone Live"
/>
</Field>
<Field
label="Slug"
error={createForm.errors.slug}
>
<Input
value={createForm.data.slug}
onChange={(event) =>
createForm.setData(
'slug',
event.target.value.toLowerCase(),
)
}
placeholder="nyone-live"
/>
</Field>
<Field label="Category">
<CategorySelect
categories={categories}
value={createForm.data.category_id}
onChange={(value) =>
createForm.setData(
'category_id',
value,
)
}
/>
</Field>
<Field
label="Description"
error={createForm.errors.description}
>
<textarea
value={createForm.data.description}
onChange={(event) =>
createForm.setData(
'description',
event.target.value,
)
}
className="min-h-28 rounded-md border bg-background px-3 py-2 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
/>
</Field>
<Button
type="submit"
disabled={createForm.processing}
>
<Save className="size-4" />
Create channel
</Button>
</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>
<Field
label="Display name"
error={createForm.errors.display_name}
>
<Input
value={createForm.data.display_name}
onChange={(event) =>
createForm.setData(
'display_name',
event.target.value,
)
}
placeholder="Nyone Live"
/>
</Field>
<Field
label="Slug"
error={createForm.errors.slug}
>
<Input
value={createForm.data.slug}
onChange={(event) =>
createForm.setData(
'slug',
event.target.value.toLowerCase(),
)
}
placeholder="nyone-live"
/>
</Field>
<Field label="Category">
<CategorySelect
categories={categories}
value={createForm.data.category_id}
onChange={(value) =>
createForm.setData(
'category_id',
value,
)
}
/>
</Field>
<Field
label="Description"
error={createForm.errors.description}
>
<textarea
value={createForm.data.description}
onChange={(event) =>
createForm.setData(
'description',
event.target.value,
)
}
className="min-h-28 rounded-md border bg-background px-3 py-2 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
/>
</Field>
<Button
type="submit"
disabled={createForm.processing}
>
<Save className="size-4" />
Create channel
</Button>
</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 className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_340px]">
<Panel
title="Channel creation requires approval"
description="An admin can grant creator access from the admin console."
>
<div className="border-warning/40 bg-warning/10 text-warning flex items-start gap-3 rounded-md border p-3 text-sm">
<ShieldAlert className="mt-0.5 size-4 shrink-0" />
<span>
This account can browse and follow
channels, but cannot create a channel
yet.
</span>
</div>
</Panel>
<Panel title="OBS readiness">
<ReadinessRow label="Channel metadata" />
<ReadinessRow label="Private stream key" />
<ReadinessRow label="Prepared broadcast" />
<ReadinessRow label="Live ingest signal" />
</Panel>
</section>
)
) : (
<div className="grid min-w-0 gap-6 xl:grid-cols-[minmax(0,1fr)_380px]">
<div className="grid min-w-0 gap-6">