mirror of
https://github.com/SrIzan10/hctv.git
synced 2026-06-06 00:56:56 +00:00
feat: add ability to change usernames
This commit is contained in:
@@ -30,6 +30,7 @@ import {
|
||||
deleteChannel,
|
||||
toggleGlobalChannelNotifs,
|
||||
editStreamInfo,
|
||||
changeUsername,
|
||||
} from '@/lib/form/actions';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { toast } from 'sonner';
|
||||
@@ -74,6 +75,7 @@ interface ChannelSettingsClientProps {
|
||||
followers: (Follow & { user: { id: string; slack_id: string } })[];
|
||||
followerPersonalChannels: (Channel | null)[];
|
||||
is247: boolean;
|
||||
nameLastChanged: Date | null;
|
||||
};
|
||||
isOwner: boolean;
|
||||
currentUser: User;
|
||||
@@ -112,6 +114,32 @@ export default function ChannelSettingsClient({
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleUsernameChangeComplete = useCallback(
|
||||
(result: any) => {
|
||||
if (result?.success && result?.newUsername) {
|
||||
toast.success('Username changed successfully! Redirecting...');
|
||||
router.push(`/settings/channel/${result.newUsername}?tab=${selTab}`);
|
||||
}
|
||||
},
|
||||
[router, selTab]
|
||||
);
|
||||
|
||||
const getUsernameChangeCooldownInfo = () => {
|
||||
if (!channel.nameLastChanged) {
|
||||
return { canChange: true, daysRemaining: 0 };
|
||||
}
|
||||
const daysSinceLastChange = Math.floor(
|
||||
(Date.now() - new Date(channel.nameLastChanged).getTime()) / (1000 * 60 * 60 * 24)
|
||||
);
|
||||
const cooldownDays = 30;
|
||||
if (daysSinceLastChange >= cooldownDays) {
|
||||
return { canChange: true, daysRemaining: 0 };
|
||||
}
|
||||
return { canChange: false, daysRemaining: cooldownDays - daysSinceLastChange };
|
||||
};
|
||||
|
||||
const cooldownInfo = getUsernameChangeCooldownInfo();
|
||||
|
||||
const copyStreamKey = async () => {
|
||||
if (streamKey) {
|
||||
await navigator.clipboard.writeText(streamKey);
|
||||
@@ -179,10 +207,10 @@ export default function ChannelSettingsClient({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex-1' />
|
||||
<div className="flex-1" />
|
||||
<div>
|
||||
<ChannelSelect
|
||||
channelList={channelList.channels.map(c => c.channel)}
|
||||
channelList={channelList.channels.map((c) => c.channel)}
|
||||
value={channel.name}
|
||||
onSelect={(value) => {
|
||||
if (value === 'create') {
|
||||
@@ -216,7 +244,7 @@ export default function ChannelSettingsClient({
|
||||
Notifications
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="utilities" className="flex items-center gap-2">
|
||||
<Wrench className='size-4' />
|
||||
<Wrench className="size-4" />
|
||||
Utilities
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
@@ -242,7 +270,7 @@ export default function ChannelSettingsClient({
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<input type="hidden" {...field} />
|
||||
|
||||
|
||||
{field.value && (
|
||||
<div className="flex items-center space-x-4">
|
||||
<Avatar className="h-16 w-16">
|
||||
@@ -251,7 +279,9 @@ export default function ChannelSettingsClient({
|
||||
</Avatar>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">Current profile picture</p>
|
||||
<p className="text-xs text-muted-foreground">Click "Upload new image" to replace</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Click "Upload new image" to replace
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -266,14 +296,14 @@ export default function ChannelSettingsClient({
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
<div>
|
||||
<UploadButton
|
||||
endpoint="pfpUpload"
|
||||
className="mt-2 ut-button:bg-mantle ut-button:text-mantle-foreground ut-allowed-content:text-muted-foreground/70"
|
||||
content={{
|
||||
button: field.value ? "Upload new image" : "Upload profile picture",
|
||||
allowedContent: "Image (1MB max)"
|
||||
button: field.value ? 'Upload new image' : 'Upload profile picture',
|
||||
allowedContent: 'Image (1MB max)',
|
||||
}}
|
||||
onUploadBegin={() => {
|
||||
setIsUploading(true);
|
||||
@@ -293,19 +323,15 @@ export default function ChannelSettingsClient({
|
||||
}}
|
||||
disabled={isUploading}
|
||||
/>
|
||||
|
||||
|
||||
{isUploading && (
|
||||
<p className="mt-2 text-sm text-primary">
|
||||
Uploading...
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-primary">Uploading...</p>
|
||||
)}
|
||||
|
||||
|
||||
{uploadError && (
|
||||
<p className="mt-2 text-sm text-red-600">
|
||||
{uploadError}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-red-600">{uploadError}</p>
|
||||
)}
|
||||
|
||||
|
||||
{!field.value && !isUploading && !uploadError && (
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Upload a profile picture for your channel.
|
||||
@@ -351,7 +377,8 @@ export default function ChannelSettingsClient({
|
||||
<div>
|
||||
<label className="text-sm font-medium">24/7 Channel</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Mark this channel as always live. It will disable notifications on #hctv-streams.
|
||||
Mark this channel as always live. It will disable notifications on
|
||||
#hctv-streams.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
@@ -363,7 +390,7 @@ export default function ChannelSettingsClient({
|
||||
<input type="hidden" {...field} value={field.value ? 'true' : 'false'} />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
},
|
||||
]}
|
||||
schemaName="updateChannelSettings"
|
||||
action={updateChannelSettings}
|
||||
@@ -371,6 +398,48 @@ export default function ChannelSettingsClient({
|
||||
onActionComplete={handleChannelSettingsActionComplete}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
{isPersonal && isOwner && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-lg font-semibold">Username</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Your username is how others find and mention you on hctv. You can change it once
|
||||
every 30 days.
|
||||
</p>
|
||||
{!cooldownInfo.canChange && (
|
||||
<div className="p-3 border border-accent/20 rounded-lg bg-accent/5">
|
||||
<p className="text-sm text-accent">
|
||||
You can change your username again in {cooldownInfo.daysRemaining} day
|
||||
{cooldownInfo.daysRemaining === 1 ? '' : 's'}.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<UniversalForm
|
||||
fields={[
|
||||
{ name: 'channelId', type: 'hidden', value: channel.id, label: 'Channel ID' },
|
||||
{
|
||||
name: 'newUsername',
|
||||
label: 'New Username',
|
||||
type: 'text',
|
||||
value: '',
|
||||
placeholder: channel.name,
|
||||
description:
|
||||
'Only lowercase letters, numbers, underscores, and dashes. Max 20 characters.',
|
||||
inputFilter: /[^a-z0-9_-]/g,
|
||||
maxChars: 20,
|
||||
},
|
||||
]}
|
||||
schemaName="changeUsername"
|
||||
action={changeUsername}
|
||||
submitText="Change Username"
|
||||
onActionComplete={handleUsernameChangeComplete}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isOwner && !isPersonal && (
|
||||
<>
|
||||
<Separator />
|
||||
@@ -441,7 +510,11 @@ export default function ChannelSettingsClient({
|
||||
onClick={() => setKeyVisible(!keyVisible)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{keyVisible ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
{keyVisible ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<Button onClick={regenerateStreamKey} variant="outline" size="smicon">
|
||||
@@ -453,7 +526,11 @@ export default function ChannelSettingsClient({
|
||||
onClick={copyStreamKey}
|
||||
disabled={!streamKey}
|
||||
>
|
||||
{copied.streamKey ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
||||
{copied.streamKey ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -486,7 +563,11 @@ export default function ChannelSettingsClient({
|
||||
onClick={copyStreamUrl}
|
||||
disabled={!streamKey}
|
||||
>
|
||||
{copied.streamUrl ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
||||
{copied.streamUrl ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -494,7 +575,7 @@ export default function ChannelSettingsClient({
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Need help getting started? Check out our{' '}
|
||||
<Link
|
||||
href="https://docs.hackclub.tv/guides/start-stream/"
|
||||
href="https://docs.hackclub.tv/guides/start-stream/"
|
||||
className="text-primary hover:underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
@@ -616,12 +697,14 @@ export default function ChannelSettingsClient({
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
if (await confirm({
|
||||
title: 'Remove Manager',
|
||||
description: `Are you sure you want to remove ${personalChannel?.name} as a manager? They will no longer be able to stream or moderate this channel.`,
|
||||
confirmText: 'Remove',
|
||||
cancelText: 'Cancel',
|
||||
})) {
|
||||
if (
|
||||
await confirm({
|
||||
title: 'Remove Manager',
|
||||
description: `Are you sure you want to remove ${personalChannel?.name} as a manager? They will no longer be able to stream or moderate this channel.`,
|
||||
confirmText: 'Remove',
|
||||
cancelText: 'Cancel',
|
||||
})
|
||||
) {
|
||||
removeChannelManager(channel.id, manager.id);
|
||||
}
|
||||
}}
|
||||
@@ -727,7 +810,7 @@ export default function ChannelSettingsClient({
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-2">Chat overlay</h3>
|
||||
<p className="text-sm text-mantle-foreground mb-4">
|
||||
Add a 300x600 browser source with this and enjoy!
|
||||
Add a 300x600 browser source with this and enjoy!
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
|
||||
@@ -248,11 +248,6 @@ export default function ChatPanel(props: Props) {
|
||||
<div
|
||||
className={`${props.isObsPanel ? 'w-full text-white' : 'md:border-l border-border bg-mantle w-[350px] max-w-[350px]'} flex flex-col h-full`}
|
||||
>
|
||||
{!props.isObsPanel && (
|
||||
<div className="px-4 py-3 border-b border-border">
|
||||
<h2 className="font-semibold text-sm text-foreground">Live Chat</h2>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className={`flex-1 px-4 py-2 ${props.isObsPanel ? 'scrollbar-hide' : 'scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent'} overflow-y-auto overflow-x-hidden`}
|
||||
|
||||
@@ -21,7 +21,12 @@ import { Textarea } from '@/components/ui/textarea';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
createBotSchema,
|
||||
createChannelSchema, editBotSchema, onboardSchema, streamInfoEditSchema, updateChannelSettingsSchema
|
||||
createChannelSchema,
|
||||
changeUsernameSchema,
|
||||
editBotSchema,
|
||||
onboardSchema,
|
||||
streamInfoEditSchema,
|
||||
updateChannelSettingsSchema,
|
||||
} from '@/lib/form/zod';
|
||||
|
||||
export const schemaDb = [
|
||||
@@ -30,7 +35,8 @@ export const schemaDb = [
|
||||
{ name: 'createChannel', zod: createChannelSchema },
|
||||
{ name: 'updateChannelSettings', zod: updateChannelSettingsSchema },
|
||||
{ name: 'createBot', zod: createBotSchema },
|
||||
{ name: 'editBot', zod: editBotSchema }
|
||||
{ name: 'editBot', zod: editBotSchema },
|
||||
{ name: 'changeUsername', zod: changeUsernameSchema },
|
||||
] as const;
|
||||
|
||||
export function UniversalForm<T extends z.ZodType>({
|
||||
@@ -62,7 +68,7 @@ export function UniversalForm<T extends z.ZodType>({
|
||||
}, [fields, defaultValues]);
|
||||
|
||||
type FormData = z.infer<T>;
|
||||
|
||||
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(schema as any),
|
||||
defaultValues: initialValues as FormData,
|
||||
@@ -86,8 +92,8 @@ export function UniversalForm<T extends z.ZodType>({
|
||||
control={form.control}
|
||||
name={field.name as Path<FormData>}
|
||||
render={({ field: formField }) => (
|
||||
<FormItem>
|
||||
{(field.type !== 'hidden' || field.label) && <FormLabel>{field.label}</FormLabel>}
|
||||
<FormItem className={field.type === 'hidden' ? 'hidden' : undefined}>
|
||||
{field.type !== 'hidden' && field.label && <FormLabel>{field.label}</FormLabel>}
|
||||
<FormControl>
|
||||
{field.component ? (
|
||||
field.component({ field: formField, ...field.componentProps })
|
||||
@@ -115,17 +121,19 @@ export function UniversalForm<T extends z.ZodType>({
|
||||
/>
|
||||
)}
|
||||
</FormControl>
|
||||
{field.description && <FormDescription>{field.description}</FormDescription>}
|
||||
<FormMessage />
|
||||
{field.type !== 'hidden' && field.description && (
|
||||
<FormDescription>{field.description}</FormDescription>
|
||||
)}
|
||||
{field.type !== 'hidden' && <FormMessage />}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
<div className={cn("flex gap-2 py-2", submitButtonDivClassname)}>
|
||||
<div className={cn('flex gap-2 py-2', submitButtonDivClassname)}>
|
||||
{otherSubmitButton}
|
||||
<SubmitButton buttonText={submitText} className={submitClassname} />
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { validateRequest } from '@/lib/auth/validate';
|
||||
import { prisma } from '@hctv/db';
|
||||
import { prisma, getRedisConnection } from '@hctv/db';
|
||||
import zodVerify from '../zodVerify';
|
||||
import {
|
||||
createBotSchema,
|
||||
createChannelSchema,
|
||||
changeUsernameSchema,
|
||||
editBotSchema,
|
||||
onboardSchema,
|
||||
streamInfoEditSchema,
|
||||
@@ -343,7 +344,10 @@ export async function deleteChannel(channelId: string) {
|
||||
}
|
||||
|
||||
if (!can(user, 'delete', 'channel', { channel })) {
|
||||
return { success: false, error: 'Only channel owners can delete channels (personal channels cannot be deleted)' };
|
||||
return {
|
||||
success: false,
|
||||
error: 'Only channel owners can delete channels (personal channels cannot be deleted)',
|
||||
};
|
||||
}
|
||||
|
||||
await prisma.channel.delete({
|
||||
@@ -424,3 +428,125 @@ export async function editBot(prev: any, formData: FormData) {
|
||||
|
||||
return { success: true, slug: updatedBot.slug };
|
||||
}
|
||||
|
||||
const USERNAME_CHANGE_COOLDOWN_DAYS = 30;
|
||||
|
||||
export async function changeUsername(prev: any, formData: FormData) {
|
||||
const { user } = await validateRequest();
|
||||
if (!user) {
|
||||
return { success: false, error: 'Unauthorized' };
|
||||
}
|
||||
|
||||
const zod = await zodVerify(changeUsernameSchema, formData);
|
||||
if (!zod.success) {
|
||||
return zod;
|
||||
}
|
||||
|
||||
const channel = await prisma.channel.findUnique({
|
||||
where: { id: zod.data.channelId },
|
||||
include: {
|
||||
owner: true,
|
||||
managers: true,
|
||||
personalFor: true,
|
||||
streamInfo: true,
|
||||
streamKey: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!channel) {
|
||||
return { success: false, error: 'Channel not found' };
|
||||
}
|
||||
|
||||
if (!channel.personalFor || channel.personalFor.id !== user.id) {
|
||||
return { success: false, error: 'You can only change the username of your personal channel' };
|
||||
}
|
||||
|
||||
if (channel.ownerId !== user.id) {
|
||||
return { success: false, error: 'Unauthorized' };
|
||||
}
|
||||
|
||||
if (channel.nameLastChanged) {
|
||||
const daysSinceLastChange = Math.floor(
|
||||
(Date.now() - new Date(channel.nameLastChanged).getTime()) / (1000 * 60 * 60 * 24)
|
||||
);
|
||||
if (daysSinceLastChange < USERNAME_CHANGE_COOLDOWN_DAYS) {
|
||||
const daysRemaining = USERNAME_CHANGE_COOLDOWN_DAYS - daysSinceLastChange;
|
||||
return {
|
||||
success: false,
|
||||
error: `Please wait ${daysRemaining} more day${daysRemaining === 1 ? '' : 's'}.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const oldName = channel.name;
|
||||
const newName = zod.data.newUsername;
|
||||
|
||||
if (oldName === newName) {
|
||||
return { success: false, error: 'New username must be different from the current one' };
|
||||
}
|
||||
|
||||
const existingChannel = await prisma.channel.findUnique({
|
||||
where: { name: newName },
|
||||
});
|
||||
if (existingChannel) {
|
||||
return { success: false, error: 'This username is already taken' };
|
||||
}
|
||||
|
||||
const redis = getRedisConnection();
|
||||
|
||||
try {
|
||||
await prisma.channel.update({
|
||||
where: { id: channel.id },
|
||||
data: {
|
||||
name: newName,
|
||||
nameLastChanged: process.env.NODE_ENV === 'production' ? new Date() : null,
|
||||
},
|
||||
});
|
||||
|
||||
if (channel.streamInfo.length > 0) {
|
||||
await prisma.streamInfo.updateMany({
|
||||
where: { channelId: channel.id },
|
||||
data: { username: newName },
|
||||
});
|
||||
}
|
||||
|
||||
if (channel.streamKey) {
|
||||
const oldStreamKey = `streamKey:${oldName}`;
|
||||
const newStreamKey = `streamKey:${newName}`;
|
||||
if (await redis.exists(oldStreamKey)) {
|
||||
await redis.rename(oldStreamKey, newStreamKey);
|
||||
}
|
||||
}
|
||||
|
||||
const oldHistoryKey = `chat:history:${oldName}`;
|
||||
const newHistoryKey = `chat:history:${newName}`;
|
||||
if (await redis.exists(oldHistoryKey)) {
|
||||
const messagesWithScores = await redis.zrange(oldHistoryKey, 0, -1, 'WITHSCORES');
|
||||
if (messagesWithScores.length > 0) {
|
||||
const args: (string | number)[] = [];
|
||||
for (let i = 0; i < messagesWithScores.length; i += 2) {
|
||||
const msgStr = messagesWithScores[i];
|
||||
const score = messagesWithScores[i + 1];
|
||||
try {
|
||||
const msg = JSON.parse(msgStr);
|
||||
msg.user.username = newName;
|
||||
args.push(score, JSON.stringify(msg));
|
||||
} catch {
|
||||
args.push(score, msgStr);
|
||||
}
|
||||
}
|
||||
await redis.zadd(newHistoryKey, ...args);
|
||||
}
|
||||
await redis.del(oldHistoryKey);
|
||||
}
|
||||
|
||||
revalidatePath(`/settings/channel/${newName}`);
|
||||
revalidatePath(`/${oldName}`);
|
||||
revalidatePath(`/${newName}`);
|
||||
|
||||
return { success: true, newUsername: newName };
|
||||
} catch (error) {
|
||||
console.error('Failed to change username:', error);
|
||||
return { success: false, error: 'Failed to change username. Please try again.' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,3 +50,8 @@ export const editBotSchema = createBotSchema.and(
|
||||
from: z.string().min(1),
|
||||
})
|
||||
);
|
||||
|
||||
export const changeUsernameSchema = z.object({
|
||||
channelId: z.string().min(1),
|
||||
newUsername: username,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Channel" ADD COLUMN "nameLastChanged" TIMESTAMP(3);
|
||||
@@ -45,8 +45,9 @@ model Channel {
|
||||
description String @default("A hctv channel")
|
||||
pfpUrl String
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
nameLastChanged DateTime?
|
||||
|
||||
personalFor User? @relation("PersonalChannel")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user