[frontend]: add stream keys page

This commit is contained in:
Sergey Krylov 2025-08-14 05:36:49 +03:00
parent 8018d21011
commit 4c0e463aec
15 changed files with 611 additions and 7 deletions

View File

@ -112,7 +112,24 @@ export default [
"@typescript-eslint/no-misused-promises": ["error", { "@typescript-eslint/no-misused-promises": ["error", {
"checksVoidReturn": false "checksVoidReturn": false
} }
] ],
'@typescript-eslint/naming-convention': ['error',
{
selector: 'enum',
format: ['UPPER_CASE', 'PascalCase'],
},
{
selector: 'variable',
format: ['camelCase', 'PascalCase', 'UPPER_CASE'],
},
{
selector: 'function',
format: ['camelCase', 'PascalCase'],
},
{
selector: 'typeLike',
format: ['PascalCase'],
}],
}, },
}, },
{ {

View File

@ -55,6 +55,7 @@
"@next/eslint-plugin-next": "15.4.5", "@next/eslint-plugin-next": "15.4.5",
"@parcel/watcher": "^2.5.1", "@parcel/watcher": "^2.5.1",
"@tailwindcss/postcss": "4.1.11", "@tailwindcss/postcss": "4.1.11",
"@types/apollo-upload-client": "18.0.0",
"@types/node": "22.17.0", "@types/node": "22.17.0",
"@types/react": "19.1.9", "@types/react": "19.1.9",
"@types/react-dom": "19.1.7", "@types/react-dom": "19.1.7",

View File

@ -0,0 +1,20 @@
import { getTranslations } from 'next-intl/server';
import { KeysSettings } from '@/components/features/keys/settings/KeysSettings';
import { NO_INDEX_PAGE } from '@/libs/constants/seo.constants';
import type { Metadata } from 'next';
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations('dashboard.keys.header');
return {
title: t('heading'),
description: t('description'),
...NO_INDEX_PAGE,
};
}
const KeysSettingsPage = () => <KeysSettings />;
export default KeysSettingsPage;

View File

@ -0,0 +1,173 @@
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/common/Button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/common/Dialog';
export const InstructionModal = () => {
const t = useTranslations('dashboard.keys.instructionModal');
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="secondary">
{t('trigger')}
</Button>
</DialogTrigger>
<DialogContent className="max-h-[80vh] max-w-[800px] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-xl">
{t('heading')}
</DialogTitle>
<DialogDescription className="text-sm text-muted-foreground">
{t('description')}
</DialogDescription>
</DialogHeader>
<h2 className="text-lg font-semibold">
{t('step1Title')}
</h2>
<p className="text-sm text-muted-foreground">
{t('step1Description')}
</p>
<ol className="list-inside list-decimal space-y-2 pl-4">
<li className="text-sm text-muted-foreground">
<strong>
{t('downloadObs')}
</strong>
<br />
{t('downloadObsDescription')}
{' '}
<a
className="text-blue-500 underline hover:text-blue-700"
href="https://obsproject.com"
rel="noopener noreferrer"
target="_blank"
>
{t('obsLinkText')}
</a>
.
</li>
<li className="text-sm text-muted-foreground">
<strong>
{t('copyKeys')}
</strong>
<br />
{t('copyKeysDescription')}
</li>
</ol>
<h2 className="mt-4 text-lg font-semibold">
{t('step2Title')}
</h2>
<p className="text-sm text-muted-foreground">
{t('step2Description')}
</p>
<ol className="list-inside list-decimal space-y-2 pl-4">
<li className="text-sm text-muted-foreground">
<strong>
{t('openObs')}
</strong>
<br />
{t('openObsDescription')}
</li>
<li className="text-sm text-muted-foreground">
<strong>
{t('openStreamSettings')}
</strong>
<br />
{t('openStreamSettingsDescription')}
</li>
<li className="text-sm text-muted-foreground">
<strong>
{t('enterDetails')}
</strong>
<br />
{t('enterDetailsDescription')}
</li>
<li className="text-sm text-muted-foreground">
<strong>
{t('saveSettings')}
</strong>
<br />
{t('saveSettingsDescription')}
</li>
</ol>
<h2 className="mt-4 text-lg font-semibold">
{t('step3Title')}
</h2>
<p className="text-sm text-muted-foreground">
{t('step3Description')}
</p>
<ol className="list-inside list-decimal space-y-2 pl-4">
<li className="text-sm text-muted-foreground">
<strong>
{t('startStream')}
</strong>
<br />
{t('startStreamDescription')}
</li>
<li className="text-sm text-muted-foreground">
<strong>
{t('monitorStream')}
</strong>
<br />
{t('monitorStreamDescription')}
</li>
</ol>
<p className="mt-4 text-sm text-muted-foreground">
{t('congrats')}
</p>
<DialogFooter>
<DialogClose asChild>
<Button variant="secondary">
{t('close')}
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,51 @@
'use client';
import { useTranslations } from 'next-intl';
import { Heading } from '@/components/ui/elements/Heading';
import { ToggleCardSkeleton } from '@/components/ui/elements/ToggleCard';
import { useCurrent } from '@/hooks/useCurrent';
import { CreateIngressForm } from './forms/CreateIngressForm';
import { StreamKey } from './forms/StreamKey';
import { StreamURL } from './forms/StreamURL';
import { InstructionModal } from './InstructionModal';
export const KeysSettings = () => {
const t = useTranslations('dashboard.keys.header');
const { user, isLoadingProfile } = useCurrent();
return (
<div className="lg:px-10">
<div className="block items-center justify-between space-y-3 lg:flex lg:space-y-0">
<Heading
description={t('description')}
size="lg"
title={t('heading')}
/>
<div className="flex items-center gap-x-4">
<InstructionModal />
<CreateIngressForm />
</div>
</div>
<div className="mt-5 space-y-6">
{isLoadingProfile
? Array.from({ length: 2 }).map((_, index) => (
// eslint-disable-next-line react/no-array-index-key
<ToggleCardSkeleton key={index} />
))
: (
<>
<StreamURL value={user?.stream?.serverUrl} />
<StreamKey value={user?.stream?.key} />
</>
)}
</div>
</div>
);
};

View File

@ -0,0 +1,149 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { Button } from '@/components/ui/common/Button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/common/Dialog';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
} from '@/components/ui/common/Form';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/common/Select';
import { useCreateIngressMutation } from '@/graphql/generated/output';
import { useCurrent } from '@/hooks/useCurrent';
import {
IngressType,
type TypeCreateIngressSchema,
createIngressSchema,
} from '@/schemas/stream/create-ingress.schema';
export const CreateIngressForm = () => {
const t = useTranslations('dashboard.keys.createModal');
const [isOpen, setIsOpen] = useState(false);
const { refetch } = useCurrent();
const form = useForm<TypeCreateIngressSchema>({
resolver: zodResolver(createIngressSchema),
defaultValues: {
ingressType: IngressType.RTMP,
},
});
const [create, { loading: isLoadingCreate }] = useCreateIngressMutation({
onCompleted() {
setIsOpen(false);
void refetch();
toast.success(t('successMessage'));
},
onError() {
toast.error(t('errorMessage'));
},
});
const { isValid } = form.formState;
function onSubmit(data: TypeCreateIngressSchema) {
void create({ variables: { ingressType: data.ingressType } });
}
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button>
{t('trigger')}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
{t('heading')}
</DialogTitle>
</DialogHeader>
<Form {...form}>
<form
className="space-y-6"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
control={form.control}
name="ingressType"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('ingressTypeLabel')}
</FormLabel>
<FormControl>
<Select
defaultValue={field.value.toString()}
onValueChange={(value) => {
field.onChange(Number(value));
}}
>
<SelectTrigger>
<SelectValue
placeholder={t(
'ingressTypePlaceholder',
)}
/>
</SelectTrigger>
<SelectContent>
<SelectItem
disabled={isLoadingCreate}
value={IngressType.RTMP.toString()}
>
RTMP
</SelectItem>
<SelectItem
disabled={isLoadingCreate}
value={IngressType.WHIP.toString()}
>
WHIP
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormDescription>
{t('ingressTypeDescription')}
</FormDescription>
</FormItem>
)}
/>
<div className="flex justify-end">
<Button disabled={!isValid || isLoadingCreate}>
{t('submitButton')}
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,47 @@
import { Eye, EyeOff } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { Button } from '@/components/ui/common/Button';
import { Input } from '@/components/ui/common/Input';
import { CardContainer } from '@/components/ui/elements/CardContainer';
import { CopyButton } from '@/components/ui/elements/CopyButton';
type StreamKeyProps = {
value?: string | null;
};
export const StreamKey = ({ value }: StreamKeyProps) => {
const t = useTranslations('dashboard.keys.key');
const [isShow, setIsShow] = useState(false);
const Icon = isShow ? Eye : EyeOff;
return (
<CardContainer
isRightContentFull
heading={t('heading')}
rightContent={(
<div className="flex w-full items-center gap-x-4">
<Input
disabled
placeholder={t('heading')}
type={isShow ? 'text' : 'password'}
value={value ?? ''}
/>
<CopyButton value={value} />
<Button
size="lgIcon"
variant="ghost"
onClick={() => { setIsShow(!isShow); }}
>
<Icon className="size-5" />
</Button>
</div>
)}
/>
);
};

View File

@ -0,0 +1,31 @@
import { useTranslations } from 'next-intl';
import { Input } from '@/components/ui/common/Input';
import { CardContainer } from '@/components/ui/elements/CardContainer';
import { CopyButton } from '@/components/ui/elements/CopyButton';
type StreamURLProps = {
value?: string | null;
};
export const StreamURL = ({ value }: StreamURLProps) => {
const t = useTranslations('dashboard.keys.url');
return (
<CardContainer
isRightContentFull
heading={t('heading')}
rightContent={(
<div className="flex w-full items-center gap-x-4">
<Input
disabled
placeholder={t('heading')}
value={value ?? ''}
/>
<CopyButton value={value} />
</div>
)}
/>
);
};

View File

@ -0,0 +1,40 @@
import { Check, Copy } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { toast } from 'sonner';
import { Button } from '../common/Button';
type CopyButtonProps = {
value?: string | null;
};
export const CopyButton = ({ value }: CopyButtonProps) => {
const t = useTranslations('components.copyButton');
const [isCopied, setIsCopied] = useState(false);
function onCopy() {
if (!value) return;
setIsCopied(true);
void navigator.clipboard.writeText(value);
toast.success(t('successMessage'));
setTimeout(() => {
setIsCopied(false);
}, 2000);
}
const Icon = isCopied ? Check : Copy;
return (
<Button
disabled={!value || isCopied}
size="lgIcon"
variant="ghost"
onClick={() => { onCopy(); }}
>
<Icon className="size-5" />
</Button>
);
};

View File

@ -643,6 +643,13 @@ export type VerifyAccountMutationVariables = Exact<{
export type VerifyAccountMutation = { __typename?: 'Mutation', verifyAccount: { __typename?: 'AuthModel', message?: string | null, user?: { __typename?: 'UserModel', isEmailVerified: boolean } | null } }; export type VerifyAccountMutation = { __typename?: 'Mutation', verifyAccount: { __typename?: 'AuthModel', message?: string | null, user?: { __typename?: 'UserModel', isEmailVerified: boolean } | null } };
export type CreateIngressMutationVariables = Exact<{
ingressType: Scalars['Float']['input'];
}>;
export type CreateIngressMutation = { __typename?: 'Mutation', createIngress: boolean };
export type ChangeEmailMutationVariables = Exact<{ export type ChangeEmailMutationVariables = Exact<{
data: ChangeEmailInput; data: ChangeEmailInput;
}>; }>;
@ -761,7 +768,7 @@ export type FindUnreadNotificationsCountQuery = { __typename?: 'Query', findUnre
export type FindProfileQueryVariables = Exact<{ [key: string]: never; }>; export type FindProfileQueryVariables = Exact<{ [key: string]: never; }>;
export type FindProfileQuery = { __typename?: 'Query', findProfile: { __typename?: 'UserModel', avatar?: string | null, bio?: string | null, createdAt: any, email: string, id: string, name: string, updatedAt: any, isEmailVerified: boolean, isTotpEnabled: boolean, isVerified: boolean, displayName: string, socialLink: Array<{ __typename?: 'SocialLinkModel', title: string, url: string, position: number }>, notificationSettings?: { __typename?: 'NotificationSettingsModel', siteNotifications: boolean, telegramNotifications: boolean } | null } }; export type FindProfileQuery = { __typename?: 'Query', findProfile: { __typename?: 'UserModel', avatar?: string | null, bio?: string | null, createdAt: any, email: string, id: string, name: string, updatedAt: any, isEmailVerified: boolean, isTotpEnabled: boolean, isVerified: boolean, displayName: string, socialLink: Array<{ __typename?: 'SocialLinkModel', title: string, url: string, position: number }>, notificationSettings?: { __typename?: 'NotificationSettingsModel', siteNotifications: boolean, telegramNotifications: boolean } | null, stream: { __typename?: 'StreamModel', serverUrl?: string | null, key?: string | null } } };
export type FindSessionsByUserQueryVariables = Exact<{ [key: string]: never; }>; export type FindSessionsByUserQueryVariables = Exact<{ [key: string]: never; }>;
@ -1046,6 +1053,37 @@ export function useVerifyAccountMutation(baseOptions?: Apollo.MutationHookOption
export type VerifyAccountMutationHookResult = ReturnType<typeof useVerifyAccountMutation>; export type VerifyAccountMutationHookResult = ReturnType<typeof useVerifyAccountMutation>;
export type VerifyAccountMutationResult = Apollo.MutationResult<VerifyAccountMutation>; export type VerifyAccountMutationResult = Apollo.MutationResult<VerifyAccountMutation>;
export type VerifyAccountMutationOptions = Apollo.BaseMutationOptions<VerifyAccountMutation, VerifyAccountMutationVariables>; export type VerifyAccountMutationOptions = Apollo.BaseMutationOptions<VerifyAccountMutation, VerifyAccountMutationVariables>;
export const CreateIngressDocument = gql`
mutation CreateIngress($ingressType: Float!) {
createIngress(ingressType: $ingressType)
}
`;
export type CreateIngressMutationFn = Apollo.MutationFunction<CreateIngressMutation, CreateIngressMutationVariables>;
/**
* __useCreateIngressMutation__
*
* To run a mutation, you first call `useCreateIngressMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useCreateIngressMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [createIngressMutation, { data, loading, error }] = useCreateIngressMutation({
* variables: {
* ingressType: // value for 'ingressType'
* },
* });
*/
export function useCreateIngressMutation(baseOptions?: Apollo.MutationHookOptions<CreateIngressMutation, CreateIngressMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<CreateIngressMutation, CreateIngressMutationVariables>(CreateIngressDocument, options);
}
export type CreateIngressMutationHookResult = ReturnType<typeof useCreateIngressMutation>;
export type CreateIngressMutationResult = Apollo.MutationResult<CreateIngressMutation>;
export type CreateIngressMutationOptions = Apollo.BaseMutationOptions<CreateIngressMutation, CreateIngressMutationVariables>;
export const ChangeEmailDocument = gql` export const ChangeEmailDocument = gql`
mutation ChangeEmail($data: ChangeEmailInput!) { mutation ChangeEmail($data: ChangeEmailInput!) {
changeEmail(data: $data) { changeEmail(data: $data) {
@ -1716,6 +1754,10 @@ export const FindProfileDocument = gql`
siteNotifications siteNotifications
telegramNotifications telegramNotifications
} }
stream {
serverUrl
key
}
} }
} }
`; `;

View File

@ -0,0 +1,3 @@
mutation CreateIngress($ingressType: Float!) {
createIngress(ingressType: $ingressType)
}

View File

@ -20,5 +20,9 @@ query FindProfile {
siteNotifications siteNotifications
telegramNotifications telegramNotifications
} }
stream {
serverUrl
key
}
} }
} }

View File

@ -8,9 +8,9 @@ import { SERVER_URL, WEBSOCKET_URL } from './constants/url.constants';
const httpLink = createUploadLink({ const httpLink = createUploadLink({
uri: SERVER_URL, uri: SERVER_URL,
credentials: 'include', credentials: 'include',
// headers: { headers: {
// 'apollo-require-preflight': 'true', 'apollo-require-preflight': 'true',
// }, },
}); });
const wsLink = new WebSocketLink({ const wsLink = new WebSocketLink({

View File

@ -0,0 +1,12 @@
import { z } from 'zod';
export enum IngressType {
RTMP = 0,
WHIP = 1,
}
export const createIngressSchema = z.object({
ingressType: z.enum(IngressType),
});
export type TypeCreateIngressSchema = z.infer<typeof createIngressSchema>;

View File

@ -15,7 +15,7 @@
"@jridgewell/gen-mapping" "^0.3.5" "@jridgewell/gen-mapping" "^0.3.5"
"@jridgewell/trace-mapping" "^0.3.24" "@jridgewell/trace-mapping" "^0.3.24"
"@apollo/client@3.13.9": "@apollo/client@3.13.9", "@apollo/client@^3.8.0":
version "3.13.9" version "3.13.9"
resolved "https://registry.yarnpkg.com/@apollo/client/-/client-3.13.9.tgz#311e0df1734aaa5598e01a292bb7982df49cc062" resolved "https://registry.yarnpkg.com/@apollo/client/-/client-3.13.9.tgz#311e0df1734aaa5598e01a292bb7982df49cc062"
integrity sha512-RStSzQfL1XwL6/NWd7W8avhGQYTgPCtJ+qHkkTTSj9Upp3VVm6Oppv81YWdXG1FgEpDPW4hvCrTUELdcC4inCQ== integrity sha512-RStSzQfL1XwL6/NWd7W8avhGQYTgPCtJ+qHkkTTSj9Upp3VVm6Oppv81YWdXG1FgEpDPW4hvCrTUELdcC4inCQ==
@ -2213,11 +2213,25 @@
dependencies: dependencies:
tslib "^2.4.0" tslib "^2.4.0"
"@types/apollo-upload-client@18.0.0":
version "18.0.0"
resolved "https://registry.yarnpkg.com/@types/apollo-upload-client/-/apollo-upload-client-18.0.0.tgz#1f7e2ff0c0e6508bc1bd19b2340339c7abd117c3"
integrity sha512-cMgITNemktxasqvp6jiPj15dv84n3FTMvMoYBP1+xonDS+0l6JygIJrj2LJh85rShRzTOOkrElrAsCXXARa3KA==
dependencies:
"@apollo/client" "^3.8.0"
"@types/extract-files" "*"
graphql "14 - 16"
"@types/estree@^1.0.6": "@types/estree@^1.0.6":
version "1.0.8" version "1.0.8"
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e" resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e"
integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==
"@types/extract-files@*":
version "13.0.1"
resolved "https://registry.yarnpkg.com/@types/extract-files/-/extract-files-13.0.1.tgz#3ec057a3fa25f778245a76a17271d23b71ee31d7"
integrity sha512-/fRbzc2lAd7jDJSSnxWiUyXWjdUZZ4HbISLJzVgt1AvrdOa7U49YRPcvuCUywkmURZ7uwJOheDjx19itbQ5KvA==
"@types/js-yaml@^4.0.0": "@types/js-yaml@^4.0.0":
version "4.0.9" version "4.0.9"
resolved "https://registry.yarnpkg.com/@types/js-yaml/-/js-yaml-4.0.9.tgz#cd82382c4f902fed9691a2ed79ec68c5898af4c2" resolved "https://registry.yarnpkg.com/@types/js-yaml/-/js-yaml-4.0.9.tgz#cd82382c4f902fed9691a2ed79ec68c5898af4c2"
@ -4271,7 +4285,7 @@ graphql-ws@^6.0.6:
resolved "https://registry.yarnpkg.com/graphql-ws/-/graphql-ws-6.0.6.tgz#e9c9ff85f4ddb5bbe6faa2721c0d27e6c11746d2" resolved "https://registry.yarnpkg.com/graphql-ws/-/graphql-ws-6.0.6.tgz#e9c9ff85f4ddb5bbe6faa2721c0d27e6c11746d2"
integrity sha512-zgfER9s+ftkGKUZgc0xbx8T7/HMO4AV5/YuYiFc+AtgcO5T0v8AxYYNQ+ltzuzDZgNkYJaFspm5MMYLjQzrkmw== integrity sha512-zgfER9s+ftkGKUZgc0xbx8T7/HMO4AV5/YuYiFc+AtgcO5T0v8AxYYNQ+ltzuzDZgNkYJaFspm5MMYLjQzrkmw==
graphql@16.11.0: "graphql@14 - 16", graphql@16.11.0:
version "16.11.0" version "16.11.0"
resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.11.0.tgz#96d17f66370678027fdf59b2d4c20b4efaa8a633" resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.11.0.tgz#96d17f66370678027fdf59b2d4c20b4efaa8a633"
integrity sha512-mS1lbMsxgQj6hge1XZ6p7GPhbrtFwUFYi3wRzXAC/FmYnyXMTvvI3td3rjmQ2u8ewXueaSvRPWaEcgVVOT9Jnw== integrity sha512-mS1lbMsxgQj6hge1XZ6p7GPhbrtFwUFYi3wRzXAC/FmYnyXMTvvI3td3rjmQ2u8ewXueaSvRPWaEcgVVOT9Jnw==