chore(ui): choose any bot as mod and fix landing page

This commit is contained in:
2026-03-02 19:19:23 +01:00
parent 70ae7ef3b3
commit ba30d6e097
6 changed files with 188 additions and 80 deletions

View File

@@ -61,6 +61,7 @@ import {
DialogTrigger,
} from '@/components/ui/dialog';
import { UserCombobox } from '@/components/app/UserCombobox/UserCombobox';
import { BotCombobox } from '@/components/app/BotCombobox/BotCombobox';
import { parseAsString, useQueryState } from 'nuqs';
import { Write } from '@/components/ui/channel-desc-fancy-area/write';
import { Preview } from '@/components/ui/channel-desc-fancy-area/preview';
@@ -89,7 +90,7 @@ interface ChannelSettingsClientProps {
chatModerators: User[];
chatModeratorPersonalChannels: (Channel | null)[];
chatModeratorBots: BotAccount[];
teamBotAccounts: BotAccount[];
allBotAccounts: Pick<BotAccount, 'id' | 'displayName' | 'slug' | 'pfpUrl'>[];
streamInfo: StreamInfo[];
streamKey: StreamKey | null;
chatSettings: ChatModerationSettings | null;
@@ -857,7 +858,7 @@ export default function ChannelSettingsClient({
/>
<AddChatBotModeratorDialog
channelId={channel.id}
teamBots={channel.teamBotAccounts}
botAccounts={channel.allBotAccounts}
existingBotModerators={channel.chatModeratorBots.map((bot) => bot.id)}
/>
</div>
@@ -1260,20 +1261,16 @@ function AddChatModeratorDialog({
function AddChatBotModeratorDialog({
channelId,
teamBots,
botAccounts,
existingBotModerators,
}: {
channelId: string;
teamBots: BotAccount[];
botAccounts: Pick<BotAccount, 'id' | 'displayName' | 'slug' | 'pfpUrl'>[];
existingBotModerators: string[];
}) {
const [open, setOpen] = useState(false);
const [selectedBotId, setSelectedBotId] = useState('');
const availableBots = teamBots.filter(
(botAccount) => !existingBotModerators.includes(botAccount.id)
);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
@@ -1286,21 +1283,16 @@ function AddChatBotModeratorDialog({
<DialogHeader>
<DialogTitle>Add bot moderator</DialogTitle>
<DialogDescription>
Bots can delete messages, timeout users, and ban users in chat.
Look up any bot account by name or slug to grant moderation powers.
</DialogDescription>
</DialogHeader>
<Select value={selectedBotId} onValueChange={setSelectedBotId}>
<SelectTrigger>
<SelectValue placeholder="Select bot" />
</SelectTrigger>
<SelectContent>
{availableBots.map((botAccount) => (
<SelectItem key={botAccount.id} value={botAccount.id}>
{botAccount.displayName} (@{botAccount.slug})
</SelectItem>
))}
</SelectContent>
</Select>
<BotCombobox
bots={botAccounts}
filter={existingBotModerators}
value={selectedBotId}
onValueChange={setSelectedBotId}
modal
/>
<DialogFooter>
<Button
disabled={!selectedBotId}

View File

@@ -65,12 +65,12 @@ export default async function ChannelSettingsPage({
const followerPersonalChannels = await Promise.all(
channel.followers.map((follower) => resolvePersonalChannel(follower.user.id))
);
const teamMemberIds = [channel.ownerId, ...channel.managers.map((manager) => manager.id)];
const teamBotAccounts = await prisma.botAccount.findMany({
where: {
ownerId: {
in: teamMemberIds,
},
const allBotAccounts = await prisma.botAccount.findMany({
select: {
id: true,
displayName: true,
slug: true,
pfpUrl: true,
},
orderBy: {
slug: 'asc',
@@ -86,7 +86,7 @@ export default async function ChannelSettingsPage({
managerPersonalChannels,
chatModeratorPersonalChannels,
followerPersonalChannels,
teamBotAccounts,
allBotAccounts,
}}
isOwner={isOwner}
currentUser={user}

View File

@@ -0,0 +1,103 @@
'use client';
import * as React from 'react';
import { Check, ChevronsUpDown } from 'lucide-react';
import type { BotAccount } from '@hctv/db';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { cn } from '@/lib/utils';
export function BotCombobox({ bots, filter, value, modal, onValueChange }: Props) {
const [open, setOpen] = React.useState(false);
const selectedBot = bots.find((bot) => bot.id === value);
const availableBots = bots.filter((bot) => !filter?.includes(bot.id));
return (
<Popover open={open} onOpenChange={setOpen} modal={modal}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between"
>
{selectedBot ? (
<div className="flex items-center gap-2 overflow-hidden">
<Avatar className="h-8 w-8 shrink-0">
<AvatarImage
src={selectedBot.pfpUrl}
alt={selectedBot.displayName}
loading="lazy"
decoding="async"
/>
<AvatarFallback>{selectedBot.displayName[0]?.toUpperCase()}</AvatarFallback>
</Avatar>
<span className="truncate">{selectedBot.displayName}</span>
</div>
) : (
'Select bot...'
)}
<ChevronsUpDown className="opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
<Command>
<CommandInput placeholder="Search bot..." className="h-9" />
<CommandList>
<CommandEmpty>No bot found.</CommandEmpty>
<CommandGroup>
{availableBots.map((bot) => (
<CommandItem
key={bot.id}
value={bot.id}
onSelect={(currentValue) => {
onValueChange(currentValue === value ? '' : currentValue);
setOpen(false);
}}
>
<Avatar className="h-8 w-8">
<AvatarImage
src={bot.pfpUrl}
alt={bot.displayName}
loading="lazy"
decoding="async"
/>
<AvatarFallback>{bot.displayName[0]?.toUpperCase()}</AvatarFallback>
</Avatar>
<div className="flex flex-col">
<span>{bot.displayName}</span>
<span className="text-xs text-mantle-foreground">@{bot.slug}</span>
</div>
<Check
className={cn('ml-auto', value === bot.id ? 'opacity-100' : 'opacity-0')}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
type BotLookupAccount = Pick<BotAccount, 'id' | 'displayName' | 'slug' | 'pfpUrl'>;
type Props = {
bots: BotLookupAccount[];
filter?: string[];
value: string;
modal?: boolean;
onValueChange: (value: string) => void;
};

View File

@@ -55,8 +55,8 @@ export default function StreamGrid({ liveStreams, offlineStreams }: StreamGridPr
{offlineStreams.length > 0 && (
<section>
<SectionHeading label="Offline channels" count={offlineStreams.length} />
<div className="relative">
<Carousel opts={{ align: 'start', dragFree: true }}>
<div className="relative max-w-full overflow-hidden">
<Carousel className="w-full max-w-full" opts={{ align: 'start', dragFree: true }}>
<CarouselContent>
{offlineStreams.map((stream) => (
<CarouselItem
@@ -86,6 +86,8 @@ function StreamCard({ stream }: { stream: StreamWithChannel }) {
src={`/api/stream/thumb/${stream.channel.name}`}
alt={stream.title}
className="absolute inset-0 object-cover"
loading="lazy"
decoding="async"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent" />
<div className="absolute bottom-1.5 left-1.5 md:bottom-2 md:left-2">
@@ -97,7 +99,12 @@ function StreamCard({ stream }: { stream: StreamWithChannel }) {
</div>
<div className="flex items-start gap-2 p-2 md:gap-3 md:p-3">
<Avatar className="h-7 w-7 shrink-0 ring-1 ring-primary/20 md:h-8 md:w-8">
<AvatarImage src={stream.channel.pfpUrl} alt={stream.channel.name} />
<AvatarImage
src={stream.channel.pfpUrl}
alt={stream.channel.name}
loading="lazy"
decoding="async"
/>
<AvatarFallback className="text-[10px]">
{stream.channel.name.slice(0, 2).toUpperCase()}
</AvatarFallback>
@@ -128,7 +135,12 @@ function OfflineCard({ stream }: { stream: StreamWithChannel }) {
<div className="flex w-[70px] flex-col items-center gap-1 rounded-lg p-1.5 transition-colors duration-150 hover:bg-muted/50 sm:w-[78px] md:w-[86px] md:gap-1.5 md:p-2">
<div className="relative">
<Avatar className="h-9 w-9 ring-2 ring-border transition-colors duration-150 group-hover:ring-border/60 sm:h-10 sm:w-10 md:h-11 md:w-11">
<AvatarImage src={stream.channel.pfpUrl} alt={stream.channel.name} />
<AvatarImage
src={stream.channel.pfpUrl}
alt={stream.channel.name}
loading="lazy"
decoding="async"
/>
<AvatarFallback className="text-xs font-semibold">
{stream.channel.name.slice(0, 2).toUpperCase()}
</AvatarFallback>

View File

@@ -22,7 +22,7 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
export function UserCombobox(props: Props) {
const [open, setOpen] = React.useState(false);
const [internalValue, setInternalValue] = React.useState('');
// Use external value if provided, otherwise use internal state
const value = props.value ?? internalValue;
const setValue = props.onValueChange ?? setInternalValue;
@@ -30,10 +30,7 @@ export function UserCombobox(props: Props) {
data: fetchedUsers,
error,
isLoading,
} = useSWR<APIResponse>(
props.users ? null : '/api/stream/info?personal=true',
fetcher
);
} = useSWR<APIResponse>(props.users ? null : '/api/stream/info?personal=true', fetcher);
const users = props.users || fetchedUsers;
@@ -48,17 +45,22 @@ export function UserCombobox(props: Props) {
aria-expanded={open}
className="w-[200px] justify-between"
>
{value
? (
<div className='flex items-center gap-2'>
<Avatar className="h-8 w-8">
<AvatarImage src={users?.find((user) => user.username === value)?.channel.pfpUrl} alt={value} />
<AvatarFallback>{value[0]}</AvatarFallback>
</Avatar>
<span>{users?.find((user) => user.username === value)?.username}</span>
</div>
)
: 'Select user...'}
{value ? (
<div className="flex items-center gap-2">
<Avatar className="h-8 w-8">
<AvatarImage
src={users?.find((user) => user.username === value)?.channel.pfpUrl}
alt={value}
loading="lazy"
decoding="async"
/>
<AvatarFallback>{value[0]}</AvatarFallback>
</Avatar>
<span>{users?.find((user) => user.username === value)?.username}</span>
</div>
) : (
'Select user...'
)}
<ChevronsUpDown className="opacity-50" />
</Button>
</PopoverTrigger>
@@ -68,28 +70,35 @@ export function UserCombobox(props: Props) {
<CommandList>
<CommandEmpty>No user found.</CommandEmpty>
<CommandGroup>
{users?.filter(user => !props.filter?.some(filterStr => user.userId === filterStr)).map((user) => (
<CommandItem
key={user.channelId}
value={user.username}
onSelect={(currentValue) => {
setValue(currentValue === value ? '' : currentValue);
setOpen(false);
}}
>
<Avatar className="h-8 w-8">
<AvatarImage src={user.channel.pfpUrl} alt={user.username} />
<AvatarFallback>{user.username[0]}</AvatarFallback>
</Avatar>
{user.username}
<Check
className={cn(
'ml-auto',
value === user.username ? 'opacity-100' : 'opacity-0'
)}
/>
</CommandItem>
))}
{users
?.filter((user) => !props.filter?.some((filterStr) => user.userId === filterStr))
.map((user) => (
<CommandItem
key={user.channelId}
value={user.username}
onSelect={(currentValue) => {
setValue(currentValue === value ? '' : currentValue);
setOpen(false);
}}
>
<Avatar className="h-8 w-8">
<AvatarImage
src={user.channel.pfpUrl}
alt={user.username}
loading="lazy"
decoding="async"
/>
<AvatarFallback>{user.username[0]}</AvatarFallback>
</Avatar>
{user.username}
<Check
className={cn(
'ml-auto',
value === user.username ? 'opacity-100' : 'opacity-0'
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
@@ -105,4 +114,4 @@ type Props = {
filter?: string[];
modal?: boolean;
onValueChange?: (value: string) => void;
}
};

View File

@@ -373,7 +373,7 @@ export async function addChatBotModerator(channelId: string, botId: string) {
const bot = await prisma.botAccount.findUnique({
where: { id: botId },
select: { id: true, ownerId: true },
select: { id: true },
});
if (!bot) {
@@ -384,14 +384,6 @@ export async function addChatBotModerator(channelId: string, botId: string) {
return { success: false, error: 'Bot is already a chat moderator' };
}
const canUseBot =
bot.ownerId === channel.ownerId ||
channel.managers.some((manager) => manager.id === bot.ownerId);
if (!canUseBot) {
return { success: false, error: 'Bot owner must be a channel manager or owner' };
}
await prisma.channel.update({
where: { id: channelId },
data: {