mirror of
https://github.com/SrIzan10/hctv.git
synced 2026-06-06 00:56:56 +00:00
feat: initial channel settings implementation
This commit is contained in:
@@ -30,6 +30,7 @@
|
||||
"@radix-ui/react-separator": "^1.1.1",
|
||||
"@radix-ui/react-slot": "^1.1.1",
|
||||
"@radix-ui/react-switch": "^1.1.3",
|
||||
"@radix-ui/react-tabs": "^1.1.12",
|
||||
"@radix-ui/react-tooltip": "^1.1.6",
|
||||
"@slack/web-api": "^7.9.1",
|
||||
"@uidotdev/usehooks": "^2.4.1",
|
||||
|
||||
@@ -1,34 +1,55 @@
|
||||
// FIXME: THIS EFFING SUCKS OH MY GOD
|
||||
|
||||
import { validateRequest } from '@/lib/auth/validate';
|
||||
import { prisma } from '@hctv/db';
|
||||
import { Prisma, prisma } from '@hctv/db';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const shouldGetOwned = searchParams.get('owned') === 'true';
|
||||
const allPersonalChannels = searchParams.get('personal') === 'true';
|
||||
const isLive = searchParams.get('live') === 'true';
|
||||
const { user } = await validateRequest();
|
||||
/* const db = await prisma.streamInfo.findMany({
|
||||
include: { ownedBy: true },
|
||||
}); */
|
||||
|
||||
if (shouldGetOwned) {
|
||||
if (!user) {
|
||||
return new Response('No user found in cookies', { status: 401 });
|
||||
}
|
||||
|
||||
const db = await prisma.streamInfo.findMany({
|
||||
where: {
|
||||
channel: {
|
||||
ownerId: user.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
return Response.json(db);
|
||||
} else {
|
||||
const db = await prisma.streamInfo.findMany({
|
||||
include: {
|
||||
channel: true,
|
||||
}
|
||||
});
|
||||
return Response.json(db);
|
||||
if ((shouldGetOwned || allPersonalChannels) && !user) {
|
||||
return new Response('No user found in cookies', { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
const where: Prisma.StreamInfoWhereInput = {};
|
||||
const channelConditions: Prisma.ChannelWhereInput[] = [];
|
||||
|
||||
if (shouldGetOwned && user) {
|
||||
channelConditions.push({ ownerId: user.id });
|
||||
}
|
||||
|
||||
if (allPersonalChannels) {
|
||||
channelConditions.push({
|
||||
personalFor: {
|
||||
isNot: null
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (isLive) {
|
||||
where.isLive = true;
|
||||
}
|
||||
|
||||
if (channelConditions.length > 0) {
|
||||
where.channel = channelConditions.length === 1
|
||||
? channelConditions[0]
|
||||
: { OR: channelConditions };
|
||||
}
|
||||
|
||||
const db = await prisma.streamInfo.findMany({
|
||||
where,
|
||||
include: {
|
||||
channel: {
|
||||
include: {
|
||||
personalFor: true,
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return Response.json(db);
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import {
|
||||
Settings,
|
||||
Users,
|
||||
Key,
|
||||
Bell,
|
||||
Trash2,
|
||||
Shield,
|
||||
UserPlus,
|
||||
UserMinus,
|
||||
Copy,
|
||||
Check,
|
||||
} from 'lucide-react';
|
||||
import { UniversalForm } from '@/components/app/UniversalForm/UniversalForm';
|
||||
import {
|
||||
updateChannelSettings,
|
||||
addChannelManager,
|
||||
removeChannelManager,
|
||||
deleteChannel,
|
||||
toggleGlobalChannelNotifs,
|
||||
} from '@/lib/form/actions';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { toast } from 'sonner';
|
||||
import type { Channel, User, StreamInfo, StreamKey, Follow } from '@hctv/db';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import { UserCombobox } from '@/components/app/UserCombobox/UserCombobox';
|
||||
|
||||
interface ChannelSettingsClientProps {
|
||||
channel: Channel & {
|
||||
owner: User;
|
||||
ownerPersonalChannel: Channel | null;
|
||||
managers: User[];
|
||||
managerPersonalChannels: (Channel | null)[];
|
||||
streamInfo: StreamInfo[];
|
||||
streamKey: StreamKey | null;
|
||||
followers: (Follow & { user: { id: string; slack_id: string } })[];
|
||||
followerPersonalChannels: (Channel | null)[];
|
||||
};
|
||||
isOwner: boolean;
|
||||
currentUser: User;
|
||||
isPersonal: boolean;
|
||||
}
|
||||
|
||||
export default function ChannelSettingsClient({
|
||||
channel,
|
||||
isOwner,
|
||||
currentUser,
|
||||
isPersonal,
|
||||
}: ChannelSettingsClientProps) {
|
||||
const [streamKey, setStreamKey] = useState(channel.streamKey?.key || '');
|
||||
const [keyVisible, setKeyVisible] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const copyStreamKey = async () => {
|
||||
if (streamKey) {
|
||||
await navigator.clipboard.writeText(streamKey);
|
||||
setCopied(true);
|
||||
toast.success('Stream key copied to clipboard');
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const regenerateStreamKey = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/rtmp/streamKey', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ channel: channel.name }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setStreamKey(data.key);
|
||||
toast.success('Stream key regenerated successfully');
|
||||
} else {
|
||||
toast.error('Failed to regenerate stream key');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to regenerate stream key');
|
||||
}
|
||||
};
|
||||
console.log(isPersonal)
|
||||
|
||||
return (
|
||||
<div className="container max-w-4xl mx-auto py-6 px-4">
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<Avatar className="h-16 w-16">
|
||||
<AvatarImage src={channel.pfpUrl} alt={channel.name} />
|
||||
<AvatarFallback>{channel.name[0]?.toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">{channel.name}</h1>
|
||||
<p className="text-muted-foreground">Channel Settings</p>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Badge variant="secondary">
|
||||
{channel.followers.length} follower{channel.followers.length !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
{isOwner && <Badge variant="outline">Owner</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="general" className="w-full">
|
||||
<TabsList className={`grid w-full ${isPersonal ? 'grid-cols-3' : 'grid-cols-4'}`}>
|
||||
<TabsTrigger value="general" className="flex items-center gap-2">
|
||||
<Settings className="h-4 w-4" />
|
||||
General
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="stream" className="flex items-center gap-2">
|
||||
<Key className="h-4 w-4" />
|
||||
Streaming
|
||||
</TabsTrigger>
|
||||
{!isPersonal && (
|
||||
<TabsTrigger value="managers" className="flex items-center gap-2">
|
||||
<Users className="h-4 w-4" />
|
||||
Managers
|
||||
</TabsTrigger>
|
||||
)}
|
||||
<TabsTrigger value="notifications" className="flex items-center gap-2">
|
||||
<Bell className="h-4 w-4" />
|
||||
Notifications
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="general">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>General Settings</CardTitle>
|
||||
<CardDescription>
|
||||
Manage your channel's basic information and settings.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<UniversalForm
|
||||
fields={[
|
||||
{ name: 'channelId', type: 'hidden', value: channel.id, label: 'Channel ID' },
|
||||
{ name: 'name', label: 'Channel Name', type: 'text', value: channel.name },
|
||||
{
|
||||
name: 'pfpUrl',
|
||||
label: 'Profile Picture URL',
|
||||
type: 'url',
|
||||
value: channel.pfpUrl,
|
||||
},
|
||||
]}
|
||||
schemaName="updateChannelSettings"
|
||||
action={updateChannelSettings}
|
||||
submitText="Save Changes"
|
||||
onActionComplete={(result: any) => {
|
||||
if (result?.success) {
|
||||
toast.success('Channel settings updated successfully');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{false && isOwner && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-destructive">Danger Zone</h3>
|
||||
<Card className="border-destructive">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-destructive">Delete Channel</CardTitle>
|
||||
<CardDescription>
|
||||
Permanently delete this channel. This action cannot be undone.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
if (
|
||||
confirm(
|
||||
'Are you sure you want to delete this channel? This action cannot be undone.'
|
||||
)
|
||||
) {
|
||||
deleteChannel(channel.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete Channel
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="stream">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Streaming Settings</CardTitle>
|
||||
<CardDescription>Manage your stream key and streaming configuration.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-2">Stream Key</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Use this key to start streaming to your channel. Keep it secure!
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
type={keyVisible ? 'text' : 'password'}
|
||||
value={streamKey}
|
||||
readOnly
|
||||
className="w-full px-3 py-2 border rounded-md bg-muted font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => setKeyVisible(!keyVisible)}>
|
||||
{keyVisible ? 'Hide' : 'Show'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={copyStreamKey}
|
||||
disabled={!streamKey}
|
||||
>
|
||||
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button onClick={regenerateStreamKey} variant="outline">
|
||||
<Key className="h-4 w-4 mr-2" />
|
||||
Regenerate Stream Key
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">Stream Information</h3>
|
||||
{channel.streamInfo.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{channel.streamInfo.map((stream) => (
|
||||
<UniversalForm
|
||||
key={stream.id}
|
||||
fields={[
|
||||
{
|
||||
name: 'username',
|
||||
type: 'hidden',
|
||||
value: stream.username,
|
||||
label: 'Username',
|
||||
},
|
||||
{
|
||||
name: 'title',
|
||||
label: 'Stream Title',
|
||||
type: 'text',
|
||||
value: stream.title,
|
||||
},
|
||||
{
|
||||
name: 'category',
|
||||
label: 'Category',
|
||||
type: 'text',
|
||||
value: stream.category,
|
||||
},
|
||||
]}
|
||||
schemaName="streamInfoEdit"
|
||||
action={updateChannelSettings}
|
||||
submitText="Update Stream Info"
|
||||
onActionComplete={(result: any) => {
|
||||
if (result?.success) {
|
||||
toast.success('Stream information updated');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground">No stream information available.</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{!isPersonal && (
|
||||
<TabsContent value="managers">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Channel Managers</CardTitle>
|
||||
<CardDescription>
|
||||
Manage who can help moderate and stream to this channel.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">Current Managers</h3>
|
||||
{isOwner && (
|
||||
<AddManagerDialog
|
||||
channelId={channel.id}
|
||||
existingManagers={[
|
||||
...channel.managers.map((m) => m.id),
|
||||
channel.owner.id,
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* Owner */}
|
||||
<div className="flex items-center justify-between p-3 border rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-10 w-10">
|
||||
<AvatarImage src={channel.owner.pfpUrl} />
|
||||
<AvatarFallback>
|
||||
{channel.owner.slack_id[0]?.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<p className="font-medium">{channel.ownerPersonalChannel?.name}</p>
|
||||
<p className="text-sm text-muted-foreground">Channel Owner</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="default">
|
||||
<Shield className="h-3 w-3 mr-1" />
|
||||
Owner
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Managers */}
|
||||
{channel.managers.map((manager) => {
|
||||
const personalChannel = channel.managerPersonalChannels.find(
|
||||
(c) => c?.ownerId === manager.id
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={manager.id}
|
||||
className="flex items-center justify-between p-3 border rounded-lg"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-10 w-10">
|
||||
<AvatarImage src={manager.pfpUrl} />
|
||||
<AvatarFallback>{personalChannel?.name}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<p className="font-medium">{personalChannel?.name}</p>
|
||||
<p className="text-sm text-muted-foreground">Manager</p>
|
||||
</div>
|
||||
</div>
|
||||
{isOwner && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (confirm('Remove this manager?')) {
|
||||
removeChannelManager(channel.id, manager.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<UserMinus className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{channel.managers.length === 0 && (
|
||||
<p className="text-muted-foreground text-center py-8">
|
||||
No managers added yet.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
<TabsContent value="notifications">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Notification Settings</CardTitle>
|
||||
<CardDescription>
|
||||
Configure when and how followers are notified about your streams.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-medium">Stream Notifications</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Send notifications to followers when you go live
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={channel.streamInfo[0]?.enableNotifications ?? true}
|
||||
onCheckedChange={(checked) => {
|
||||
toast.promise(toggleGlobalChannelNotifs(channel.id), {
|
||||
loading: 'Updating notifications...',
|
||||
success(data) {
|
||||
return `${data.toggle ? 'Enabled' : 'Disabled'} global notifications for this channel.`
|
||||
},
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<h3 className="font-medium mb-3">Followers ({channel.followers.length})</h3>
|
||||
<div className="space-y-2 max-h-60 overflow-y-auto">
|
||||
{channel.followers.map((follower) => {
|
||||
const personalChannel = channel.followerPersonalChannels.find(
|
||||
(c) => c?.ownerId === follower.user.id
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={follower.id}
|
||||
className="flex items-center justify-between p-2 border rounded"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarFallback>{personalChannel?.name}</AvatarFallback>
|
||||
<AvatarImage src={personalChannel?.pfpUrl} alt={personalChannel?.name} />
|
||||
</Avatar>
|
||||
<span className="text-sm">{personalChannel?.name}</span>
|
||||
</div>
|
||||
<Badge variant={follower.notifyStream ? 'default' : 'secondary'}>
|
||||
{follower.notifyStream ? 'Notifications On' : 'Notifications Off'}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{channel.followers.length === 0 && (
|
||||
<p className="text-muted-foreground text-center py-4">No followers yet.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AddManagerDialog({
|
||||
channelId,
|
||||
existingManagers,
|
||||
}: {
|
||||
channelId: string;
|
||||
existingManagers: string[];
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [channel, setChannel] = useState('');
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm">
|
||||
<UserPlus className="h-4 w-4 mr-2" />
|
||||
Add Manager
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add channel manager</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add a channel manager to help manage your channel during big events or projects.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<UserCombobox
|
||||
onValueChange={(value) => {
|
||||
setChannel(value);
|
||||
}}
|
||||
filter={existingManagers}
|
||||
value={channel}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
toast.promise(addChannelManager(channelId, channel), {
|
||||
loading: 'Adding manager...',
|
||||
success: 'Manager added successfully',
|
||||
error: 'Failed to add manager',
|
||||
});
|
||||
setOpen(false);
|
||||
setChannel('');
|
||||
}}
|
||||
>
|
||||
Add Manager
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { validateRequest } from '@/lib/auth/validate';
|
||||
import { prisma } from '@hctv/db';
|
||||
import { redirect } from 'next/navigation';
|
||||
import ChannelSettingsClient from './page.client';
|
||||
import { resolvePersonalChannel } from '@/lib/auth/resolve';
|
||||
|
||||
export default async function ChannelSettingsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ channelName: string }>;
|
||||
}) {
|
||||
const { channelName } = await params;
|
||||
const { user } = await validateRequest();
|
||||
|
||||
if (!user) {
|
||||
redirect('/auth/slack');
|
||||
}
|
||||
|
||||
const channel = await prisma.channel.findUnique({
|
||||
where: { name: channelName },
|
||||
include: {
|
||||
owner: true,
|
||||
managers: true,
|
||||
streamInfo: true,
|
||||
streamKey: true,
|
||||
followers: {
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
slack_id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
personalFor: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!channel) {
|
||||
redirect('/');
|
||||
}
|
||||
|
||||
const isOwner = channel.ownerId === user.id;
|
||||
const isManager = channel.managers.some((manager) => manager.id === user.id);
|
||||
|
||||
if (!isOwner && !isManager) {
|
||||
redirect('/');
|
||||
}
|
||||
|
||||
const ownerPersonalChannel = await resolvePersonalChannel(channel.ownerId);
|
||||
const managerPersonalChannels = await Promise.all(
|
||||
channel.managers.map((manager) => resolvePersonalChannel(manager.id))
|
||||
);
|
||||
const followerPersonalChannels = await Promise.all(
|
||||
channel.followers.map((follower) => resolvePersonalChannel(follower.user.id))
|
||||
);
|
||||
|
||||
return (
|
||||
<ChannelSettingsClient
|
||||
channel={{
|
||||
...channel,
|
||||
ownerPersonalChannel,
|
||||
managerPersonalChannels,
|
||||
followerPersonalChannels,
|
||||
}}
|
||||
isOwner={isOwner}
|
||||
currentUser={user}
|
||||
isPersonal={channel.personalFor !== null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -19,12 +19,13 @@ import React from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { createChannelSchema, onboardSchema, streamInfoEditSchema } from '@/lib/form/zod';
|
||||
import { createChannelSchema, onboardSchema, streamInfoEditSchema, updateChannelSettingsSchema } from '@/lib/form/zod';
|
||||
|
||||
export const schemaDb = [
|
||||
{ name: 'streamInfoEdit', zod: streamInfoEditSchema },
|
||||
{ name: 'onboard', zod: onboardSchema },
|
||||
{ name: 'createChannel', zod: createChannelSchema }
|
||||
{ name: 'createChannel', zod: createChannelSchema },
|
||||
{ name: 'updateChannelSettings', zod: updateChannelSettingsSchema },
|
||||
] as const;
|
||||
|
||||
export function UniversalForm<T extends z.ZodType>({
|
||||
|
||||
107
apps/web/src/components/app/UserCombobox/UserCombobox.tsx
Normal file
107
apps/web/src/components/app/UserCombobox/UserCombobox.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Check, ChevronsUpDown } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
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 useSWR from 'swr';
|
||||
import { fetcher } from '@/lib/services/swr';
|
||||
import { Channel, StreamInfo } from '@hctv/db';
|
||||
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;
|
||||
const {
|
||||
data: fetchedUsers,
|
||||
error,
|
||||
isLoading,
|
||||
} = useSWR<APIResponse>(
|
||||
props.users ? null : '/api/stream/info?personal=true',
|
||||
fetcher
|
||||
);
|
||||
|
||||
const users = props.users || fetchedUsers;
|
||||
|
||||
if (!props.users && error) return <div>Error loading users</div>;
|
||||
if (!props.users && isLoading) return <div>Loading...</div>;
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
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...'}
|
||||
<ChevronsUpDown className="opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search user..." className="h-9" />
|
||||
<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>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
type APIResponse = (StreamInfo & { channel: Channel })[];
|
||||
type Props = {
|
||||
users?: APIResponse;
|
||||
value?: string;
|
||||
filter?: string[];
|
||||
onValueChange?: (value: string) => void;
|
||||
}
|
||||
36
apps/web/src/components/ui/badge.tsx
Normal file
36
apps/web/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
66
apps/web/src/components/ui/tabs.tsx
Normal file
66
apps/web/src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
className={cn(
|
||||
"bg-mantle text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"data-[state=active]:bg-background/50 dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-xs [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
@@ -43,4 +43,47 @@ export async function resolveFollowedChannels(id?: string) {
|
||||
return null;
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
export async function resolvePersonalChannel(id?: string) {
|
||||
const { user } = await validateRequest();
|
||||
const db = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: id ?? user?.id,
|
||||
},
|
||||
select: {
|
||||
personalChannel: true,
|
||||
},
|
||||
});
|
||||
if (!db) {
|
||||
return null;
|
||||
}
|
||||
return db.personalChannel;
|
||||
}
|
||||
|
||||
export async function resolveUserFromPersonalChannelName(channelName: string) {
|
||||
const db = await prisma.channel.findUnique({
|
||||
where: {
|
||||
name: channelName,
|
||||
},
|
||||
select: {
|
||||
personalFor: true,
|
||||
},
|
||||
});
|
||||
if (!db) {
|
||||
return null;
|
||||
}
|
||||
return db.personalFor;
|
||||
}
|
||||
|
||||
export async function resolveStreamInfo(channelId: string) {
|
||||
const db = await prisma.streamInfo.findFirst({
|
||||
where: {
|
||||
channelId,
|
||||
},
|
||||
});
|
||||
if (!db) {
|
||||
return null;
|
||||
}
|
||||
return db;
|
||||
}
|
||||
@@ -4,9 +4,9 @@ import { revalidatePath } from 'next/cache';
|
||||
import { validateRequest } from '@/lib/auth/validate';
|
||||
import { prisma } from '@hctv/db';
|
||||
import zodVerify from '../zodVerify';
|
||||
import { createChannelSchema, onboardSchema, streamInfoEditSchema } from './zod';
|
||||
import { createChannelSchema, onboardSchema, streamInfoEditSchema, updateChannelSettingsSchema } from './zod';
|
||||
import { initializeStreamInfo } from '../instrumentation/streamInfo';
|
||||
import { resolveFollowedChannels } from '../auth/resolve';
|
||||
import { resolveFollowedChannels, resolveStreamInfo, resolveUserFromPersonalChannelName } from '../auth/resolve';
|
||||
import { genIdenticonUpload } from '../utils/genIdenticonUpload';
|
||||
|
||||
export async function editStreamInfo(prev: any, formData: FormData) {
|
||||
@@ -156,4 +156,197 @@ export async function createChannel(prev: any, formData: FormData) {
|
||||
await initializeStreamInfo(createdChannel.id);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function updateChannelSettings(prev: any, formData: FormData) {
|
||||
const { user } = await validateRequest();
|
||||
if (!user) {
|
||||
return { success: false, error: 'Unauthorized' };
|
||||
}
|
||||
|
||||
const zod = await zodVerify(updateChannelSettingsSchema, formData);
|
||||
if (!zod.success) {
|
||||
return zod;
|
||||
}
|
||||
|
||||
const channel = await prisma.channel.findUnique({
|
||||
where: { id: zod.data.channelId },
|
||||
include: {
|
||||
owner: true,
|
||||
managers: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!channel) {
|
||||
return { success: false, error: 'Channel not found' };
|
||||
}
|
||||
|
||||
const isOwner = channel.ownerId === user.id;
|
||||
const isManager = channel.managers.some(manager => manager.id === user.id);
|
||||
|
||||
if (!isOwner && !isManager) {
|
||||
return { success: false, error: 'Unauthorized' };
|
||||
}
|
||||
|
||||
// Only owners can change certain settings
|
||||
const updateData: any = {};
|
||||
if (zod.data.name && isOwner) {
|
||||
updateData.name = zod.data.name;
|
||||
}
|
||||
if (zod.data.pfpUrl) {
|
||||
updateData.pfpUrl = zod.data.pfpUrl;
|
||||
}
|
||||
|
||||
await prisma.channel.update({
|
||||
where: { id: zod.data.channelId },
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
revalidatePath(`/settings/channel/${channel.name}`);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function addChannelManager(channelId: string, userChannel: string) {
|
||||
const { user } = await validateRequest();
|
||||
if (!user) {
|
||||
return { success: false, error: 'Unauthorized' };
|
||||
}
|
||||
|
||||
const channel = await prisma.channel.findUnique({
|
||||
where: { id: channelId, personalFor: null },
|
||||
include: { owner: true, managers: true },
|
||||
});
|
||||
|
||||
if (!channel) {
|
||||
return { success: false, error: 'Channel not found OR is personal.' };
|
||||
}
|
||||
|
||||
if (channel.ownerId !== user.id) {
|
||||
return { success: false, error: 'Only channel owners can add managers' };
|
||||
}
|
||||
|
||||
if (channel.ownerId === userChannel) {
|
||||
return { success: false, error: 'Owner can\'t add themselves as managers' };
|
||||
}
|
||||
|
||||
const userDb = await resolveUserFromPersonalChannelName(userChannel);
|
||||
if (!userDb) {
|
||||
return { success: false, error: 'User not found' };
|
||||
}
|
||||
if (channel.managers.some((m) => m.id === userDb.id)) {
|
||||
return { success: false, error: 'User is already a manager' };
|
||||
}
|
||||
|
||||
await prisma.channel.update({
|
||||
where: { id: channelId },
|
||||
data: {
|
||||
managers: {
|
||||
connect: { id: userDb.id },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath(`/settings/channel/${channel.name}`);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function removeChannelManager(channelId: string, userId: string) {
|
||||
const { user } = await validateRequest();
|
||||
if (!user) {
|
||||
return { success: false, error: 'Unauthorized' };
|
||||
}
|
||||
|
||||
const channel = await prisma.channel.findUnique({
|
||||
where: { id: channelId },
|
||||
include: { owner: true },
|
||||
});
|
||||
|
||||
if (!channel) {
|
||||
return { success: false, error: 'Channel not found' };
|
||||
}
|
||||
|
||||
if (channel.ownerId !== user.id) {
|
||||
return { success: false, error: 'Only channel owners can remove managers' };
|
||||
}
|
||||
|
||||
await prisma.channel.update({
|
||||
where: { id: channelId },
|
||||
data: {
|
||||
managers: {
|
||||
disconnect: { id: userId },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath(`/settings/channel/${channel.name}`);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function toggleGlobalChannelNotifs(channelId: string) {
|
||||
const { user } = await validateRequest();
|
||||
if (!user) {
|
||||
return { success: false, error: 'Unauthorized' };
|
||||
}
|
||||
|
||||
const channel = await prisma.channel.findUnique({
|
||||
where: { id: channelId },
|
||||
include: { followers: true },
|
||||
});
|
||||
|
||||
if (!channel) {
|
||||
return { success: false, error: 'Channel not found' };
|
||||
}
|
||||
|
||||
const streamInfo = await resolveStreamInfo(channelId);
|
||||
if (!streamInfo) {
|
||||
return { success: false, error: 'Stream info not found' };
|
||||
}
|
||||
|
||||
await prisma.streamInfo.update({
|
||||
where: {
|
||||
id: streamInfo.id,
|
||||
},
|
||||
data: {
|
||||
enableNotifications: !streamInfo.enableNotifications,
|
||||
}
|
||||
})
|
||||
|
||||
revalidatePath(`/settings/channel/${channel.name}`);
|
||||
|
||||
return { success: true, toggle: !streamInfo.enableNotifications };
|
||||
}
|
||||
|
||||
export async function deleteChannel(channelId: string) {
|
||||
return { success: false, error: 'disabled atm. dm @eth0 if you want to request a deletion.' }
|
||||
/* const { user } = await validateRequest();
|
||||
if (!user) {
|
||||
return { success: false, error: 'Unauthorized' };
|
||||
}
|
||||
|
||||
const channel = await prisma.channel.findUnique({
|
||||
where: { id: channelId },
|
||||
include: {
|
||||
owner: true,
|
||||
personalFor: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!channel) {
|
||||
return { success: false, error: 'Channel not found' };
|
||||
}
|
||||
|
||||
if (channel.ownerId !== user.id) {
|
||||
return { success: false, error: 'Only channel owners can delete channels' };
|
||||
}
|
||||
|
||||
// Prevent deletion of personal channels
|
||||
if (channel.personalFor) {
|
||||
return { success: false, error: 'Cannot delete personal channels' };
|
||||
}
|
||||
|
||||
await prisma.channel.delete({
|
||||
where: { id: channelId },
|
||||
});
|
||||
|
||||
return { success: true }; */
|
||||
}
|
||||
@@ -18,4 +18,10 @@ export const onboardSchema = z.object({
|
||||
|
||||
export const createChannelSchema = z.object({
|
||||
name: username,
|
||||
});
|
||||
|
||||
export const updateChannelSettingsSchema = z.object({
|
||||
channelId: z.string().min(1),
|
||||
name: username.optional(),
|
||||
pfpUrl: z.string().url().optional(),
|
||||
});
|
||||
@@ -10,7 +10,8 @@
|
||||
},
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc --build"
|
||||
"build": "tsc --build",
|
||||
"dev": "tsc --watch --preserveWatchOutput"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hctv/db": "*",
|
||||
|
||||
@@ -21,6 +21,7 @@ export const lucia = new Lucia(adapter, {
|
||||
slack_id: attributes.slack_id,
|
||||
pfpUrl: attributes.pfpUrl,
|
||||
hasOnboarded: attributes.hasOnboarded,
|
||||
personalChannelId: attributes.personalChannelId,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -36,4 +37,5 @@ interface DatabaseUserAttributes {
|
||||
slack_id: string;
|
||||
pfpUrl: string;
|
||||
hasOnboarded: boolean;
|
||||
personalChannelId: string | null;
|
||||
}
|
||||
|
||||
113
yarn.lock
113
yarn.lock
@@ -1038,6 +1038,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/primitive/-/primitive-1.1.1.tgz#fc169732d755c7fbad33ba8d0cd7fd10c90dc8e3"
|
||||
integrity sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==
|
||||
|
||||
"@radix-ui/primitive@1.1.2":
|
||||
version "1.1.2"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/primitive/-/primitive-1.1.2.tgz#83f415c4425f21e3d27914c12b3272a32e3dae65"
|
||||
integrity sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==
|
||||
|
||||
"@radix-ui/react-arrow@1.1.2":
|
||||
version "1.1.2"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-arrow/-/react-arrow-1.1.2.tgz#30c0d574d7bb10eed55cd7007b92d38b03c6b2ab"
|
||||
@@ -1079,6 +1084,16 @@
|
||||
"@radix-ui/react-primitive" "2.0.2"
|
||||
"@radix-ui/react-slot" "1.1.2"
|
||||
|
||||
"@radix-ui/react-collection@1.1.7":
|
||||
version "1.1.7"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-collection/-/react-collection-1.1.7.tgz#d05c25ca9ac4695cc19ba91f42f686e3ea2d9aec"
|
||||
integrity sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==
|
||||
dependencies:
|
||||
"@radix-ui/react-compose-refs" "1.1.2"
|
||||
"@radix-ui/react-context" "1.1.2"
|
||||
"@radix-ui/react-primitive" "2.1.3"
|
||||
"@radix-ui/react-slot" "1.2.3"
|
||||
|
||||
"@radix-ui/react-compose-refs@1.0.1":
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.1.tgz#7ed868b66946aa6030e580b1ffca386dd4d21989"
|
||||
@@ -1091,6 +1106,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz#6f766faa975f8738269ebb8a23bad4f5a8d2faec"
|
||||
integrity sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==
|
||||
|
||||
"@radix-ui/react-compose-refs@1.1.2":
|
||||
version "1.1.2"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz#a2c4c47af6337048ee78ff6dc0d090b390d2bb30"
|
||||
integrity sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==
|
||||
|
||||
"@radix-ui/react-context@1.0.1":
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-context/-/react-context-1.0.1.tgz#fe46e67c96b240de59187dcb7a1a50ce3e2ec00c"
|
||||
@@ -1103,6 +1123,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-context/-/react-context-1.1.1.tgz#82074aa83a472353bb22e86f11bcbd1c61c4c71a"
|
||||
integrity sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==
|
||||
|
||||
"@radix-ui/react-context@1.1.2":
|
||||
version "1.1.2"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-context/-/react-context-1.1.2.tgz#61628ef269a433382c364f6f1e3788a6dc213a36"
|
||||
integrity sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==
|
||||
|
||||
"@radix-ui/react-dialog@1.0.5":
|
||||
version "1.0.5"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-dialog/-/react-dialog-1.0.5.tgz#71657b1b116de6c7a0b03242d7d43e01062c7300"
|
||||
@@ -1149,6 +1174,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-direction/-/react-direction-1.1.0.tgz#a7d39855f4d077adc2a1922f9c353c5977a09cdc"
|
||||
integrity sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==
|
||||
|
||||
"@radix-ui/react-direction@1.1.1":
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-direction/-/react-direction-1.1.1.tgz#39e5a5769e676c753204b792fbe6cf508e550a14"
|
||||
integrity sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==
|
||||
|
||||
"@radix-ui/react-dismissable-layer@1.0.5":
|
||||
version "1.0.5"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.0.5.tgz#3f98425b82b9068dfbab5db5fff3df6ebf48b9d4"
|
||||
@@ -1231,6 +1261,13 @@
|
||||
dependencies:
|
||||
"@radix-ui/react-use-layout-effect" "1.1.0"
|
||||
|
||||
"@radix-ui/react-id@1.1.1":
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-id/-/react-id-1.1.1.tgz#1404002e79a03fe062b7e3864aa01e24bd1471f7"
|
||||
integrity sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==
|
||||
dependencies:
|
||||
"@radix-ui/react-use-layout-effect" "1.1.1"
|
||||
|
||||
"@radix-ui/react-label@^2.1.1":
|
||||
version "2.1.2"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-label/-/react-label-2.1.2.tgz#994a5d815c2ff46e151410ae4e301f1b639f9971"
|
||||
@@ -1332,6 +1369,14 @@
|
||||
"@radix-ui/react-compose-refs" "1.1.1"
|
||||
"@radix-ui/react-use-layout-effect" "1.1.0"
|
||||
|
||||
"@radix-ui/react-presence@1.1.4":
|
||||
version "1.1.4"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-presence/-/react-presence-1.1.4.tgz#253ac0ad4946c5b4a9c66878335f5cf07c967ced"
|
||||
integrity sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA==
|
||||
dependencies:
|
||||
"@radix-ui/react-compose-refs" "1.1.2"
|
||||
"@radix-ui/react-use-layout-effect" "1.1.1"
|
||||
|
||||
"@radix-ui/react-primitive@1.0.3":
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-primitive/-/react-primitive-1.0.3.tgz#d49ea0f3f0b2fe3ab1cb5667eb03e8b843b914d0"
|
||||
@@ -1347,6 +1392,28 @@
|
||||
dependencies:
|
||||
"@radix-ui/react-slot" "1.1.2"
|
||||
|
||||
"@radix-ui/react-primitive@2.1.3":
|
||||
version "2.1.3"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz#db9b8bcff49e01be510ad79893fb0e4cda50f1bc"
|
||||
integrity sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==
|
||||
dependencies:
|
||||
"@radix-ui/react-slot" "1.2.3"
|
||||
|
||||
"@radix-ui/react-roving-focus@1.1.10":
|
||||
version "1.1.10"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.10.tgz#46030496d2a490c4979d29a7e1252465e51e4b0b"
|
||||
integrity sha512-dT9aOXUen9JSsxnMPv/0VqySQf5eDQ6LCk5Sw28kamz8wSOW2bJdlX2Bg5VUIIcV+6XlHpWTIuTPCf/UNIyq8Q==
|
||||
dependencies:
|
||||
"@radix-ui/primitive" "1.1.2"
|
||||
"@radix-ui/react-collection" "1.1.7"
|
||||
"@radix-ui/react-compose-refs" "1.1.2"
|
||||
"@radix-ui/react-context" "1.1.2"
|
||||
"@radix-ui/react-direction" "1.1.1"
|
||||
"@radix-ui/react-id" "1.1.1"
|
||||
"@radix-ui/react-primitive" "2.1.3"
|
||||
"@radix-ui/react-use-callback-ref" "1.1.1"
|
||||
"@radix-ui/react-use-controllable-state" "1.2.2"
|
||||
|
||||
"@radix-ui/react-roving-focus@1.1.2":
|
||||
version "1.1.2"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.2.tgz#815d051a54299114a68db6eb8d34c41a3c0a646f"
|
||||
@@ -1411,6 +1478,13 @@
|
||||
dependencies:
|
||||
"@radix-ui/react-compose-refs" "1.1.1"
|
||||
|
||||
"@radix-ui/react-slot@1.2.3":
|
||||
version "1.2.3"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.2.3.tgz#502d6e354fc847d4169c3bc5f189de777f68cfe1"
|
||||
integrity sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==
|
||||
dependencies:
|
||||
"@radix-ui/react-compose-refs" "1.1.2"
|
||||
|
||||
"@radix-ui/react-switch@^1.1.3":
|
||||
version "1.1.3"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-switch/-/react-switch-1.1.3.tgz#cb6386909d1d3f65a2b81a3b15da8c91d18f49b0"
|
||||
@@ -1424,6 +1498,20 @@
|
||||
"@radix-ui/react-use-previous" "1.1.0"
|
||||
"@radix-ui/react-use-size" "1.1.0"
|
||||
|
||||
"@radix-ui/react-tabs@^1.1.12":
|
||||
version "1.1.12"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-tabs/-/react-tabs-1.1.12.tgz#99b3522c73db9263f429a6d0f5a9acb88df3b129"
|
||||
integrity sha512-GTVAlRVrQrSw3cEARM0nAx73ixrWDPNZAruETn3oHCNP6SbZ/hNxdxp+u7VkIEv3/sFoLq1PfcHrl7Pnp0CDpw==
|
||||
dependencies:
|
||||
"@radix-ui/primitive" "1.1.2"
|
||||
"@radix-ui/react-context" "1.1.2"
|
||||
"@radix-ui/react-direction" "1.1.1"
|
||||
"@radix-ui/react-id" "1.1.1"
|
||||
"@radix-ui/react-presence" "1.1.4"
|
||||
"@radix-ui/react-primitive" "2.1.3"
|
||||
"@radix-ui/react-roving-focus" "1.1.10"
|
||||
"@radix-ui/react-use-controllable-state" "1.2.2"
|
||||
|
||||
"@radix-ui/react-tooltip@^1.1.6":
|
||||
version "1.1.8"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-tooltip/-/react-tooltip-1.1.8.tgz#1aa2a575630fca2b2845b62f85056bb826bec456"
|
||||
@@ -1454,6 +1542,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz#bce938ca413675bc937944b0d01ef6f4a6dc5bf1"
|
||||
integrity sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==
|
||||
|
||||
"@radix-ui/react-use-callback-ref@1.1.1":
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz#62a4dba8b3255fdc5cc7787faeac1c6e4cc58d40"
|
||||
integrity sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==
|
||||
|
||||
"@radix-ui/react-use-controllable-state@1.0.1":
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.0.1.tgz#ecd2ced34e6330caf89a82854aa2f77e07440286"
|
||||
@@ -1469,6 +1562,21 @@
|
||||
dependencies:
|
||||
"@radix-ui/react-use-callback-ref" "1.1.0"
|
||||
|
||||
"@radix-ui/react-use-controllable-state@1.2.2":
|
||||
version "1.2.2"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz#905793405de57d61a439f4afebbb17d0645f3190"
|
||||
integrity sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==
|
||||
dependencies:
|
||||
"@radix-ui/react-use-effect-event" "0.0.2"
|
||||
"@radix-ui/react-use-layout-effect" "1.1.1"
|
||||
|
||||
"@radix-ui/react-use-effect-event@0.0.2":
|
||||
version "0.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz#090cf30d00a4c7632a15548512e9152217593907"
|
||||
integrity sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==
|
||||
dependencies:
|
||||
"@radix-ui/react-use-layout-effect" "1.1.1"
|
||||
|
||||
"@radix-ui/react-use-escape-keydown@1.0.3":
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.0.3.tgz#217b840c250541609c66f67ed7bab2b733620755"
|
||||
@@ -1496,6 +1604,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.0.tgz#3c2c8ce04827b26a39e442ff4888d9212268bd27"
|
||||
integrity sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==
|
||||
|
||||
"@radix-ui/react-use-layout-effect@1.1.1":
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz#0c4230a9eed49d4589c967e2d9c0d9d60a23971e"
|
||||
integrity sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==
|
||||
|
||||
"@radix-ui/react-use-previous@1.1.0":
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-previous/-/react-use-previous-1.1.0.tgz#d4dd37b05520f1d996a384eb469320c2ada8377c"
|
||||
|
||||
Reference in New Issue
Block a user