feat: initial channel settings implementation

This commit is contained in:
2025-06-21 01:38:46 +02:00
parent 338254889a
commit 818566b1c5
14 changed files with 1207 additions and 30 deletions

View File

@@ -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",

View File

@@ -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);
}

View File

@@ -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&apos;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>
);
}

View File

@@ -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}
/>
);
}

View File

@@ -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>({

View 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;
}

View 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 }

View 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 }

View File

@@ -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;
}

View File

@@ -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 }; */
}

View File

@@ -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(),
});

View File

@@ -10,7 +10,8 @@
},
"type": "module",
"scripts": {
"build": "tsc --build"
"build": "tsc --build",
"dev": "tsc --watch --preserveWatchOutput"
},
"dependencies": {
"@hctv/db": "*",

View File

@@ -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
View File

@@ -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"