From 4c0e463aec7c97274534e43ae760ef0f0441a067 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Thu, 14 Aug 2025 05:36:49 +0300 Subject: [PATCH] [frontend]: add stream keys page --- frontend/eslint.config.mjs | 19 +- frontend/package.json | 1 + .../src/app/(site)/dashboard/keys/page.tsx | 20 ++ .../keys/settings/InstructionModal.tsx | 173 ++++++++++++++++++ .../features/keys/settings/KeysSettings.tsx | 51 ++++++ .../keys/settings/forms/CreateIngressForm.tsx | 149 +++++++++++++++ .../keys/settings/forms/StreamKey.tsx | 47 +++++ .../keys/settings/forms/StreamURL.tsx | 31 ++++ .../src/components/ui/elements/CopyButton.tsx | 40 ++++ frontend/src/graphql/generated/output.ts | 44 ++++- .../mutations/stream/CreateIngress.graphql | 3 + .../graphql/queries/user/FindProfile.graphql | 4 + frontend/src/libs/apollo-client.ts | 6 +- .../schemas/stream/create-ingress.schema.ts | 12 ++ frontend/yarn.lock | 18 +- 15 files changed, 611 insertions(+), 7 deletions(-) create mode 100644 frontend/src/app/(site)/dashboard/keys/page.tsx create mode 100644 frontend/src/components/features/keys/settings/InstructionModal.tsx create mode 100644 frontend/src/components/features/keys/settings/KeysSettings.tsx create mode 100644 frontend/src/components/features/keys/settings/forms/CreateIngressForm.tsx create mode 100644 frontend/src/components/features/keys/settings/forms/StreamKey.tsx create mode 100644 frontend/src/components/features/keys/settings/forms/StreamURL.tsx create mode 100644 frontend/src/components/ui/elements/CopyButton.tsx create mode 100644 frontend/src/graphql/mutations/stream/CreateIngress.graphql create mode 100644 frontend/src/schemas/stream/create-ingress.schema.ts diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index cd97840..de5432c 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -112,7 +112,24 @@ export default [ "@typescript-eslint/no-misused-promises": ["error", { "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'], + }], }, }, { diff --git a/frontend/package.json b/frontend/package.json index 7e50bd0..f9ecfc3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -55,6 +55,7 @@ "@next/eslint-plugin-next": "15.4.5", "@parcel/watcher": "^2.5.1", "@tailwindcss/postcss": "4.1.11", + "@types/apollo-upload-client": "18.0.0", "@types/node": "22.17.0", "@types/react": "19.1.9", "@types/react-dom": "19.1.7", diff --git a/frontend/src/app/(site)/dashboard/keys/page.tsx b/frontend/src/app/(site)/dashboard/keys/page.tsx new file mode 100644 index 0000000..5239c39 --- /dev/null +++ b/frontend/src/app/(site)/dashboard/keys/page.tsx @@ -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 { + const t = await getTranslations('dashboard.keys.header'); + + return { + title: t('heading'), + description: t('description'), + ...NO_INDEX_PAGE, + }; +} + +const KeysSettingsPage = () => ; + +export default KeysSettingsPage; diff --git a/frontend/src/components/features/keys/settings/InstructionModal.tsx b/frontend/src/components/features/keys/settings/InstructionModal.tsx new file mode 100644 index 0000000..cd7f110 --- /dev/null +++ b/frontend/src/components/features/keys/settings/InstructionModal.tsx @@ -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 ( + + + + + + + + + {t('heading')} + + + + {t('description')} + + + +

+ {t('step1Title')} +

+ +

+ {t('step1Description')} +

+ +
    +
  1. + + {t('downloadObs')} + + +
    + + {t('downloadObsDescription')} + + {' '} + + + {t('obsLinkText')} + + . +
  2. + +
  3. + + {t('copyKeys')} + + +
    + + {t('copyKeysDescription')} +
  4. +
+ +

+ {t('step2Title')} +

+ +

+ {t('step2Description')} +

+ +
    +
  1. + + {t('openObs')} + + +
    + + {t('openObsDescription')} +
  2. + +
  3. + + {t('openStreamSettings')} + + +
    + + {t('openStreamSettingsDescription')} +
  4. + +
  5. + + {t('enterDetails')} + + +
    + + {t('enterDetailsDescription')} +
  6. + +
  7. + + {t('saveSettings')} + + +
    + + {t('saveSettingsDescription')} +
  8. +
+ +

+ {t('step3Title')} +

+ +

+ {t('step3Description')} +

+ +
    +
  1. + + {t('startStream')} + + +
    + + {t('startStreamDescription')} +
  2. + +
  3. + + {t('monitorStream')} + + +
    + + {t('monitorStreamDescription')} +
  4. +
+ +

+ {t('congrats')} +

+ + + + + + +
+
+ ); +}; diff --git a/frontend/src/components/features/keys/settings/KeysSettings.tsx b/frontend/src/components/features/keys/settings/KeysSettings.tsx new file mode 100644 index 0000000..b8eea96 --- /dev/null +++ b/frontend/src/components/features/keys/settings/KeysSettings.tsx @@ -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 ( +
+
+ + +
+ + + +
+
+ +
+ {isLoadingProfile + ? Array.from({ length: 2 }).map((_, index) => ( + // eslint-disable-next-line react/no-array-index-key + + )) + : ( + <> + + + + + )} +
+
+ ); +}; diff --git a/frontend/src/components/features/keys/settings/forms/CreateIngressForm.tsx b/frontend/src/components/features/keys/settings/forms/CreateIngressForm.tsx new file mode 100644 index 0000000..49c2e13 --- /dev/null +++ b/frontend/src/components/features/keys/settings/forms/CreateIngressForm.tsx @@ -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({ + 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 ( + + + + + + + + + {t('heading')} + + + +
+ + ( + + + {t('ingressTypeLabel')} + + + + + + + + {t('ingressTypeDescription')} + + + )} + /> + +
+ +
+ + +
+
+ ); +}; diff --git a/frontend/src/components/features/keys/settings/forms/StreamKey.tsx b/frontend/src/components/features/keys/settings/forms/StreamKey.tsx new file mode 100644 index 0000000..ab71d54 --- /dev/null +++ b/frontend/src/components/features/keys/settings/forms/StreamKey.tsx @@ -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 ( + + + + + + + + )} + /> + ); +}; diff --git a/frontend/src/components/features/keys/settings/forms/StreamURL.tsx b/frontend/src/components/features/keys/settings/forms/StreamURL.tsx new file mode 100644 index 0000000..b15ce86 --- /dev/null +++ b/frontend/src/components/features/keys/settings/forms/StreamURL.tsx @@ -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 ( + + + + + + )} + /> + ); +}; diff --git a/frontend/src/components/ui/elements/CopyButton.tsx b/frontend/src/components/ui/elements/CopyButton.tsx new file mode 100644 index 0000000..6925d1c --- /dev/null +++ b/frontend/src/components/ui/elements/CopyButton.tsx @@ -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 ( + + ); +}; diff --git a/frontend/src/graphql/generated/output.ts b/frontend/src/graphql/generated/output.ts index c23e230..5ae52d8 100644 --- a/frontend/src/graphql/generated/output.ts +++ b/frontend/src/graphql/generated/output.ts @@ -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 CreateIngressMutationVariables = Exact<{ + ingressType: Scalars['Float']['input']; +}>; + + +export type CreateIngressMutation = { __typename?: 'Mutation', createIngress: boolean }; + export type ChangeEmailMutationVariables = Exact<{ data: ChangeEmailInput; }>; @@ -761,7 +768,7 @@ export type FindUnreadNotificationsCountQuery = { __typename?: 'Query', findUnre 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; }>; @@ -1046,6 +1053,37 @@ export function useVerifyAccountMutation(baseOptions?: Apollo.MutationHookOption export type VerifyAccountMutationHookResult = ReturnType; export type VerifyAccountMutationResult = Apollo.MutationResult; export type VerifyAccountMutationOptions = Apollo.BaseMutationOptions; +export const CreateIngressDocument = gql` + mutation CreateIngress($ingressType: Float!) { + createIngress(ingressType: $ingressType) +} + `; +export type CreateIngressMutationFn = Apollo.MutationFunction; + +/** + * __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) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(CreateIngressDocument, options); + } +export type CreateIngressMutationHookResult = ReturnType; +export type CreateIngressMutationResult = Apollo.MutationResult; +export type CreateIngressMutationOptions = Apollo.BaseMutationOptions; export const ChangeEmailDocument = gql` mutation ChangeEmail($data: ChangeEmailInput!) { changeEmail(data: $data) { @@ -1716,6 +1754,10 @@ export const FindProfileDocument = gql` siteNotifications telegramNotifications } + stream { + serverUrl + key + } } } `; diff --git a/frontend/src/graphql/mutations/stream/CreateIngress.graphql b/frontend/src/graphql/mutations/stream/CreateIngress.graphql new file mode 100644 index 0000000..f2473e1 --- /dev/null +++ b/frontend/src/graphql/mutations/stream/CreateIngress.graphql @@ -0,0 +1,3 @@ +mutation CreateIngress($ingressType: Float!) { + createIngress(ingressType: $ingressType) +} diff --git a/frontend/src/graphql/queries/user/FindProfile.graphql b/frontend/src/graphql/queries/user/FindProfile.graphql index 37b8bdd..d41fe85 100644 --- a/frontend/src/graphql/queries/user/FindProfile.graphql +++ b/frontend/src/graphql/queries/user/FindProfile.graphql @@ -20,5 +20,9 @@ query FindProfile { siteNotifications telegramNotifications } + stream { + serverUrl + key + } } } diff --git a/frontend/src/libs/apollo-client.ts b/frontend/src/libs/apollo-client.ts index 6d52539..08b4b2f 100644 --- a/frontend/src/libs/apollo-client.ts +++ b/frontend/src/libs/apollo-client.ts @@ -8,9 +8,9 @@ import { SERVER_URL, WEBSOCKET_URL } from './constants/url.constants'; const httpLink = createUploadLink({ uri: SERVER_URL, credentials: 'include', - // headers: { - // 'apollo-require-preflight': 'true', - // }, + headers: { + 'apollo-require-preflight': 'true', + }, }); const wsLink = new WebSocketLink({ diff --git a/frontend/src/schemas/stream/create-ingress.schema.ts b/frontend/src/schemas/stream/create-ingress.schema.ts new file mode 100644 index 0000000..9cc9fc6 --- /dev/null +++ b/frontend/src/schemas/stream/create-ingress.schema.ts @@ -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; diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 9b722d8..aff73c8 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -15,7 +15,7 @@ "@jridgewell/gen-mapping" "^0.3.5" "@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" resolved "https://registry.yarnpkg.com/@apollo/client/-/client-3.13.9.tgz#311e0df1734aaa5598e01a292bb7982df49cc062" integrity sha512-RStSzQfL1XwL6/NWd7W8avhGQYTgPCtJ+qHkkTTSj9Upp3VVm6Oppv81YWdXG1FgEpDPW4hvCrTUELdcC4inCQ== @@ -2213,11 +2213,25 @@ dependencies: 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": version "1.0.8" resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e" 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": version "4.0.9" 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" integrity sha512-zgfER9s+ftkGKUZgc0xbx8T7/HMO4AV5/YuYiFc+AtgcO5T0v8AxYYNQ+ltzuzDZgNkYJaFspm5MMYLjQzrkmw== -graphql@16.11.0: +"graphql@14 - 16", graphql@16.11.0: version "16.11.0" resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.11.0.tgz#96d17f66370678027fdf59b2d4c20b4efaa8a633" integrity sha512-mS1lbMsxgQj6hge1XZ6p7GPhbrtFwUFYi3wRzXAC/FmYnyXMTvvI3td3rjmQ2u8ewXueaSvRPWaEcgVVOT9Jnw==