From 818566b1c5b1097c63da9e05b1ac59cb4287e51f Mon Sep 17 00:00:00 2001
From: Izan Gil <66965250+SrIzan10@users.noreply.github.com>
Date: Sat, 21 Jun 2025 01:38:46 +0200
Subject: [PATCH] feat: initial channel settings implementation
---
apps/web/package.json | 1 +
.../app/(protected)/api/stream/info/route.ts | 71 ++-
.../channel/[channelName]/page.client.tsx | 515 ++++++++++++++++++
.../settings/channel/[channelName]/page.tsx | 72 +++
.../app/UniversalForm/UniversalForm.tsx | 5 +-
.../app/UserCombobox/UserCombobox.tsx | 107 ++++
apps/web/src/components/ui/badge.tsx | 36 ++
apps/web/src/components/ui/tabs.tsx | 66 +++
apps/web/src/lib/auth/resolve.ts | 43 ++
apps/web/src/lib/form/actions.ts | 197 ++++++-
apps/web/src/lib/form/zod.ts | 6 +
packages/auth/package.json | 3 +-
packages/auth/src/index.ts | 2 +
yarn.lock | 113 ++++
14 files changed, 1207 insertions(+), 30 deletions(-)
create mode 100644 apps/web/src/app/(protected)/settings/channel/[channelName]/page.client.tsx
create mode 100644 apps/web/src/app/(protected)/settings/channel/[channelName]/page.tsx
create mode 100644 apps/web/src/components/app/UserCombobox/UserCombobox.tsx
create mode 100644 apps/web/src/components/ui/badge.tsx
create mode 100644 apps/web/src/components/ui/tabs.tsx
diff --git a/apps/web/package.json b/apps/web/package.json
index e6e8342..0c2f7fe 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -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",
diff --git a/apps/web/src/app/(protected)/api/stream/info/route.ts b/apps/web/src/app/(protected)/api/stream/info/route.ts
index ef79fa1..03ba31f 100644
--- a/apps/web/src/app/(protected)/api/stream/info/route.ts
+++ b/apps/web/src/app/(protected)/api/stream/info/route.ts
@@ -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);
+}
\ No newline at end of file
diff --git a/apps/web/src/app/(protected)/settings/channel/[channelName]/page.client.tsx b/apps/web/src/app/(protected)/settings/channel/[channelName]/page.client.tsx
new file mode 100644
index 0000000..18b7405
--- /dev/null
+++ b/apps/web/src/app/(protected)/settings/channel/[channelName]/page.client.tsx
@@ -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 (
+
+
+
+
+
+ {channel.name[0]?.toUpperCase()}
+
+
+
{channel.name}
+
Channel Settings
+
+
+ {channel.followers.length} follower{channel.followers.length !== 1 ? 's' : ''}
+
+ {isOwner && Owner}
+
+
+
+
+
+
+
+
+
+ General
+
+
+
+ Streaming
+
+ {!isPersonal && (
+
+
+ Managers
+
+ )}
+
+
+ Notifications
+
+
+
+
+
+
+ General Settings
+
+ Manage your channel's basic information and settings.
+
+
+
+ {
+ if (result?.success) {
+ toast.success('Channel settings updated successfully');
+ }
+ }}
+ />
+
+ {false && isOwner && (
+ <>
+
+
+
Danger Zone
+
+
+ Delete Channel
+
+ Permanently delete this channel. This action cannot be undone.
+
+
+
+
+
+
+
+ >
+ )}
+
+
+
+
+
+
+
+ Streaming Settings
+ Manage your stream key and streaming configuration.
+
+
+
+
+
Stream Key
+
+ Use this key to start streaming to your channel. Keep it secure!
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Stream Information
+ {channel.streamInfo.length > 0 ? (
+
+ {channel.streamInfo.map((stream) => (
+ {
+ if (result?.success) {
+ toast.success('Stream information updated');
+ }
+ }}
+ />
+ ))}
+
+ ) : (
+
No stream information available.
+ )}
+
+
+
+
+
+ {!isPersonal && (
+
+
+
+ Channel Managers
+
+ Manage who can help moderate and stream to this channel.
+
+
+
+
+
+
Current Managers
+ {isOwner && (
+
m.id),
+ channel.owner.id,
+ ]}
+ />
+ )}
+
+
+
+ {/* Owner */}
+
+
+
+
+
+ {channel.owner.slack_id[0]?.toUpperCase()}
+
+
+
+
{channel.ownerPersonalChannel?.name}
+
Channel Owner
+
+
+
+
+ Owner
+
+
+
+ {/* Managers */}
+ {channel.managers.map((manager) => {
+ const personalChannel = channel.managerPersonalChannels.find(
+ (c) => c?.ownerId === manager.id
+ );
+ return (
+
+
+
+
+ {personalChannel?.name}
+
+
+
{personalChannel?.name}
+
Manager
+
+
+ {isOwner && (
+
+ )}
+
+ );
+ })}
+
+ {channel.managers.length === 0 && (
+
+ No managers added yet.
+
+ )}
+
+
+
+
+
+ )}
+
+
+
+
+ Notification Settings
+
+ Configure when and how followers are notified about your streams.
+
+
+
+
+
+
+
Stream Notifications
+
+ Send notifications to followers when you go live
+
+
+
{
+ toast.promise(toggleGlobalChannelNotifs(channel.id), {
+ loading: 'Updating notifications...',
+ success(data) {
+ return `${data.toggle ? 'Enabled' : 'Disabled'} global notifications for this channel.`
+ },
+ })
+ }}
+ />
+
+
+
+
+
+
Followers ({channel.followers.length})
+
+ {channel.followers.map((follower) => {
+ const personalChannel = channel.followerPersonalChannels.find(
+ (c) => c?.ownerId === follower.user.id
+ );
+ return (
+
+
+
+ {personalChannel?.name}
+
+
+
{personalChannel?.name}
+
+
+ {follower.notifyStream ? 'Notifications On' : 'Notifications Off'}
+
+
+ );
+ })}
+ {channel.followers.length === 0 && (
+
No followers yet.
+ )}
+
+
+
+
+
+
+
+
+ );
+}
+
+function AddManagerDialog({
+ channelId,
+ existingManagers,
+}: {
+ channelId: string;
+ existingManagers: string[];
+}) {
+ const [open, setOpen] = useState(false);
+ const [channel, setChannel] = useState('');
+
+ return (
+
+ );
+}
diff --git a/apps/web/src/app/(protected)/settings/channel/[channelName]/page.tsx b/apps/web/src/app/(protected)/settings/channel/[channelName]/page.tsx
new file mode 100644
index 0000000..e3e131c
--- /dev/null
+++ b/apps/web/src/app/(protected)/settings/channel/[channelName]/page.tsx
@@ -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 (
+
+ );
+}
diff --git a/apps/web/src/components/app/UniversalForm/UniversalForm.tsx b/apps/web/src/components/app/UniversalForm/UniversalForm.tsx
index e14fd44..48e84ff 100644
--- a/apps/web/src/components/app/UniversalForm/UniversalForm.tsx
+++ b/apps/web/src/components/app/UniversalForm/UniversalForm.tsx
@@ -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({
diff --git a/apps/web/src/components/app/UserCombobox/UserCombobox.tsx b/apps/web/src/components/app/UserCombobox/UserCombobox.tsx
new file mode 100644
index 0000000..f64d6ae
--- /dev/null
+++ b/apps/web/src/components/app/UserCombobox/UserCombobox.tsx
@@ -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(
+ props.users ? null : '/api/stream/info?personal=true',
+ fetcher
+ );
+
+ const users = props.users || fetchedUsers;
+
+ if (!props.users && error) return Error loading users
;
+ if (!props.users && isLoading) return Loading...
;
+ return (
+
+
+
+
+
+
+
+
+ No user found.
+
+ {users?.filter(user => !props.filter?.some(filterStr => user.userId === filterStr)).map((user) => (
+ {
+ setValue(currentValue === value ? '' : currentValue);
+ setOpen(false);
+ }}
+ >
+
+
+ {user.username[0]}
+
+ {user.username}
+
+
+ ))}
+
+
+
+
+
+ );
+}
+
+type APIResponse = (StreamInfo & { channel: Channel })[];
+type Props = {
+ users?: APIResponse;
+ value?: string;
+ filter?: string[];
+ onValueChange?: (value: string) => void;
+}
\ No newline at end of file
diff --git a/apps/web/src/components/ui/badge.tsx b/apps/web/src/components/ui/badge.tsx
new file mode 100644
index 0000000..f000e3e
--- /dev/null
+++ b/apps/web/src/components/ui/badge.tsx
@@ -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,
+ VariantProps {}
+
+function Badge({ className, variant, ...props }: BadgeProps) {
+ return (
+
+ )
+}
+
+export { Badge, badgeVariants }
diff --git a/apps/web/src/components/ui/tabs.tsx b/apps/web/src/components/ui/tabs.tsx
new file mode 100644
index 0000000..beebf79
--- /dev/null
+++ b/apps/web/src/components/ui/tabs.tsx
@@ -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) {
+ return (
+
+ )
+}
+
+function TabsList({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function TabsTrigger({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function TabsContent({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export { Tabs, TabsList, TabsTrigger, TabsContent }
diff --git a/apps/web/src/lib/auth/resolve.ts b/apps/web/src/lib/auth/resolve.ts
index 6b99907..31fae88 100644
--- a/apps/web/src/lib/auth/resolve.ts
+++ b/apps/web/src/lib/auth/resolve.ts
@@ -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;
}
\ No newline at end of file
diff --git a/apps/web/src/lib/form/actions.ts b/apps/web/src/lib/form/actions.ts
index 916f98b..c3d44ea 100644
--- a/apps/web/src/lib/form/actions.ts
+++ b/apps/web/src/lib/form/actions.ts
@@ -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 }; */
}
\ No newline at end of file
diff --git a/apps/web/src/lib/form/zod.ts b/apps/web/src/lib/form/zod.ts
index 32cc63f..f79721b 100644
--- a/apps/web/src/lib/form/zod.ts
+++ b/apps/web/src/lib/form/zod.ts
@@ -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(),
});
\ No newline at end of file
diff --git a/packages/auth/package.json b/packages/auth/package.json
index 16be15f..6557fe4 100644
--- a/packages/auth/package.json
+++ b/packages/auth/package.json
@@ -10,7 +10,8 @@
},
"type": "module",
"scripts": {
- "build": "tsc --build"
+ "build": "tsc --build",
+ "dev": "tsc --watch --preserveWatchOutput"
},
"dependencies": {
"@hctv/db": "*",
diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts
index eedfa54..8d17059 100644
--- a/packages/auth/src/index.ts
+++ b/packages/auth/src/index.ts
@@ -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;
}
diff --git a/yarn.lock b/yarn.lock
index 01becac..70bc57f 100644
--- a/yarn.lock
+++ b/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"