mirror of
https://github.com/SrIzan10/hctv.git
synced 2026-06-06 00:56:56 +00:00
chore(ui): choose any bot as mod and fix landing page
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
103
apps/web/src/components/app/BotCombobox/BotCombobox.tsx
Normal file
103
apps/web/src/components/app/BotCombobox/BotCombobox.tsx
Normal 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;
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user