Compare commits

..

15 Commits

191 changed files with 17411 additions and 33 deletions

View File

@ -25,10 +25,26 @@ services:
networks:
- teastream-backend
minio:
image: minio/minio:latest
container_name: teastream-minio
ports:
- "9000:9000"
- "9001:9001"
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
command: server /data --console-address ":9001"
volumes:
- minio_data:/data
networks:
- teastream-backend
volumes:
postgres_data:
redis_data:
minio_data:
networks:
teastream-backend:

View File

@ -1,7 +1,5 @@
import config from 'eslint-config-ksv741';
import type { Linter } from 'eslint';
export default [
...config,
{
@ -107,4 +105,4 @@ export default [
'import/no-extraneous-dependencies': 'off',
},
},
] satisfies Linter.Config[];
];

View File

@ -11,7 +11,7 @@
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint src",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\"",
"lint:inspect": "npx @eslint/config-inspector@latest",
"test": "jest",
"test:watch": "jest --watch",
@ -46,8 +46,6 @@
"connect-redis": "^7.1.1",
"cookie-parser": "^1.4.7",
"device-detector-js": "^3.0.3",
"dotenv": "^17.2.1",
"express": "^5.1.0",
"express-session": "^1.18.1",
"geoip-lite": "^1.4.10",
"graphql": "^16.11.0",

View File

@ -142,7 +142,7 @@ type MakePaymentModel {
type Mutation {
changeChatSettings(data: ChangeChatSettingsInput!): StreamModel!
changeEmail(data: ChangeEmailInput!): UserModel!
changeNotificationSettigs(data: ChangeNotificationSettingsInput!): ChangeNotificationsSettingsResponse!
changeNotificationSettings(data: ChangeNotificationSettingsInput!): ChangeNotificationsSettingsResponse!
changePassword(data: ChangePasswordInput!): UserModel!
changeProfileAvatar(avatar: Upload!): Boolean!
changeProfileInfo(data: ChangeProfileInfoInput!): UserModel!
@ -370,7 +370,7 @@ type UserModel {
isVerified: Boolean!
name: String!
notification: [NotificationModel!]!
notificationSettings: NotificationSettingsModel!
notificationSettings: NotificationSettingsModel
password: String!
socialLink: [SocialLinkModel!]!
stream: StreamModel!

View File

@ -1,7 +1,8 @@
import { BadRequestException, Logger } from '@nestjs/common';
import { hash } from 'argon2';
import { Prisma, PrismaClient } from '@/prisma/generated';
// eslint-disable-next-line
import { Prisma, PrismaClient } from '../../../prisma/generated';
import { CATEGORIES } from './data/categories.data';
import { STREAMS } from './data/streams.data';

View File

@ -21,6 +21,11 @@ export class AccountService {
where: {
id,
},
include: {
socialLink: true,
stream: true,
notificationSettings: true,
},
});
}

View File

@ -66,7 +66,7 @@ export class UserModel implements User {
@Field(() => [NotificationModel])
notification: NotificationModel[];
@Field(() => NotificationSettingsModel)
@Field(() => NotificationSettingsModel, { nullable: true })
notificationSettings: NotificationSettingsModel;
@Field(() => Date)

View File

@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config';
import { verify } from 'argon2';
import { PrismaService } from '@/src/core/prisma/prisma.service';
import { RedisService } from '@/src/core/redis/redis.service';
import { DeactivateAccountInput } from '@/src/module/auth/deactivate/inputs/deactivate-account.input';
import { MailService } from '@/src/module/libs/mail/mail.service';
import { TelegramService } from '@/src/module/libs/telegram/telegram.service';
@ -19,6 +20,7 @@ import type { Request } from 'express';
export class DeactivateService {
constructor(
private readonly prismaService: PrismaService,
private readonly redisService: RedisService,
private readonly configService: ConfigService<ProcessEnv>,
private readonly mailService: MailService,
private readonly telegramService: TelegramService,
@ -82,7 +84,7 @@ export class DeactivateService {
throw new BadRequestException('Токен истек');
}
await this.prismaService.user.update({
const user = await this.prismaService.user.update({
where: { id: existingToken.userId },
data: {
isDeactivated: true,
@ -96,7 +98,24 @@ export class DeactivateService {
type: TokenType.DEACTIVATE_ACCOUNT,
},
});
await this.clearSessions(user.id);
return destroySession(req, this.configService);
}
private async clearSessions(userId: string) {
const keys = await this.redisService.keys('*');
for (const key of keys) {
const sessionData = await this.redisService.get(key);
if (sessionData) {
const session = JSON.parse(sessionData);
if (session.userId === userId) {
await this.redisService.del(key);
}
}
}
}
}

View File

@ -0,0 +1,28 @@
import { Field, ID, ObjectType } from '@nestjs/graphql';
import { UserModel } from '@/src/module/auth/account/models/user.model';
import { SocialLink } from '@prisma/generated';
@ObjectType()
export class SocialLinkModel implements SocialLink {
@Field(() => ID)
id: string;
@Field(() => ID)
userId: UserModel['id'];
@Field(() => String)
title: string;
@Field(() => String)
url: string;
@Field(() => Number)
position: number;
@Field(() => Date)
updatedAt: Date;
@Field(() => Date)
createdAt: Date;
}

View File

@ -1,6 +1,6 @@
import { ConflictException, Injectable } from '@nestjs/common';
import * as Upload from 'graphql-upload/Upload.js';
import sharp from 'sharp';
import * as sharp from 'sharp';
import { SocialLink, User } from '@/prisma/generated';
import { PrismaService } from '@/src/core/prisma/prisma.service';

View File

@ -89,7 +89,7 @@ export class SessionService {
},
});
if (!user) {
if (!user || user.isDeactivated) {
throw new NotFoundException('Пользователь не найден');
}

View File

@ -20,15 +20,16 @@ export class StorageService {
private readonly configService: ConfigService<ProcessEnv>,
) {
this.client = new S3Client({
endpoint: this.configService.getOrThrow('S3_ENDPOINT'),
region: this.configService.getOrThrow('S3_REGION'),
endpoint: this.configService.getOrThrow('MINIO_ENDPOINT'),
region: this.configService.getOrThrow('MINIO_REGION'),
credentials: {
accessKeyId: this.configService.getOrThrow('S3_ACCESS_KEY_ID'),
secretAccessKey: this.configService.getOrThrow('S3_SECRET_KEY_ID'),
accessKeyId: this.configService.getOrThrow('MINIO_ACCESS_KEY'),
secretAccessKey: this.configService.getOrThrow('MINIO_SECRET_KEY'),
},
forcePathStyle: true,
});
this.bucket = this.configService.getOrThrow('S3_BUCKET_NAME');
this.bucket = this.configService.getOrThrow('MINIO_BUCKET_NAME');
}
public async upload(buffer: Buffer, key: string, mimetype: string) {

View File

@ -2,12 +2,12 @@ import {
Args, Mutation, Query, Resolver,
} from '@nestjs/graphql';
import { ChangeNotificationSettingsInput } from '@/src/module/notification/inputs/change-notification-settings.input';
import { ChangeNotificationsSettingsResponse } from '@/src/module/notification/models/notification-settings.model';
import { Authorization } from '@/src/shared/decorators/auth.decorator';
import { Authorized } from '@/src/shared/decorators/authorized.decorator';
import { User } from '@prisma/generated';
import { ChangeNotificationSettingsInput } from './inputs/change-notification-settings.input';
import { ChangeNotificationsSettingsResponse } from './models/notification-settings.model';
import { NotificationModel } from './models/notification.model';
import { NotificationService } from './notification.service';
@ -28,7 +28,7 @@ export class NotificationResolver {
}
@Authorization()
@Mutation(() => ChangeNotificationsSettingsResponse, { name: 'changeNotificationSettigs' })
@Mutation(() => ChangeNotificationsSettingsResponse, { name: 'changeNotificationSettings' })
public async changeSettings(
@Authorized() user: User,
@Args('data') input: ChangeNotificationSettingsInput,

View File

@ -42,7 +42,7 @@ export class NotificationService {
public async changeSettings(user: User, input: ChangeNotificationSettingsInput) {
const { siteNotifications, telegramNotifications } = input;
const notificationSetting = await this.prismaService.notificationSettings.upsert({
const notificationSettings = await this.prismaService.notificationSettings.upsert({
where: { userId: user.id },
create: {
siteNotifications,
@ -53,7 +53,7 @@ export class NotificationService {
include: { user: true },
});
if (notificationSetting.telegramNotifications && !notificationSetting.user.telegramId) {
if (notificationSettings.telegramNotifications && !notificationSettings.user.telegramId) {
const telegramAuthToken = await generateToken(
this.prismaService,
user,
@ -61,21 +61,21 @@ export class NotificationService {
);
return {
notificationSetting,
notificationSettings,
telegramAuthToken: telegramAuthToken.token,
};
}
if (!notificationSetting.telegramNotifications && notificationSetting.user.telegramId) {
if (!notificationSettings.telegramNotifications && notificationSettings.user.telegramId) {
await this.prismaService.user.update({
where: { id: user.id },
data: { telegramId: null },
});
return { notificationSetting };
return { notificationSettings };
}
return { notificationSetting };
return { notificationSettings };
}
public async createStreamStart(userId: string, channel: User) {

View File

@ -42,6 +42,14 @@ export type ProcessEnv = {
S3_SECRET_KEY_ID: string;
S3_BUCKET_NAME: string;
MINIO_ROOT_USER: string;
MINIO_ROOT_PASSWORD: string;
MINIO_ENDPOINT: string;
MINIO_ACCESS_KEY: string;
MINIO_SECRET_KEY: string;
MINIO_BUCKET_NAME: string;
MINIO_REGION: string;
LIVEKIT_URL: string;
LIVEKIT_API_KEY: string;
LIVEKIT_API_SECRET: string;

View File

@ -5414,11 +5414,6 @@ dotenv@^16.4.5:
resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.5.0.tgz#092b49f25f808f020050051d1ff258e404c78692"
integrity sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==
dotenv@^17.2.1:
version "17.2.1"
resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-17.2.1.tgz#6f32e10faf014883515538dc922a0fb8765d9b32"
integrity sha512-kQhDYKZecqnM0fCnzI5eIv5L4cAe/iRI+HqMbO/hbRdTAeXDG+M9FjipUxNfbARuEg4iHIbhnhs78BCHNbSxEQ==
dset@^3.1.4:
version "3.1.4"
resolved "https://registry.yarnpkg.com/dset/-/dset-3.1.4.tgz#f8eaf5f023f068a036d08cd07dc9ffb7d0065248"
@ -6049,7 +6044,7 @@ express-session@^1.18.1:
safe-buffer "5.2.1"
uid-safe "~2.1.5"
express@5.1.0, express@^5.1.0:
express@5.1.0:
version "5.1.0"
resolved "https://registry.yarnpkg.com/express/-/express-5.1.0.tgz#d31beaf715a0016f0d53f47d3b4d7acf28c75cc9"
integrity sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==

41
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

36
frontend/README.md Normal file
View File

@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

10
frontend/apollo.config.js Normal file
View File

@ -0,0 +1,10 @@
require('dotenv/config')
module.exports = {
service: {
endpoint: {
url: process.env.NEXT_PUBLIC_SERVER_URL,
skipSSLValidation: true
}
}
}

21
frontend/components.json Normal file
View File

@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/styles/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

121
frontend/eslint.config.mjs Normal file
View File

@ -0,0 +1,121 @@
import config from 'eslint-config-ksv741';
import nextPlugin from '@next/eslint-plugin-next'
const {flatConfig} = nextPlugin;
export default [
...config,
flatConfig.coreWebVitals,
{
name: 'my-rules',
files: [
'**/*.js',
'**/*.ts',
'**/*.tsx',
],
rules: {
'func-style': ['error', 'declaration', { allowArrowFunctions: true }],
'import/order': [
'error', {
pathGroups: [
{
pattern: '__spec__/**',
group: 'builtin',
position: 'before',
},
{
pattern: 'apps/**',
group: 'internal',
position: 'after',
},
{
pattern: 'pages/**',
group: 'internal',
position: 'after',
},
{
pattern: 'widgets/**',
group: 'internal',
position: 'after',
},
{
pattern: 'features/**',
group: 'internal',
position: 'after',
},
{
pattern: 'entities/**',
group: 'internal',
position: 'after',
},
{
pattern: 'shared/**',
group: 'internal',
position: 'after',
},
],
distinctGroup: true,
groups: [
'builtin',
'external',
'internal',
'parent',
'sibling',
'object',
'index',
'type',
],
'newlines-between': 'always',
alphabetize: {
order: 'asc',
caseInsensitive: true,
},
},
],
'no-await-in-loop': 'off',
'no-void': ['error', { allowAsStatement: true }],
// "@typescript-eslint/no-extraneous-class": ['error', {allowEmpty: true}],
// "@typescript-eslint/parameter-properties": ['error', { "allow": ["private readonly"] }],
'@typescript-eslint/max-params': 'off',
'@typescript-eslint/no-require-imports': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
// todo fixme
'import/max-dependencies': 'off',
'@typescript-eslint/no-unsafe-type-assertion': 'off',
'@typescript-eslint/no-unsafe-call': 'off',
'import/no-cycle': 'off',
'@typescript-eslint/strict-boolean-expressions': 'off',
'import/extensions': 'off',
'@typescript-eslint/no-unnecessary-condition': 'off',
'max-classes-per-file': 'off',
'@typescript-eslint/no-unsafe-return': 'off',
'@typescript-eslint/no-unsafe-member-access': 'off',
'@typescript-eslint/no-unsafe-argument': 'off',
'@typescript-eslint/no-unsafe-assignment': 'off',
'@typescript-eslint/no-unnecessary-type-conversion': 'off',
'@typescript-eslint/consistent-return': 'off',
'@typescript-eslint/class-methods-use-this': 'off',
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/prefer-nullish-coalescing': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/member-ordering': 'off',
'no-undefined': 'off',
'@typescript-eslint/no-misused-spread': 'off',
'@typescript-eslint/require-await': 'off',
'@typescript-eslint/parameter-properties': 'off',
'@typescript-eslint/no-extraneous-class': 'off',
'@stylistic/max-len': 'off',
'@typescript-eslint/no-unnecessary-type-parameters': 'off',
'import/no-extraneous-dependencies': 'off',
'react/destructuring-assignment': ['off', 'never', { ignoreClassFields: true, destructureInSignature: 'always' }],
"@typescript-eslint/no-misused-promises": ["error", {
"checksVoidReturn": false
}
]
},
},
{
ignores: ['src/graphql/generated']
}
];

View File

@ -0,0 +1,19 @@
import type { CodegenConfig } from '@graphql-codegen/cli';
import 'dotenv/config';
const config: CodegenConfig = {
schema: process.env.NEXT_PUBLIC_SERVER_URL,
documents: ['./src/graphql/**/*.graphql'],
generates: {
'./src/graphql/generated/output.ts': {
plugins: [
'typescript',
'typescript-operations',
'typescript-react-apollo',
],
},
},
ignoreNoDocuments: true,
};
export default config;

6
frontend/graphql.d.ts vendored Normal file
View File

@ -0,0 +1,6 @@
declare module '*.graphql' {
import {DocumentNode} from 'graphql'
const schema: DocumentNode
export = schema
}

10
frontend/next.config.ts Normal file
View File

@ -0,0 +1,10 @@
import type { NextConfig } from "next";
import createNextIntlPlugin from 'next-intl/plugin';
const withNextIntl = createNextIntlPlugin('./src/libs/i18n/request.ts')
const nextConfig: NextConfig = {
reactStrictMode: true
};
export default withNextIntl(nextConfig);

69
frontend/package.json Normal file
View File

@ -0,0 +1,69 @@
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint",
"codegen": "graphql-codegen --config graphql.config.ts --watch"
},
"dependencies": {
"@apollo/client": "3.13.9",
"@graphql-codegen/cli": "5.0.7",
"@graphql-codegen/typescript": "4.1.6",
"@graphql-codegen/typescript-operations": "4.6.1",
"@graphql-codegen/typescript-react-apollo": "4.3.3",
"@hello-pangea/dnd": "18.0.1",
"@hookform/resolvers": "5.2.1",
"@pbe/react-yandex-maps": "1.2.5",
"@radix-ui/react-alert-dialog": "1.1.14",
"@radix-ui/react-avatar": "1.1.10",
"@radix-ui/react-dialog": "1.1.14",
"@radix-ui/react-dropdown-menu": "2.1.15",
"@radix-ui/react-label": "2.1.7",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-select": "2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-switch": "1.2.5",
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-tooltip": "1.2.7",
"apollo-upload-client": "18.0.1",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"graphql": "16.11.0",
"html-react-parser": "^5.2.6",
"input-otp": "1.4.2",
"lucide-react": "0.534.0",
"next": "15.4.5",
"next-intl": "4.3.4",
"next-themes": "0.4.6",
"react": "19.1.1",
"react-dom": "19.1.1",
"react-hook-form": "7.61.1",
"react-icons": "5.5.0",
"sonner": "2.0.6",
"subscriptions-transport-ws": "0.11.0",
"tailwind-merge": "3.3.1",
"zod": "4.0.14",
"zustand": "5.0.7"
},
"devDependencies": {
"@eslint/eslintrc": "3.3.1",
"@next/eslint-plugin-next": "15.4.5",
"@parcel/watcher": "^2.5.1",
"@tailwindcss/postcss": "4.1.11",
"@types/node": "22.17.0",
"@types/react": "19.1.9",
"@types/react-dom": "19.1.7",
"eslint": "9.32.0",
"eslint-config-ksv741": "0.2.0",
"eslint-config-next": "15.4.4",
"tailwindcss": "4.1.11",
"ts-node": "10.9.2",
"tw-animate-css": "1.3.6",
"typescript": "5.8.3"
}
}

View File

@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {}
},
};
export default config;

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 986 B

View File

@ -0,0 +1,621 @@
{
"layout": {
"header": {
"logo": {
"platform": "Streaming platform"
},
"headerMenu": {
"login": "Sign In",
"register": "Sign Up",
"profileMenu": {
"notifications": {
"heading": "Notifications",
"loading": "Loading...",
"empty": "You have no notifications"
},
"successMessage": "Successfully logged out",
"errorMessage": "Logout error",
"channel": "My channel",
"dashboard": "Dashboard",
"logout": "Sign out"
}
},
"search": {
"placeholder": "Search"
}
},
"sidebar": {
"header": {
"expand": "Expand",
"collapse": "Collapse",
"navigation": "Navigation"
},
"userNav": {
"home": "Home",
"categories": "Categories",
"streams": "Streams",
"recommended": "Recommended"
},
"recommended": {
"heading": "Recommendations"
},
"dashboardNav": {
"settings": "Settings",
"streamSettings": "Stream Settings",
"keys": "Stream Keys",
"chatSettings": "Chat Settings",
"followers": "Followers",
"sponsors": "Sponsors",
"premium": "Premium Plans",
"transactions": "Transactions"
}
}
},
"home": {
"streamsHeading": "Channels You Might Like",
"categoriesHeading": "Categories You Might Like"
},
"categories": {
"heading": "Categories",
"overview": {
"heading": "Streams for this category"
}
},
"streams": {
"heading": "Streams",
"searchHeading": "Search by query"
},
"stream": {
"video": {
"offline": "is offline",
"loading": "Connecting...",
"player": {
"volume": "Volume",
"fullscreen": {
"open": "Fullscreen",
"exit": "Exit Fullscreen"
}
}
},
"info": {
"viewers": "viewers",
"offline": "Offline"
},
"actions": {
"follow": {
"confirmUnfollowHeading": "Unfollow",
"confirmUnfollowMessage": "You will no longer receive notifications from this channel and it will disappear from your followed list.",
"unfollowButton": "Unfollow",
"followButton": "Follow",
"successFollowMessage": "You have successfully followed",
"errorFollowMessage": "Error while following",
"successUnfollowMessage": "You have successfully unfollowed",
"errorUnfollowMessage": "Error while unfollowing"
},
"support": {
"alreadySponsor": "You are already a sponsor",
"supportAuthor": "Support the author",
"perMonth": "per month",
"choose": "Choose",
"errorMessage": "Error while creating payment"
},
"share": {
"heading": "Share"
}
},
"aboutChannel": {
"heading": "About",
"followersCount": "followers",
"noDescription": "Description not provided."
},
"sponsors": {
"heading": "Channel Sponsors"
},
"chat": {
"heading": "Chat",
"unavailable": "Chat unavailable",
"unavailableMessage": "Chat is unavailable while there is no stream on the channel. Please check back later!",
"loading": "Connecting...",
"info": {
"authRequired": "Authorization required",
"chatDisabled": "Chat disabled",
"premiumFollowersOnly": "For sponsors only",
"followersOnly": "For followers only"
},
"sendMessage": {
"placeholder": "Send a message",
"emojiPlaceholder": "Search",
"errorMessage": "Error while sending message."
}
},
"settings": {
"heading": "Streaming Settings",
"thumbnail": {
"updateButton": "Update Image",
"confirmModal": {
"heading": "Stream Thumbnail Removal",
"message": "Are you sure you want to delete the stream thumbnail? This action cannot be undone."
},
"info": "Supported formats: JPG, JPEG, PNG, or GIF. Max size: 10 MB.",
"successUpdateMessage": "Stream image successfully updated",
"errorUpdateMessage": "Error updating the stream image",
"successRemoveMessage": "Stream image successfully removed",
"errorRemoveMessage": "Error removing the stream image"
},
"info": {
"titleLabel": "Title",
"titlePlaceholder": "GTA 5 Stream",
"titleDescription": "Enter the title of your stream. It should be short and memorable.",
"categoryLabel": "Category",
"categoryPlaceholder": "Select a category",
"categoryDescription": "Select the category your content belongs to. This will help viewers find your streams more easily.",
"submitButton": "Save Changes",
"successMessage": "Stream settings successfully updated",
"errorMessage": "Error updating stream settings"
}
}
},
"success": {
"heading": "Payment Successful!",
"details": {
"heading": "Sponsorship Details:",
"price": "Price:",
"duration": "Duration:",
"channel": "Channel:"
},
"congratulations": "You now have access to the channel's premium content!",
"backToHome": "Back to Home",
"backToChannel": "Back to Channel",
"support": "If you have any questions, contact our support."
},
"notFound": {
"description": "Oops, something went wrong.",
"backToHome": "Back to Home"
},
"auth": {
"register": {
"heading": "Register on TeaStream",
"backButtonLabel": "Already have an account? Sign in",
"usernameLabel": "Username",
"usernameDescription": "This is the name other users will know you by. You can change it later.",
"emailLabel": "Email",
"emailDescription": "We may use your email for sending account-related notifications.",
"passwordLabel": "Password",
"passwordDescription": "The password must contain at least 8 characters.",
"submitButton": "Sign up",
"successAlertTitle": "Check Your Email",
"successAlertDescription": "A verification email has been sent to your inbox. If you don't see the email, check your spam folder",
"errorMessage": "Error when creating an account"
},
"verify": {
"heading": "Account verification",
"successMessage": "Account verified",
"errorMessage": "Verification error"
},
"login": {
"heading": "Login to TeaStream",
"backButtonLabel": "Don't have an account? Sign up!",
"loginLabel": "Login",
"loginDescription": "Your username or email that you used during registration.",
"passwordLabel": "Password",
"passwordDescription": "The password you used during registration.",
"pinLabel": "6-digit code",
"pinDescription": "Enter the code from your authentication app.",
"forgotPassword": "Forgot password?",
"submitButton": "Login",
"successMessage": "You have successfully logged in",
"errorMessage": "Error when logging in"
},
"resetPassword": {
"heading": "Reset password",
"backButtonLabel": "Already have an account? Sign in",
"emailLabel": "Email",
"emailDescription": "The email you used during registration",
"submitButton": "Reset password",
"successAlertTitle": "Link sent",
"successAlertDescription": "We have sent a password reset link to your email. If you have enabled Telegram notifications, the link was sent there as well",
"errorMessage": "Password reset error"
},
"newPassword": {
"heading": "New password",
"backButtonLabel": "Already have an account? Sign in",
"passwordLabel": "New password",
"passwordDescription": "The password you used during registration",
"passwordRepeatLabel": "Repeat password",
"passwordRepeatDescription": "Repeat your new password for confirmation",
"submitButton": "Set new password",
"successMessage": "Password successfully changed",
"errorMessage": "Error when setting a new password"
},
"deactivate": {
"heading": "Deactivate Account",
"backButtonLabel": "Go to Dashboard",
"emailLabel": "Email",
"emailDescription": "Your email that you used during registration",
"passwordLabel": "Password",
"passwordDescription": "The password you used during registration",
"pinLabel": "6-digit code",
"pinDescription": "A code has been sent to your email. If you enabled Telegram notifications, we sent the code there as well",
"submitButton": "Deactivate",
"successMessage": "You have successfully deactivated your account",
"errorMessage": "Deactivation error"
}
},
"dashboard": {
"settings": {
"header": {
"heading": "Settings",
"description": "Here you can manage your settings",
"profile": "Profile",
"account": "Account",
"appearance": "Appearance",
"notifications": "Notifications",
"sessions": "Sessions"
},
"profile": {
"header": {
"heading": "Profile",
"description": "Customize your profile by changing your avatar, personal information, and social media links. Update your information so that other users can learn more about you."
},
"avatar": {
"heading": "Profile Image",
"updateButton": "Update Profile Image",
"confirmModal": {
"heading": "Remove Avatar",
"message": "Are you sure you want to remove your profile image? This action cannot be undone."
},
"info": "Supported formats: JPG, JPEG, PNG, WEBP or GIF. Max size: 10 MB.",
"successUpdateMessage": "Profile image updated successfully.",
"errorUpdateMessage": "Error updating profile image.",
"successRemoveMessage": "Profile image removed successfully.",
"errorRemoveMessage": "Error removing profile image."
},
"info": {
"heading": "Profile Settings",
"usernameLabel": "Username",
"usernamePlaceholder": "johndoe",
"usernameDescription": "This is how other users will know you.",
"displayNameLabel": "Display Name",
"displayNamePlaceholder": "John Doe",
"displayNameDescription": "You can use uppercase letters in your display name as you wish.",
"bioLabel": "About Me",
"bioPlaceholder": "I love programming",
"bioDescription": "The information about yourself should contain no more than 300 characters.",
"successMessage": "Profile successfully updated",
"errorMessage": "Error updating profile",
"submitButton": "Save Changes"
},
"socialLinks": {
"createForm": {
"heading": "Social Media Links",
"titleLabel": "Link Title",
"titlePlaceholder": "Youtube",
"titleDescription": "Link text",
"urlLabel": "Link URL",
"urlPlaceholder": "https://github.com/TeaCoder52",
"urlDescription": "Where does this link lead? Please enter the full URL, e.g., https://github.com/TeaCoder52",
"submitButton": "Add",
"successMessage": "Social media link successfully added",
"errorMessage": "Error adding social media link"
},
"editForm": {
"cancelButton": "Cancel",
"submitButton": "Save",
"successUpdateMessage": "Social media link successfully updated",
"errorUpdateMessage": "Error updating social media link",
"successRemoveMessage": "Social media link successfully removed",
"errorRemoveMessage": "Error removing social media link"
},
"successReorderMessage": "Order of social media links changed",
"errorReorderMessage": "Error changing order of social media links"
}
},
"account": {
"header": {
"heading": "Account",
"description": "Manage your account settings, including access changes, security, and deactivation options.",
"securityHeading": "Security",
"securityDescription": "Set up two-factor authentication and auto-renewals for enhanced protection of your data.",
"deactivationHeading": "Deactivation",
"deactivationDescription": "If you want to temporarily or permanently disable your account, use this option. Be aware of the consequences."
},
"email": {
"heading": "Email Address",
"emailLabel": "Email",
"emailDescription": "Enter your new email address.",
"submitButton": "Save",
"successMessage": "Email successfully updated",
"errorMessage": "Error during mail update"
},
"password": {
"heading": "Account Password",
"oldPasswordLabel": "Old Password",
"oldPasswordDescription": "Enter your old password to verify your identity before changing your password. This is necessary to ensure the security of your account.",
"newPasswordLabel": "New Password",
"newPasswordDescription": "Your new password must be at least 8 characters long. It is recommended to use special characters for added security.",
"submitButton": "Save",
"successMessage": "Password successfully updated",
"errorMessage": "Error when updating password"
},
"twoFactor": {
"heading": "TOTP Authentication",
"description": "Enhance your account security by enabling TOTP authentication. This additional layer of protection requires entering a unique code, making your account less vulnerable to unauthorized access.",
"enable": {
"trigger": "Enable",
"heading": "Enabling TOTP",
"qrInstructions": "Scan the QR code to add TOTP",
"secretCodeLabel": "Secret Code: ",
"pinLabel": "PIN Code",
"pinDescription": "Please enter the 6-digit code provided by your authentication app.",
"submitButton": "Confirm",
"successMessage": "TOTP authentication enabled",
"errorMessage": "Error verifying the code"
},
"disable": {
"trigger": "Disable",
"heading": "Are you sure you want to disable TOTP?",
"message": "Disabling TOTP authentication will make your account less secure. Are you sure you want to proceed?",
"successMessage": "TOTP authentication has been disabled",
"errorMessage": "Error disabling"
}
},
"deactivation": {
"heading": "Account Deactivation",
"description": "After deactivating your account, you will not be able to log in. Your account will be completely deleted after 7 days. Until then, you can contact support to restore access and unlock your account.",
"confirmModal": {
"heading": "Deactivation Confirmation",
"message": "Are you sure you want to deactivate your account? Access can only be restored within 7 days."
},
"button": "Deactivate"
}
},
"appearance": {
"header": {
"heading": "Appearance",
"description": "Customize the look of the website interface to your liking. Choose a theme and language that make your experience more comfortable."
},
"theme": {
"heading": "Change Theme",
"description": "You can choose a dark or light theme for the website interface.",
"successMessage": "The website theme has been successfully changed"
},
"language": {
"heading": "Website Language",
"description": "Select the language that will be used on the website.",
"selectPlaceholder": "Select a language",
"successMessage": "The website language has been successfully changed"
},
"color": {
"heading": "Accent Color",
"description": "Choose an accent color for the interface"
}
},
"notifications": {
"header": {
"heading": "Notifications",
"description": "Configure how you want to receive notifications from TeaStream. You will receive notifications about the start of streams on the channels you are subscribed to, as well as about new followers."
},
"siteNotifications": {
"heading": "Site Notifications",
"description": "If you turn off site notifications, you will not receive notifications about new subscribers to your channel and notifications about new streams."
},
"telegramNotifications": {
"heading": "Telegram Notifications",
"description": "If you enable notifications via Telegram, your TeaStream account will be linked to your Telegram account, and you will receive messages about new subscriptions, new streams, password resets, and two-factor authentication codes."
},
"successMessage": "Notification settings have been successfully updated",
"errorMessage": "Error when updating notification settings"
},
"sessions": {
"header": {
"heading": "Sessions",
"description": "Sessions are devices that you are currently using or have used to access your TeaStream account. Here you can see the active sessions right now."
},
"info": {
"current": "Current session",
"active": "Active sessions",
"notFound": "No active sessions found"
},
"sessionItem": {
"deleteButton": "Delete",
"detailsButton": "Details",
"confirmModal": {
"heading": "Delete Session",
"message": "Are you sure you want to delete this session? This action will result in the irreversible deletion of the session on this device and will terminate all active actions associated with it."
},
"successMessage": "Session deleted"
},
"sessionModal": {
"heading": "Session Information",
"device": "Device:",
"location": "Location:",
"ipAddress": "IP Address:",
"createdAt": "Created At:"
}
}
},
"keys": {
"header": {
"heading": "Stream Keys",
"description": "Generate a URL and key for streaming."
},
"url": {
"heading": "URL"
},
"key": {
"heading": "Stream Key"
},
"instructionModal": {
"trigger": "Instruction",
"heading": "🛠️ OBS Setup Guide for Live Streaming 🛠️",
"description": "To start sharing your content with your audience, follow this step-by-step guide to connecting OBS Studio. Just a few steps, and you're ready to go live!",
"step1Title": "🔑 Step 1: Download OBS Studio and Generate Server URL and Stream Key",
"step1Description": "To start streaming, you need two key elements: Server URL and Stream Key. Follow these steps:",
"downloadObs": "Download OBS Studio",
"downloadObsDescription": "Before setting up your stream, download and install the program",
"obsLinkText": "OBS Studio",
"copyKeys": "Copy Server URL and Stream Key",
"copyKeysDescription": "Once generated, you will see two fields: Server URL and Stream Key. Click the copy icons next to each field to save them to your clipboard.",
"step2Title": "🎛️ Step 2: Set Up OBS Studio",
"step2Description": "Now that you have the required data, lets set up OBS to work with your stream:",
"openObs": "Open OBS Studio",
"openObsDescription": "If OBS is not installed yet, download it from the official site and install it. Once launched, the main OBS page will open.",
"openStreamSettings": "Open Stream Settings",
"openStreamSettingsDescription": "In the top menu, click “File” → “Settings.” Go to the “Stream” tab.",
"enterDetails": "Enter Your Details",
"enterDetailsDescription": "In the 'Service' field, select 'Custom...'. Paste the previously copied Server URL into the 'URL' field. In the 'Stream Key' field, enter your Stream Key.",
"saveSettings": "Save Settings",
"saveSettingsDescription": "Click 'Apply' and then 'OK' to save the changes.",
"step3Title": "🚀 Step 3: Start Streaming",
"step3Description": "After successfully setting up OBS, youre ready to start a live broadcast:",
"startStream": "Start Streaming",
"startStreamDescription": "Return to the main OBS screen and click the 'Start Streaming' button.",
"monitorStream": "Monitor Your Stream",
"monitorStreamDescription": "Your stream will begin in real-time, and you can monitor it both on the platform and in OBS.",
"congrats": "✨ Congratulations! You've successfully set up OBS for streaming on our platform. Your audience awaits new exciting moments and content! 🎉",
"close": "Close"
},
"createModal": {
"trigger": "Generate Keys",
"heading": "Generate Keys",
"ingressTypeLabel": "Connection Type",
"ingressTypePlaceholder": "Select connection type",
"ingressTypeDescription": "Select RTMP for classic broadcasting or WHIP for WebRTC connections.",
"submitButton": "Generate",
"successMessage": "Ingress created",
"errorMessage": "Error creating ingress"
}
},
"chat": {
"header": {
"heading": "Chat Settings",
"description": "Here you can manage your chat settings."
},
"isChatEnabled": {
"heading": "Enable Chat",
"description": "If you enable chat, viewers will be able to send messages in the chat during your broadcast."
},
"isChatFollowersOnly": {
"heading": "Followers-Only Chat",
"description": "If you enable this setting, only your followers will be able to send messages in the chat."
},
"isChatPremiumFollowersOnly": {
"heading": "Premium Followers-Only Chat",
"description": "If you enable this setting, only premium followers will be able to send messages in the chat."
},
"successMessage": "Chat settings updated successfully",
"errorMessage": "Error updating chat settings"
},
"followers": {
"header": {
"heading": "Followers",
"description": "Here is a list of all your followers."
},
"columns": {
"date": "Date",
"user": "User",
"actions": "Actions",
"viewChannel": "View channel"
}
},
"sponsors": {
"header": {
"heading": "Sponsors",
"description": "Here is a list of all your sponsors."
},
"columns": {
"date": "Expiration Date",
"user": "User",
"plan": "Plan",
"actions": "Actions",
"viewChannel": "View Channel"
}
},
"plans": {
"header": {
"heading": "Premium Plans",
"description": "Here is the list of premium plans for your channel."
},
"alert": {
"heading": "Channel verification required",
"description": "To create premium plans, your channel must be verified. You only need 10 subscribers to get the checkmark and unlock access to this feature."
},
"createForm": {
"trigger": "Create Plan",
"heading": "Create Plan",
"titleLabel": "Title",
"titlePlaceholder": "Premium subscription for the channel",
"titleDescription": "Enter the name of your plan. This name will be displayed to your subscribers.",
"descriptionLabel": "Description",
"descriptionPlaceholder": "Describe what your plan includes",
"descriptionDescription": "Describe what your plan includes. This will help your subscribers understand the benefits of the subscription.",
"priceLabel": "Price",
"priceDescription": "Specify the price of your plan in currency. This is the amount users will pay for the subscription.",
"submitButton": "Create",
"successMessage": "Premium plan created",
"errorMessage": "Error while creating the plan"
},
"columns": {
"date": "Creation Date",
"title": "Title",
"price": "Price",
"actions": "Actions",
"remove": "Remove",
"successMessage": "Plan successfully removed",
"removeMessage": "Error while removing the plan"
}
},
"transactions": {
"header": {
"heading": "Transactions",
"description": "Here are the transactions related to channel sponsorship."
},
"columns": {
"date": "Creation Date",
"status": "Status",
"success": "Successful",
"pending": "Pending",
"failed": "Failed",
"expired": "Expired",
"amount": "Amount"
}
}
},
"components": {
"liveBadge": {
"text": "LIVE"
},
"confirmModal": {
"cancel": "Cancel",
"continue": "Continue"
},
"copyButton": {
"successMessage": "Copied"
},
"dataTable": {
"notFound": "Nothing found"
},
"emptyState": {
"heading": "Nothing found",
"text": "We've searched every corner of our site, but unfortunately, nothing was found for your request. Maybe you could try something else?"
}
},
"utils": {
"formatDate": {
"months": {
"january": "january",
"february": "february",
"march": "march",
"april": "april",
"may": "may",
"june": "june",
"july": "july",
"august": "august",
"september": "september",
"october": "october",
"november": "november",
"december": "december"
}
}
}
}

View File

@ -0,0 +1,622 @@
{
"layout": {
"header": {
"logo": {
"platform": "Платформа для стримов"
},
"headerMenu": {
"login": "Войти",
"register": "Регистрация",
"profileMenu": {
"notifications": {
"heading": "Уведомления",
"loading": "Загрузка...",
"empty": "У вас нет уведомлений"
},
"successMessage": "Вы успешно вышли из системы",
"errorMessage": "Ошибка при выходе",
"channel": "Мой канал",
"dashboard": "Панель управления",
"logout": "Выйти"
}
},
"search": {
"placeholder": "Поиск"
}
},
"sidebar": {
"header": {
"expand": "Развернуть",
"collapse": "Свернуть",
"navigation": "Навигация"
},
"dashboardNav": {
"settings": "Настройки",
"streamSettings": "Настройки стрима",
"keys": "Ключи для стрима",
"chatSettings": "Настройки чата",
"followers": "Подписчики",
"sponsors": "Спонсоры",
"premium": "Премиум планы",
"transactions": "Транзакции"
},
"userNav": {
"home": "Главная",
"categories": "Категории",
"streams": "Трансляции",
"recommended": "Рекомендации"
},
"recommended": {
"heading": "Рекомендации"
}
}
},
"home": {
"streamsHeading": "Каналы, которые могут вам понравиться",
"categoriesHeading": "Категории, которые могут вам понравиться"
},
"categories": {
"heading": "Категории",
"overview": {
"heading": "Стримы к этой категории"
}
},
"streams": {
"heading": "Трансляции",
"searchHeading": "Поиск по запросу"
},
"stream": {
"video": {
"offline": "не в сети",
"loading": "Подключение...",
"player": {
"volume": "Громкость",
"fullscreen": {
"open": "Полноэкранный режим",
"exit": "Выйти из полноэкранного режима"
}
}
},
"info": {
"viewers": "зрителей",
"offline": "Не в сети"
},
"actions": {
"follow": {
"confirmUnfollowHeading": "Перестать отслеживать",
"confirmUnfollowMessage": "Вы больше не будете получать уведомления от этого канала и он пропадет из списка отслеживаемых",
"unfollowButton": "Перестать отслеживать",
"followButton": "Отслеживать",
"successFollowMessage": "Вы успешно подписались",
"errorFollowMessage": "Ошибка при подписке",
"successUnfollowMessage": "Вы успешно описались",
"errorUnfollowMessage": "Ошибка при описке"
},
"support": {
"alreadySponsor": "Вы уже спонсор",
"supportAuthor": "Поддержать автора",
"perMonth": "в месяц",
"choose": "Выбрать",
"errorMessage": "Ошибка при создании платежа"
},
"share": {
"heading": "Поделиться с помощью"
}
},
"aboutChannel": {
"heading": "Информация про",
"followersCount": "фолловеров",
"noDescription": "Описание не указано"
},
"sponsors": {
"heading": "Спонсоры канала"
},
"chat": {
"heading": "Чат",
"unavailable": "Чат не доступен",
"unavailableMessage": "Чат недоступен, пока на канале нет трансляции. Пожалуйста, загляните позже!",
"loading": "Подключение...",
"info": {
"authRequired": "Требуется авторизация",
"chatDisabled": "Чат отключен",
"premiumFollowersOnly": "Только для спонсоров",
"followersOnly": "Только для подписчиков"
},
"sendMessage": {
"placeholder": "Отправить сообщение",
"emojiPlaceholder": "Поиск",
"errorMessage": "Ошибка при отправке сообщения"
}
},
"settings": {
"heading": "Параметры трансляции",
"thumbnail": {
"updateButton": "Обновить изображение",
"confirmModal": {
"heading": "Удаление превью для стрима",
"message": "Вы уверены, что хотите удалить изображение для стрима? Это действие нельзя будет отменить."
},
"info": "Поддерживаемые форматы: JPG, JPEG, PNG или GIF. Макс. размер: 10 МБ.",
"successUpdateMessage": "Изображение стрима обновлено удалено",
"errorUpdateMessage": "Ошибка при обновлении изображения стрима",
"successRemoveMessage": "Изображение стрима обновлено удалено",
"errorRemoveMessage": "Ошибка при удалении изображения стрима"
},
"info": {
"titleLabel": "Название",
"titlePlaceholder": "Стрим по ГТА 5",
"titleDescription": "Введите название вашего стрима. Оно должно быть коротким и запоминающимся.",
"categoryLabel": "Категория",
"categoryPlaceholder": "Выберете категорию",
"categoryDescription": "Выберите категорию, к которой относится ваш контент. Это поможет зрителям легче находить ваши трансляции.",
"submitButton": "Сохранить изменения",
"successMessage": "Настройки стрима успешно обновлены",
"errorMessage": "Ошибка при обновлении настроек стрима"
}
}
},
"success": {
"heading": "Оплата прошла успешно!",
"details": {
"heading": "Детали спонсорства:",
"price": "Цена:",
"duration": "Длительность:",
"channel": "Канал:"
},
"congratulations": "Теперь у вас есть доступ к премиум-контенту канала!",
"backToHome": "Вернуться на главную",
"backToChannel": "Вернуться на канал",
"support": "Если у вас возникли вопросы, свяжитесь с нашей поддержкой."
},
"notFound": {
"description": "Упс, что-то пошло не так.",
"backToHome": "Перейти на главную"
},
"auth": {
"register": {
"heading": "Регистрация в TeaStream",
"backButtonLabel": "Есть учетная запись? Войти",
"usernameLabel": "Имя пользователя",
"usernameDescription": "Под этим именем вас будут знать другие пользователи.",
"emailLabel": "Почта",
"emailDescription": "Мы можем использовать вашу почту для отправки сообщений касательно учетной записи.",
"passwordLabel": "Пароль",
"passwordDescription": "Пароль должен содержать не менее 8 символов.",
"submitButton": "Зарегистрироваться",
"successAlertTitle": "Проверьте свою почту",
"successAlertDescription": "На ваш адрес электронной почты было отправлено письмо для подтверждения. Если вы не видите письмо, проверьте папку «Спам»",
"errorMessage": "Ошибка при создании аккаунта"
},
"verify": {
"heading": "Верификация аккаунта",
"successMessage": "Аккаунт верифицирован",
"errorMessage": "Ошибка при верификации"
},
"login": {
"heading": "Войти в TeaStream",
"backButtonLabel": "У вас нет учетной записи? Зарегистрируйтесь!",
"loginLabel": "Логин",
"loginDescription": "Ваше имя или почта, которые вы вводили при регистрации.",
"passwordLabel": "Пароль",
"passwordDescription": "Пароль, который вы вводили при регистрации.",
"pinLabel": "6-значный код",
"pinDescription": "Введите код из вашего приложения для аутентификации.",
"forgotPassword": "Забыли пароль?",
"submitButton": "Войти",
"successMessage": "Вы успешно вошли в систему",
"errorMessage": "Ошибка при входе в систему"
},
"resetPassword": {
"heading": "Сброс пароля",
"backButtonLabel": "Есть учётная запись? Войти",
"emailLabel": "Почта",
"emailDescription": "Ваша почта, которую вы вводили при регистрации",
"submitButton": "Сбросить пароль",
"successAlertTitle": "Ссылка отправлена",
"successAlertDescription": "Мы отправили ссылку для сброса пароля на вашу почту. Если у вас включены уведомления в Telegram, ссылка также была отправлена туда",
"errorMessage": "Ошибка при сбросе пароля"
},
"newPassword": {
"heading": "Новый пароль",
"backButtonLabel": "Есть учётная запись? Войти",
"passwordLabel": "Новый пароль",
"passwordDescription": "Пароль, который вы вводили при регистрации",
"passwordRepeatLabel": "Повторите пароль",
"passwordRepeatDescription": "Повторите свой новый пароль для подтверждения",
"submitButton": "Продолжить",
"successMessage": "Пароль успешно изменён",
"errorMessage": "Ошибка при установлении нового пароля"
},
"deactivate": {
"heading": "Деактивация аккаунта",
"backButtonLabel": "Перейти в панель управления",
"emailLabel": "Почта",
"emailDescription": "Ваша почта, которую вы вводили при регистрации",
"passwordLabel": "Пароль",
"passwordDescription": "Пароль, который вы вводили при регистрации",
"pinLabel": "6-значный код",
"pinDescription": "Вам на почту был выслан код. Если вы подключали уведомления в Telegram, мы отправили код и туда",
"submitButton": "Деактивировать",
"successMessage": "Вы успешно деактивировали аккаунт",
"errorMessage": "Ошибка при деактивации"
}
},
"dashboard": {
"settings": {
"header": {
"heading": "Настройки",
"description": "Здесь вы можете управлять вашими настройками",
"profile": "Профиль",
"account": "Аккаунт",
"appearance": "Внешний вид",
"notifications": "Уведомления",
"sessions": "Сессии"
},
"profile": {
"header": {
"heading": "Профиль",
"description": "Настройте ваш профиль, обновите аватар, измените информацию о себе и добавьте ссылки на социальные сети, чтобы сделать вашу страницу более привлекательной для других пользователей."
},
"avatar": {
"heading": "Изображение профиля",
"updateButton": "Обновить изображение профиля",
"confirmModal": {
"heading": "Удаление аватара",
"message": "Вы уверены, что хотите удалить изображение профиля? Это действие нельзя будет отменить."
},
"info": "Поддерживаемые форматы: JPG, JPEG, PNG, WEBP или GIF. Макс. размер: 10 МБ.",
"successUpdateMessage": "Изображение профиля успешно обновлено",
"errorUpdateMessage": "Ошибка при обновлении изображения профиля",
"successRemoveMessage": "Изображение профиля успешно удалено",
"errorRemoveMessage": "Ошибка при удалении изображения профиля"
},
"info": {
"heading": "Настройки профиля",
"usernameLabel": "Имя пользователя",
"usernamePlaceholder": "johndoe",
"usernameDescription": "Под этим именем вас будут знать другие пользователи.",
"displayNameLabel": "Отображаемое имя",
"displayNamePlaceholder": "John Doe",
"displayNameDescription": "Проставьте прописные буквы в имени пользователя по своему желанию.",
"bioLabel": "О себе",
"bioPlaceholder": "Я люблю программировать",
"bioDescription": "Информация о себе должна содержать не более 300 символов.",
"submitButton": "Сохранить изменения",
"successMessage": "Профиль успешно обновлён",
"errorMessage": "Ошибка при обновлении профиля"
},
"socialLinks": {
"createForm": {
"heading": "Ссылки на соцсети",
"titleLabel": "Название ссылки",
"titlePlaceholder": "Youtube",
"titleDescription": "Текст ссылки",
"urlLabel": "URL ссылки",
"urlPlaceholder": "https://github.com/TeaCoder52",
"urlDescription": "Куда ведет эта ссылка? Введите полный URL-адрес, например https://github.com/TeaCoder52",
"submitButton": "Добавить",
"successMessage": "Ссылка на соц. сеть успешно добавлена",
"errorMessage": "Ошибка при добавлении ссылки на соц. сеть"
},
"editForm": {
"cancelButton": "Отмена",
"submitButton": "Сохранить",
"successUpdateMessage": "Ссылка на соц. сеть успешно изменена",
"errorUpdateMessage": "Ошибка при обновлении соц. сети",
"successRemoveMessage": "Ссылка на соц. сеть успешно удалена",
"errorRemoveMessage": "Ошибка при удалении соц. сети"
},
"successReorderMessage": "Порядок ссылок на соц. сети изменен",
"errorReorderMessage": "Ошибка при изменении порядка соц. сетей"
}
},
"account": {
"header": {
"heading": "Аккаунт",
"description": "Управляйте настройками вашего аккаунта, включая изменение доступа, безопасность и возможность деактивации.",
"securityHeading": "Безопасность",
"securityDescription": "Настройте двухфакторную аутентификацию для улучшенной защиты ваших данных.",
"deactivationHeading": "Деактивация",
"deactivationDescription": "Если вы хотите временно или постоянно отключить свой аккаунт, используйте эту опцию. Обратите внимание на последствия."
},
"email": {
"heading": "Адрес электронной почты",
"emailLabel": "Почта",
"emailDescription": "Введите ваш новый адрес электронной почты.",
"submitButton": "Сохранить",
"successMessage": "Почта успешно обновлена",
"errorMessage": "Ошибка при обновлении почты"
},
"password": {
"heading": "Пароль от аккаунта",
"oldPasswordLabel": "Старый пароль",
"oldPasswordDescription": "Введите свой старый пароль, чтобы подтвердить вашу личность перед изменением пароля. Это необходимо для обеспечения безопасности вашей учетной записи.",
"newPasswordLabel": "Новый пароль",
"newPasswordDescription": "Ваш новый пароль должен содержать не менее 8 символов. Рекомендуется использовать также специальные символы для повышения безопасности.",
"submitButton": "Сохранить",
"successMessage": "Пароль успешно обновлён",
"errorMessage": "Ошибка при обновлении пароля"
},
"twoFactor": {
"heading": "Аутентификация с помощью TOTP",
"description": "Увеличьте безопасность вашего аккаунта, активировав аутентификацию с помощью TOTP. Этот дополнительный уровень защиты требует ввода уникального кода, что делает ваш аккаунт менее уязвимым к несанкционированному доступу.",
"enable": {
"trigger": "Включить",
"heading": "Включение TOTP",
"qrInstructions": "Сканируйте QR-код для добавления TOTP",
"secretCodeLabel": "Секретный код: ",
"pinLabel": "PIN-код",
"pinDescription": "Пожалуйста, введите 6-значный код, предоставленный вашим приложением для аутентификации.",
"submitButton": "Подтвердить",
"successMessage": "Аутентификация с помощью TOTP включена",
"errorMessage": "Ошибка при верификации кода"
},
"disable": {
"trigger": "Выключить",
"heading": "Вы действительно хотите отключить TOTP?",
"message": "При отключении аутентификации с помощью TOTP ваш аккаунт станет менее защищенным. Вы уверены, что хотите продолжить?",
"successMessage": "Аутентификация через TOTP отключена",
"errorMessage": "Ошибка при отключении"
}
},
"deactivation": {
"heading": "Деактивация аккаунта",
"description": "После деактивации аккаунта вы не сможете в него войти. Через 7 дней аккаунт будет полностью удалён. До этого времени вы можете обратиться в поддержку для восстановления доступа и разблокировки аккаунта.",
"confirmModal": {
"heading": "Подтверждение деактивации",
"message": "Вы уверены, что хотите деактивировать аккаунт? Восстановить доступ можно только в течение 7 дней."
},
"button": "Деактивировать"
}
},
"appearance": {
"header": {
"heading": "Внешний вид",
"description": "Настройте внешний вид интерфейса сайта по своему вкусу. Выберите тему и язык, которые сделают ваше взаимодействие более комфортным."
},
"theme": {
"heading": "Смена темы",
"description": "Вы можете выбрать темную или светлую тему для интерфейса сайта",
"successMessage": "Тема сайта успешно изменена"
},
"language": {
"heading": "Язык сайта",
"description": "Выберите язык, который будет использован на сайте",
"selectPlaceholder": "Выберите язык",
"successMessage": "Язык сайта успешно изменен"
},
"color": {
"heading": "Цвет акцента",
"description": "Выберите цвет акцента для интерфейса"
}
},
"notifications": {
"header": {
"heading": "Уведомления",
"description": "Настройте, как вы хотите получать уведомления от TeaStream. Вы будете получать уведомления о начале трансляций на каналах, на которые вы подписаны, а также о новых подписчиках."
},
"siteNotifications": {
"heading": "Уведомления на сайте",
"description": "Если вы выключите уведомления на сайте, то не будете получать уведомления о новых подписчиках на ваш канал и уведомления о новых трансляциях."
},
"telegramNotifications": {
"heading": "Уведомления в Telegram",
"description": "Если вы включите уведомления через Telegram, ваш аккаунт TeaStream свяжется с вашим Telegram-аккаунтом, и вы будете получать сообщения о новых подписках, новых стримах, сбросе пароля и коды двухфакторной аутентификации."
},
"successMessage": "Настройки уведомлений успешно обновлены",
"errorMessage": "Ошибка при обновлении настроек уведомлений"
},
"sessions": {
"header": {
"heading": "Сессии",
"description": "Сессии — это устройства, которые вы используете или которые использовали для входа в вашу учетную запись TeaStream. Здесь показаны активные сессии в данный момент."
},
"info": {
"current": "Текущая сессия",
"active": "Активные сессии",
"notFound": "Активных сессий не найдено"
},
"sessionItem": {
"deleteButton": "Удалить",
"detailsButton": "Подробнее",
"confirmModal": {
"heading": "Удаление сессии",
"message": "Вы уверены, что хотите удалить эту сессию? Это действие приведёт к необратимому удалению сессии на этом устройстве и завершит все активные действия, связанные с ним."
},
"successMessage": "Сессия удалена",
"errorMessage": "Ошибка при удалении сессии"
},
"sessionModal": {
"heading": "Информация о сессии",
"device": "Устройство:",
"location": "Местоположение:",
"ipAddress": "IP-адрес:",
"createdAt": "Дата создания:"
}
}
},
"keys": {
"header": {
"heading": "Ключи для стрима",
"description": "Сгенерируйте URL и ключ для проведения трансляции."
},
"url": {
"heading": "URL"
},
"key": {
"heading": "Ключ"
},
"instructionModal": {
"trigger": "Инструкция",
"heading": "🛠️ Инструкция по настройке OBS для прямых трансляций 🛠️",
"description": "Чтобы начать делиться своим контентом с аудиторией, следуйте подробной инструкции по подключению OBS Studio. Это всего несколько шагов, и вы готовы выходить в эфир!",
"step1Title": "🔑 Шаг 1: Скачивание OBS Studio и генерация Server URL и Stream Key",
"step1Description": "Чтобы начать трансляцию, вам необходимо получить два важных элемента: Server URL и Stream Key (ключ трансляции). Следуйте этим шагам:",
"downloadObs": "Скачайте OBS Studio",
"downloadObsDescription": "Прежде чем настраивать трансляцию, скачайте и установите программу",
"obsLinkText": "OBS Studio",
"copyKeys": "Скопируйте Server URL и Stream Key",
"copyKeysDescription": "После генерации на экране появятся два поля: Server URL и Stream Key. Нажмите на иконки копирования рядом с каждым из полей, чтобы сохранить данные в буфер обмена.",
"step2Title": "🎛️ Шаг 2: Настройка OBS Studio",
"step2Description": "Теперь, когда у вас есть необходимые данные, давайте настроим OBS для работы с вашей трансляцией:",
"openObs": "Откройте OBS Studio",
"openObsDescription": "Если у вас еще не установлен OBS, скачайте его с официального сайта и установите. После запуска откроется главная страница OBS.",
"openStreamSettings": "Откройте настройки трансляции",
"openStreamSettingsDescription": "В верхнем меню нажмите «Файл» → «Настройки». Перейдите во вкладку «Трансляция».",
"enterDetails": "Введите ваши данные",
"enterDetailsDescription": "В поле «Сервис» выберите опцию «Пользовательский...». Вставьте скопированный ранее Server URL в поле «URL». В поле «Ключ трансляции» вставьте ваш Stream Key.",
"saveSettings": "Сохраните настройки",
"saveSettingsDescription": "Нажмите «Применить», а затем «ОК», чтобы сохранить изменения.",
"step3Title": "🚀 Шаг 3: Начало трансляции",
"step3Description": "После успешной настройки OBS вы готовы начать прямую трансляцию:",
"startStream": "Запустите трансляцию",
"startStreamDescription": "Вернитесь на главный экран OBS и нажмите кнопку «Начать трансляцию».",
"monitorStream": "Следите за своей трансляцией",
"monitorStreamDescription": "Ваша трансляция начнется в режиме реального времени, и вы сможете наблюдать за ней как на платформе, так и в OBS.",
"congrats": "✨ Поздравляем! Теперь вы успешно настроили OBS для трансляций на нашей платформе. Ваша аудитория ждет новых ярких моментов и контента! 🎉",
"close": "Закрыть"
},
"createModal": {
"trigger": "Сгенерировать ключи",
"heading": "Сгенерировать ключи",
"ingressTypeLabel": "Тип подключения",
"ingressTypePlaceholder": "Выберите тип подключения",
"ingressTypeDescription": "Выберите RTMP для классического вещания или WHIP для WebRTC подключений.",
"submitButton": "Сгенерировать",
"successMessage": "Входной поток создан",
"errorMessage": "Ошибка при создании входного потока"
}
},
"chat": {
"header": {
"heading": "Настройки чата",
"description": "Здесь вы можете управлять настройками вашего чата."
},
"isChatEnabled": {
"heading": "Включить чат",
"description": "Если вы включите чат, зрители смогут писать сообщения в чате во время вашей трансляции."
},
"isChatFollowersOnly": {
"heading": "Чат только для подписчиков",
"description": "Если вы включите эту настройку, только ваши подписчики смогут писать сообщения в чате."
},
"isChatPremiumFollowersOnly": {
"heading": "Чат только для премиум подписчиков",
"description": "Если вы включите эту настройку, только премиум подписчики смогут писать сообщения в чате."
},
"successMessage": "Настройки чата успешно обновлены",
"errorMessage": "Ошибка при обновлении настроек чата "
},
"followers": {
"header": {
"heading": "Подписчики",
"description": "Здесь отображается список всех ваших подписчиков."
},
"columns": {
"date": "Дата подписи",
"user": "Пользователь",
"actions": "Действия",
"viewChannel": "Перейти на канал"
}
},
"sponsors": {
"header": {
"heading": "Спонсоры",
"description": "Здесь отображается список всех ваших спонсоров."
},
"columns": {
"date": "Дата окончания",
"user": "Пользователь",
"plan": "План",
"actions": "Действия",
"viewChannel": "Перейти на канал"
}
},
"plans": {
"header": {
"heading": "Премиум планы",
"description": "Здесь отображается список премиум планов для вашего канала."
},
"alert": {
"heading": "Требуется верификация канала",
"description": "Для создания премиум планов ваш канал должен быть верифицирован. Для этого вам нужно всего 10 подписчиков, чтобы получить галочку и разблокировать доступ к этой функции."
},
"createForm": {
"trigger": "Создать план",
"heading": "Создать план",
"titleLabel": "Название",
"titlePlaceholder": "Премиум подписка на канал",
"titleDescription": "Введите название вашего плана. Это название будет отображаться вашим подписчикам.",
"descriptionLabel": "Описание",
"descriptionPlaceholder": "Опишите, что включает в себя ваш план",
"descriptionDescription": "Опишите, что включает в себя ваш план. Это поможет вашим подписчикам понять преимущества подписки.",
"priceLabel": "Цена",
"priceDescription": "Укажите цену вашего плана в валюте. Это сумма, которую пользователи будут платить за подписку.",
"submitButton": "Создать",
"successMessage": "Премиум план создан",
"errorMessage": "Ошибка при создании плана"
},
"columns": {
"date": "Дата создания",
"title": "Название",
"price": "Цена",
"actions": "Действия",
"remove": "Удалить",
"successMessage": "План успешно удалён",
"removeMessage": "Ошибка при удалении плана"
}
},
"transactions": {
"header": {
"heading": "Транзакции",
"description": "Здесь отображаются транзакции, связанные с оформлением спонсорства на каналы."
},
"columns": {
"date": "Дата создания",
"status": "Статус",
"success": "Успешно",
"pending": "В ожидании",
"failed": "Ошибка",
"expired": "Истек",
"amount": "Сумма"
}
}
},
"components": {
"liveBadge": {
"text": "В ЭФИРЕ"
},
"confirmModal": {
"cancel": "Отмена",
"continue": "Продолжить"
},
"copyButton": {
"successMessage": "Скопировано"
},
"dataTable": {
"notFound": "Нечего не найдено"
},
"emptyState": {
"heading": "Нечего не найдено",
"text": "Мы обшарили все уголки нашего сайта, но к сожалению по вашему запросу ничего не нашлось. Может попробуете что-то другое?"
}
},
"utils": {
"formatDate": {
"months": {
"january": "января",
"february": "февраля",
"march": "марта",
"april": "апреля",
"may": "мая",
"june": "июня",
"july": "июля",
"august": "августа",
"september": "сентября",
"october": "октября",
"november": "ноября",
"december": "декабря"
}
}
}
}

View File

@ -0,0 +1,21 @@
import { getTranslations } from 'next-intl/server';
import { UserSettings } from '@/components/features/user/UserSettings';
import type { Metadata } from 'next';
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations('dashboard.settings.header');
return {
title: t('heading'),
description: t('description'),
robots: { index: false, follow: false },
};
}
const DashboardSettings = () => (
<UserSettings />
);
export default DashboardSettings;

View File

@ -0,0 +1,23 @@
import { Header } from '@/components/layout/header/Header';
import { LayoutContainer } from '@/components/layout/LayoutContainer';
import { Sidebar } from '@/components/layout/sidebar/Sidebar';
import type { PropsWithChildren } from 'react';
const SiteLayout = ({ children }: PropsWithChildren) => (
<div className="flex h-full flex-col">
<div className="flex-1">
<div className="fixed inset-y-0 z-50 h-[75px] w-full">
<Header />
</div>
<Sidebar />
<LayoutContainer>
{children}
</LayoutContainer>
</div>
</div>
);
export default SiteLayout;

View File

@ -0,0 +1,19 @@
import { getTranslations } from 'next-intl/server';
import type { Metadata } from 'next';
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations('layout.header.logo');
return {
title: t('platform'),
};
}
const Home = () => (
<div>
home
</div>
);
export default Home;

View File

@ -0,0 +1,20 @@
import { getTranslations } from 'next-intl/server';
import React from 'react';
import CreateAccountForm from '@/components/features/auth/forms/CreateAccountForm';
import type { Metadata } from 'next';
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations('auth.register');
return {
title: t('heading'),
};
}
const CreateAccountPage = () => (
<CreateAccountForm />
);
export default CreateAccountPage;

View File

@ -0,0 +1,19 @@
import { getTranslations } from 'next-intl/server';
import { DeactivateForm } from '@/components/features/auth/forms/DeactivateForm';
import { NO_INDEX_PAGE } from '@/libs/constants/seo.constants';
import type { Metadata } from 'next';
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations('auth.deactivate');
return {
title: t('heading'),
...NO_INDEX_PAGE,
};
}
export default function DeactivatePage() {
return <DeactivateForm />;
}

View File

@ -0,0 +1,20 @@
import { getTranslations } from 'next-intl/server';
import React from 'react';
import LoginAccountForm from '@/components/features/auth/forms/LoginAccountForm';
import type { Metadata } from 'next';
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations('auth.login');
return {
title: t('heading'),
};
}
const LoginAccountPage = () => (
<LoginAccountForm />
);
export default LoginAccountPage;

View File

@ -0,0 +1,20 @@
import { getTranslations } from 'next-intl/server';
import React from 'react';
import { NewPasswordForm } from '@/components/features/auth/forms/NewPasswordForm';
import type { Metadata } from 'next';
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations('auth.newPassword');
return {
title: t('heading'),
};
}
const NewPasswordPage = () => (
<NewPasswordForm />
);
export default NewPasswordPage;

View File

@ -0,0 +1,20 @@
import { getTranslations } from 'next-intl/server';
import React from 'react';
import { ResetPasswordForm } from '@/components/features/auth/forms/ResetPasswordForm';
import type { Metadata } from 'next';
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations('auth.resetPassword');
return {
title: t('heading'),
};
}
const ResetPasswordPage = () => (
<ResetPasswordForm />
);
export default ResetPasswordPage;

View File

@ -0,0 +1,33 @@
import { redirect } from 'next/navigation';
import { getTranslations } from 'next-intl/server';
import React from 'react';
import { VerifyAccountForm } from '@/components/features/auth/forms/VerifyAccoiuntForm';
import type { Metadata } from 'next';
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations('auth.verify');
return {
title: t('heading'),
};
}
type VerifyAccountPageProps = {
searchParams: Promise<{ token: string }>;
};
const VerifyAccountPage = async (props: VerifyAccountPageProps) => {
const { token } = await props.searchParams;
if (!token) {
return redirect('/account/create');
}
return (
<VerifyAccountForm token={token} />
);
};
export default VerifyAccountPage;

View File

@ -0,0 +1,57 @@
import { Geist } from 'next/font/google';
import { NextIntlClientProvider } from 'next-intl';
import { getLocale, getMessages } from 'next-intl/server';
import { ColorSwitcher } from '@/components/ui/elements/ColorSwitcher';
import ApolloClientProvider from '@/providers/ApolloClientProvider';
import { ThemeProvider } from '@/providers/ThemeProvider';
import { ToastProvider } from '@/providers/ToastProvider';
import '../styles/globals.css';
import '../styles/themes.css';
import type { Metadata } from 'next';
import type { ReactNode } from 'react';
const geistSans = Geist({
variable: '--font-geist-sans',
subsets: ['latin'],
});
export const metadata: Metadata = {
title: 'Create Next App',
description: 'Generated by create next app',
};
const RootLayout = async ({
children,
}: Readonly<{
children: ReactNode;
}>) => {
const locale = await getLocale();
const messages = await getMessages();
return (
<html suppressHydrationWarning lang={locale}>
<body className={geistSans.variable}>
<ColorSwitcher />
<ApolloClientProvider>
<NextIntlClientProvider messages={messages}>
<ThemeProvider
disableTransitionOnChange
enableSystem
attribute="class"
defaultTheme="dark"
>
<ToastProvider />
{children}
</ThemeProvider>
</NextIntlClientProvider>
</ApolloClientProvider>
</body>
</html>
);
};
export default RootLayout;

View File

@ -0,0 +1,54 @@
import Link from 'next/link';
import React from 'react';
import { LogoImage } from '@/components/images/LogoImage';
import { Button } from '@/components/ui/common/Button';
import {
Card, CardContent, CardFooter, CardHeader, CardTitle,
} from '@/components/ui/common/Card';
import type { FC, ReactNode } from 'react';
type AuthWrapperProps = {
heading: string;
backButtonLabel?: string;
backButtonHref?: string;
children: ReactNode;
};
const AuthWrapper: FC<AuthWrapperProps> = (props) => {
const {
children, backButtonHref, backButtonLabel, heading,
} = props;
return (
<div className="flex h-full items-center justify-center">
<Card className="w-[450px]">
<CardHeader className="flex-row items-center justify-center gap-x-4">
<LogoImage />
<CardTitle>
{heading}
</CardTitle>
</CardHeader>
<CardContent>
{children}
</CardContent>
<CardFooter className="-mt-2">
{backButtonLabel && backButtonHref
? (
<Link className="w-full" href={backButtonHref}>
<Button className="w-full" variant="ghost">
{backButtonLabel}
</Button>
</Link>
)
: null}
</CardFooter>
</Card>
</div>
);
};
export default AuthWrapper;

View File

@ -0,0 +1,162 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { CircleCheck } from 'lucide-react';
import { useTranslations } from 'next-intl';
import React, { useState } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/common/Alert';
import { Button } from '@/components/ui/common/Button';
import {
Form, FormControl, FormDescription, FormField, FormItem, FormLabel,
} from '@/components/ui/common/Form';
import { Input } from '@/components/ui/common/Input';
import { useCreateUserMutation } from '@/graphql/generated/output';
import { createAccountSchema } from '@/schemas/auth/create-account.schema';
import AuthWrapper from '../AuthWrapper';
import type { TypeCreateAccountSchema } from '@/schemas/auth/create-account.schema';
const CreateAccountForm = () => {
const [isSuccess, setIsSuccess] = useState(false);
const t = useTranslations('auth.register');
const form = useForm<TypeCreateAccountSchema>({
resolver: zodResolver(createAccountSchema),
defaultValues: {
name: '',
email: '',
password: '',
},
});
const { isValid } = form.formState;
const [create, { loading: isLoadingCreate }] = useCreateUserMutation({
onCompleted() {
setIsSuccess(true);
},
onError() {
toast.error(t('errorMessage'));
},
});
function onSubmit(data: TypeCreateAccountSchema) {
void create({ variables: { data } });
}
return (
<AuthWrapper
backButtonHref="/account/login"
backButtonLabel={t('backButtonLabel')}
heading={t('heading')}
>
{isSuccess
? (
<Alert>
<CircleCheck className="size-4" />
<AlertTitle>
{t('successAlertTitle')}
</AlertTitle>
<AlertDescription>
{t('successAlertDescription')}
</AlertDescription>
</Alert>
)
: (
<Form {...form}>
<form
className="grid gap-y-3"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('usernameLabel')}
</FormLabel>
<FormControl>
<Input
disabled={isLoadingCreate}
placeholder="johndoe"
{...field}
/>
</FormControl>
<FormDescription>
{t('usernameDescription')}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('emailLabel')}
</FormLabel>
<FormControl>
<Input
disabled={isLoadingCreate}
placeholder="john.doe@example.com"
{...field}
/>
</FormControl>
<FormDescription>
{t('emailDescription')}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('passwordLabel')}
</FormLabel>
<FormControl>
<Input
disabled={isLoadingCreate}
placeholder="********"
type="password"
{...field}
/>
</FormControl>
<FormDescription>
{t('passwordDescription')}
</FormDescription>
</FormItem>
)}
/>
<Button
className="mt-2 w-full"
disabled={!isValid || isLoadingCreate}
>
{t('submitButton')}
</Button>
</form>
</Form>
)}
</AuthWrapper>
);
};
export default CreateAccountForm;

View File

@ -0,0 +1,179 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useRouter } from 'next/navigation';
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 {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
} from '@/components/ui/common/Form';
import { Input } from '@/components/ui/common/Input';
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
} from '@/components/ui/common/InputOTP';
import { useDeactivateAccountMutation } from '@/graphql/generated/output';
import { useAuth } from '@/hooks/useAuth';
import { type TypeDeactivateSchema, deactivateSchema } from '@/schemas/auth/deactivate.schema';
import AuthWrapper from '../AuthWrapper';
export const DeactivateForm = () => {
const t = useTranslations('auth.deactivate');
const { exit } = useAuth();
const router = useRouter();
const [isShowConfirm, setIsShowConfirm] = useState(false);
const form = useForm<TypeDeactivateSchema>({
resolver: zodResolver(deactivateSchema),
defaultValues: {
email: '',
password: '',
},
});
const [deactivate, { loading: isLoadingDeactivate }] = useDeactivateAccountMutation({
onCompleted(data) {
if (data.deactivateAccount.message) {
setIsShowConfirm(true);
} else {
exit();
toast.success(t('successMessage'));
router.push('/');
}
},
onError() {
toast.error(t('errorMessage'));
},
});
const { isValid } = form.formState;
function onSubmit(data: TypeDeactivateSchema) {
deactivate({ variables: { data } });
}
return (
<AuthWrapper
backButtonHref="/dashboard/settings"
backButtonLabel={t('backButtonLabel')}
heading={t('heading')}
>
<Form {...form}>
<form
className="grid gap-y-3"
onSubmit={form.handleSubmit(onSubmit)}
>
{isShowConfirm
? (
<FormField
control={form.control}
name="pin"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('pinLabel')}
</FormLabel>
<FormControl>
<InputOTP maxLength={6} {...field}>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
</FormControl>
<FormDescription>
{t('pinDescription')}
</FormDescription>
</FormItem>
)}
/>
)
: (
<>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('emailLabel')}
</FormLabel>
<FormControl>
<Input
disabled={isLoadingDeactivate}
placeholder="john.doe@example.com"
{...field}
/>
</FormControl>
<FormDescription>
{t('emailDescription')}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('passwordLabel')}
</FormLabel>
<FormControl>
<Input
disabled={isLoadingDeactivate}
placeholder="********"
type="password"
{...field}
/>
</FormControl>
<FormDescription>
{t('passwordDescription')}
</FormDescription>
</FormItem>
)}
/>
</>
)}
<Button
className="mt-2 w-full"
disabled={!isValid || isLoadingDeactivate}
>
{t('submitButton')}
</Button>
</form>
</Form>
</AuthWrapper>
);
};

View File

@ -0,0 +1,180 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useTranslations } from 'next-intl';
import React, { useState } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { Button } from '@/components/ui/common/Button';
import {
Form, FormControl, FormDescription, FormField, FormItem, FormLabel,
} from '@/components/ui/common/Form';
import { Input } from '@/components/ui/common/Input';
import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/ui/common/InputOTP';
import { useLoginUserMutation } from '@/graphql/generated/output';
import { useAuth } from '@/hooks/useAuth';
import { loginSchema } from '@/schemas/auth/login.schema';
import AuthWrapper from '../AuthWrapper';
import type { TypeLoginSchema } from '@/schemas/auth/login.schema';
const LoginAccountForm = () => {
const t = useTranslations('auth.login');
const [isShowTwoFactor, setIsShowTwoFactor] = useState(false);
const router = useRouter();
const { auth } = useAuth();
const form = useForm<TypeLoginSchema>({
resolver: zodResolver(loginSchema),
defaultValues: {
login: '',
password: '',
},
});
const { isValid } = form.formState;
const [login, { loading: isLoadingLogin }] = useLoginUserMutation({
onCompleted(data) {
if (data.loginUser.message) {
setIsShowTwoFactor(true);
} else {
auth();
toast.success(t('successMessage'));
router.push('/dashboard/settings');
}
},
onError: () => {
toast.error(t('errorMessage'));
},
});
function onSubmit(data: TypeLoginSchema) {
void login({ variables: { data } });
}
return (
<AuthWrapper
backButtonHref="/account/create"
backButtonLabel={t('backButtonLabel')}
heading={t('heading')}
>
<Form {...form}>
<form
className="grid gap-y-3"
onSubmit={form.handleSubmit(onSubmit)}
>
{isShowTwoFactor
? (
<FormField
control={form.control}
name="pin"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('pinLabel')}
</FormLabel>
<FormControl>
<InputOTP maxLength={6} {...field}>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
</FormControl>
<FormDescription>
{t('pinDescription')}
</FormDescription>
</FormItem>
)}
/>
)
: (
<>
<FormField
control={form.control}
name="login"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('loginLabel')}
</FormLabel>
<FormControl>
<Input
disabled={isLoadingLogin}
placeholder="johndoe"
{...field}
/>
</FormControl>
<FormDescription>
{t('loginDescription')}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<div className="flex items-center justify-between">
<FormLabel>
{t('passwordLabel')}
</FormLabel>
<Link
className="ml-auto inline-block text-sm"
href="/account/recovery"
>
{t('forgotPassword')}
</Link>
</div>
<FormControl>
<Input
disabled={isLoadingLogin}
placeholder="********"
type="password"
{...field}
/>
</FormControl>
<FormDescription>
{t('passwordDescription')}
</FormDescription>
</FormItem>
)}
/>
</>
)}
<Button
className="mt-2 w-full"
disabled={!isValid || isLoadingLogin}
>
{t('submitButton')}
</Button>
</form>
</Form>
</AuthWrapper>
);
};
export default LoginAccountForm;

View File

@ -0,0 +1,127 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useParams, useRouter } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { Button } from '@/components/ui/common/Button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
} from '@/components/ui/common/Form';
import { Input } from '@/components/ui/common/Input';
import { useNewPasswordMutation } from '@/graphql/generated/output';
import { newPasswordSchema } from '@/schemas/auth/new-password.schema';
import AuthWrapper from '../AuthWrapper';
import type { TypeNewPasswordSchema } from '@/schemas/auth/new-password.schema';
export const NewPasswordForm = () => {
const t = useTranslations('auth.newPassword');
const router = useRouter();
const params = useParams<{ token: string }>();
const form = useForm<TypeNewPasswordSchema>({
resolver: zodResolver(newPasswordSchema),
defaultValues: {
password: '',
passwordRepeat: '',
},
});
const [newPassword, { loading: isLoadingNew }] = useNewPasswordMutation({
onCompleted(data) {
toast.success(t('successMessage'));
router.push('/account/login');
},
onError() {
toast.error(t('errorMessage'));
},
});
const { isValid } = form.formState;
function onSubmit(data: TypeNewPasswordSchema) {
void newPassword({ variables: { data: { ...data, token: params.token } } });
}
return (
<AuthWrapper
backButtonHref="/account/login"
backButtonLabel={t('backButtonLabel')}
heading={t('heading')}
>
<Form {...form}>
<form
className="grid gap-y-3"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('passwordLabel')}
</FormLabel>
<FormControl>
<Input
disabled={isLoadingNew}
placeholder="********"
type="password"
{...field}
/>
</FormControl>
<FormDescription>
{t('passwordDescription')}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="passwordRepeat"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('passwordRepeatLabel')}
</FormLabel>
<FormControl>
<Input
disabled={isLoadingNew}
placeholder="********"
type="password"
{...field}
/>
</FormControl>
<FormDescription>
{t('passwordRepeatDescription')}
</FormDescription>
</FormItem>
)}
/>
<Button
className="mt-2 w-full"
disabled={!isValid || isLoadingNew}
>
{t('submitButton')}
</Button>
</form>
</Form>
</AuthWrapper>
);
};

View File

@ -0,0 +1,123 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { CircleCheck } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import {
Alert,
AlertDescription,
AlertTitle,
} from '@/components/ui/common/Alert';
import { Button } from '@/components/ui/common/Button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
} from '@/components/ui/common/Form';
import { Input } from '@/components/ui/common/Input';
import { useResetPasswordMutation } from '@/graphql/generated/output';
import {
type TypeResetPasswordSchema,
resetPasswordSchema,
} from '@/schemas/auth/reset-password.schema';
import AuthWrapper from '../AuthWrapper';
export const ResetPasswordForm = () => {
const t = useTranslations('auth.resetPassword');
const [isSuccess, setIsSuccess] = useState(false);
const form = useForm<TypeResetPasswordSchema>({
resolver: zodResolver(resetPasswordSchema),
defaultValues: {
email: '',
},
});
const [resetPassword, { loading: isLoadingReset }] = useResetPasswordMutation({
onCompleted() {
setIsSuccess(true);
},
onError() {
toast.error(t('errorMessage'));
},
});
const { isValid } = form.formState;
function onSubmit(data: TypeResetPasswordSchema) {
void resetPassword({ variables: { data } });
}
return (
<AuthWrapper
backButtonHref="/account/login"
backButtonLabel={t('backButtonLabel')}
heading={t('heading')}
>
{isSuccess
? (
<Alert>
<CircleCheck className="size-4" />
<AlertTitle>
{t('successAlertTitle')}
</AlertTitle>
<AlertDescription>
{t('successAlertDescription')}
</AlertDescription>
</Alert>
)
: (
<Form {...form}>
<form
className="grid gap-y-3"
onSubmit={() => {
form.handleSubmit(onSubmit);
}}
>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('emailLabel')}
</FormLabel>
<FormControl>
<Input
disabled={isLoadingReset}
placeholder="john.doe@example.com"
{...field}
/>
</FormControl>
<FormDescription>
{t('emailDescription')}
</FormDescription>
</FormItem>
)}
/>
<Button
className="mt-2 w-full"
disabled={!isValid || isLoadingReset}
>
{t('submitButton')}
</Button>
</form>
</Form>
)}
</AuthWrapper>
);
};

View File

@ -0,0 +1,49 @@
'use client';
import { Loader } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { useEffect } from 'react';
import { toast } from 'sonner';
import { useVerifyAccountMutation } from '@/graphql/generated/output';
import { useAuth } from '@/hooks/useAuth';
import AuthWrapper from '../AuthWrapper';
type VerifyAccountFormProps = {
token: string;
};
export const VerifyAccountForm = (props: VerifyAccountFormProps) => {
const { token } = props;
const t = useTranslations('auth.verify');
const { auth } = useAuth();
const router = useRouter();
const [verify] = useVerifyAccountMutation({
onCompleted() {
auth();
toast.success(t('successMessage'));
router.push('/dashboard/settings');
},
onError() {
toast.error(t('errorMessage'));
},
});
useEffect(() => {
void verify({
variables: {
data: { token },
},
});
}, [token, verify]);
return (
<AuthWrapper heading={t('heading')}>
<div className="flex justify-center">
<Loader className="size-8 animate-spin" />
</div>
</AuthWrapper>
);
};

View File

@ -0,0 +1,143 @@
import { useTranslations } from 'next-intl';
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from '@/components/ui/common/Tabs';
import { Heading } from '@/components/ui/elements/Heading';
import { ChangeEmailForm } from './account/ChangeEmailForm';
import { ChangePasswordForm } from './account/ChangePasswordForm';
import { DeactivateCard } from './account/DeactivateCard';
import { WrapperTotp } from './account/totp/WrapperTotp';
import { ChangeColorForm } from './appearance/ChangeColorForm';
import { ChangeLanguageForm } from './appearance/ChangeLanguageForm';
import { ChangeThemeForm } from './appearance/ChangeThemeForm';
import { ChangeNotificationsSettingsForm } from './notifications/ChangeNotificationsSettingsForm';
import { ChangeAvatarForm } from './profile/ChangeAvatarForm';
import { ChangeInfoForm } from './profile/ChangeInfoForm';
import { SocialLinksForm } from './profile/social-links-form/SocialLinksForm';
import { SessionsList } from './sessions/SessionsList';
export const UserSettings = () => {
const t = useTranslations('dashboard.settings');
return (
<div className="lg:px-10">
<Heading
description={t('header.description')}
size="lg"
title={t('header.heading')}
/>
<Tabs className="mt-3 w-full" defaultValue="profile">
<TabsList className="grid max-w-2xl grid-cols-5">
<TabsTrigger value="profile">
{t('header.profile')}
</TabsTrigger>
<TabsTrigger value="account">
{t('header.account')}
</TabsTrigger>
<TabsTrigger value="appearance">
{t('header.appearance')}
</TabsTrigger>
<TabsTrigger value="notifications">
{t('header.notifications')}
</TabsTrigger>
<TabsTrigger value="sessions">
{t('header.sessions')}
</TabsTrigger>
</TabsList>
<TabsContent value="profile">
<div className="mt-5 space-y-6">
<Heading
description={t('profile.header.description')}
title={t('profile.header.heading')}
/>
<ChangeAvatarForm />
<ChangeInfoForm />
<SocialLinksForm />
</div>
</TabsContent>
<TabsContent value="account">
<div className="mt-5 space-y-6">
<Heading
description={t('account.header.description')}
title={t('account.header.heading')}
/>
<ChangeEmailForm />
<ChangePasswordForm />
<Heading
description={t(
'account.header.securityDescription',
)}
title={t('account.header.securityHeading')}
/>
<WrapperTotp />
<Heading
description={t(
'account.header.deactivationDescription',
)}
title={t('account.header.deactivationHeading')}
/>
<DeactivateCard />
</div>
</TabsContent>
<TabsContent value="appearance">
<div className="mt-5 space-y-6">
<Heading
description={t('appearance.header.description')}
title={t('appearance.header.heading')}
/>
<ChangeThemeForm />
<ChangeLanguageForm />
<ChangeColorForm />
</div>
</TabsContent>
<TabsContent value="notifications">
<div className="mt-5 space-y-6">
<Heading
description={t('notifications.header.description')}
title={t('notifications.header.heading')}
/>
<ChangeNotificationsSettingsForm />
</div>
</TabsContent>
<TabsContent value="sessions">
<div className="mt-5 space-y-6">
<Heading
description={t('sessions.header.description')}
title={t('sessions.header.heading')}
/>
<SessionsList />
</div>
</TabsContent>
</Tabs>
</div>
);
};

View File

@ -0,0 +1,106 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useTranslations } from 'next-intl';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { Button } from '@/components/ui/common/Button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
} from '@/components/ui/common/Form';
import { Input } from '@/components/ui/common/Input';
import { Separator } from '@/components/ui/common/Separator';
import { Skeleton } from '@/components/ui/common/Skeleton';
import { FormWrapper } from '@/components/ui/elements/FormWrapper';
import { useChangeEmailMutation } from '@/graphql/generated/output';
import { useCurrent } from '@/hooks/useCurrent';
import {
type TypeChangeEmailSchema,
changeEmailSchema,
} from '@/schemas/user/change-email.schema';
export const ChangeEmailFormSkeleton = () => <Skeleton className="h-64 w-full" />;
export const ChangeEmailForm = () => {
const t = useTranslations('dashboard.settings.account.email');
const { user, isLoadingProfile, refetch } = useCurrent();
const form = useForm<TypeChangeEmailSchema>({
resolver: zodResolver(changeEmailSchema),
values: {
email: user?.email ?? '',
},
});
const [update, { loading: isLoadingUpdate }] = useChangeEmailMutation({
onCompleted() {
void refetch();
toast.success(t('successMessage'));
},
onError() {
toast.error(t('errorMessage'));
},
});
const { isValid, isDirty } = form.formState;
function onSubmit(data: TypeChangeEmailSchema) {
void update({ variables: { data } });
}
return isLoadingProfile
? (
<ChangeEmailFormSkeleton />
)
: (
<FormWrapper heading={t('heading')}>
<Form {...form}>
<form
className="grid gap-y-3"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem className="px-5">
<FormLabel>
{t('emailLabel')}
</FormLabel>
<FormControl>
<Input
disabled={isLoadingUpdate}
placeholder="john.doe@example.com"
{...field}
/>
</FormControl>
<FormDescription>
{t('emailDescription')}
</FormDescription>
</FormItem>
)}
/>
<Separator />
<div className="flex justify-end p-5">
<Button
disabled={!isValid || !isDirty || isLoadingUpdate}
>
{t('submitButton')}
</Button>
</div>
</form>
</Form>
</FormWrapper>
);
};

View File

@ -0,0 +1,134 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useTranslations } from 'next-intl';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { Button } from '@/components/ui/common/Button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
} from '@/components/ui/common/Form';
import { Input } from '@/components/ui/common/Input';
import { Separator } from '@/components/ui/common/Separator';
import { Skeleton } from '@/components/ui/common/Skeleton';
import { FormWrapper } from '@/components/ui/elements/FormWrapper';
import { useChangePasswordMutation } from '@/graphql/generated/output';
import { useCurrent } from '@/hooks/useCurrent';
import {
type TypeChangePasswordSchema,
changePasswordSchema,
} from '@/schemas/user/change-password.schema';
export const ChangePasswordFormSkeleton = () => <Skeleton className="h-96 w-full" />;
export const ChangePasswordForm = () => {
const t = useTranslations('dashboard.settings.account.password');
const { isLoadingProfile, refetch } = useCurrent();
const form = useForm<TypeChangePasswordSchema>({
resolver: zodResolver(changePasswordSchema),
values: {
oldPassword: '',
newPassword: '',
},
});
const [update, { loading: isLoadingUpdate }] = useChangePasswordMutation({
onCompleted() {
form.reset();
void refetch();
toast.success(t('successMessage'));
},
onError() {
toast.error(t('errorMessage'));
},
});
const { isValid } = form.formState;
function onSubmit(data: TypeChangePasswordSchema) {
void update({ variables: { data } });
}
return isLoadingProfile
? (
<ChangePasswordFormSkeleton />
)
: (
<FormWrapper heading={t('heading')}>
<Form {...form}>
<form
className="grid gap-y-3"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
control={form.control}
name="oldPassword"
render={({ field }) => (
<FormItem className="px-5">
<FormLabel>
{t('oldPasswordLabel')}
</FormLabel>
<FormControl>
<Input
disabled={isLoadingUpdate}
placeholder="********"
type="password"
{...field}
/>
</FormControl>
<FormDescription>
{t('oldPasswordDescription')}
</FormDescription>
</FormItem>
)}
/>
<Separator />
<FormField
control={form.control}
name="newPassword"
render={({ field }) => (
<FormItem className="px-5">
<FormLabel>
{t('newPasswordLabel')}
</FormLabel>
<FormControl>
<Input
disabled={isLoadingUpdate}
placeholder="********"
type="password"
{...field}
/>
</FormControl>
<FormDescription>
{t('newPasswordDescription')}
</FormDescription>
</FormItem>
)}
/>
<Separator />
<div className="flex justify-end p-5">
<Button disabled={!isValid || isLoadingUpdate}>
{t('submitButton')}
</Button>
</div>
</form>
</Form>
</FormWrapper>
);
};

View File

@ -0,0 +1,34 @@
'use client';
import { useRouter } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/common/Button';
import { CardContainer } from '@/components/ui/elements/CardContainer';
import { ConfirmModal } from '@/components/ui/elements/ConfirmModal';
export const DeactivateCard = () => {
const t = useTranslations('dashboard.settings.account.deactivation');
const router = useRouter();
return (
<CardContainer
description={t('description')}
heading={t('heading')}
rightContent={(
<div className="flex items-center gap-x-4">
<ConfirmModal
heading={t('confirmModal.heading')}
message={t('confirmModal.message')}
onConfirm={() => { router.push('/account/deactivate'); }}
>
<Button>
{t('button')}
</Button>
</ConfirmModal>
</div>
)}
/>
);
};

View File

@ -0,0 +1,35 @@
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
import { Button } from '@/components/ui/common/Button';
import { ConfirmModal } from '@/components/ui/elements/ConfirmModal';
import { useDisableTotpMutation } from '@/graphql/generated/output';
import { useCurrent } from '@/hooks/useCurrent';
export const DisableTotp = () => {
const t = useTranslations('dashboard.settings.account.twoFactor.disable');
const { refetch } = useCurrent();
const [disable, { loading: isLoadingDisable }] = useDisableTotpMutation({
onCompleted() {
void refetch();
toast.success(t('successMessage'));
},
onError() {
toast.error(t('errorMessage'));
},
});
return (
<ConfirmModal
heading={t('heading')}
message={t('message')}
onConfirm={async () => disable()}
>
<Button disabled={isLoadingDisable} variant="secondary">
{t('trigger')}
</Button>
</ConfirmModal>
);
};

View File

@ -0,0 +1,176 @@
import { zodResolver } from '@hookform/resolvers/zod';
import Image from 'next/image';
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,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/common/Dialog';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
} from '@/components/ui/common/Form';
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
} from '@/components/ui/common/InputOTP';
import {
useEnableTotpMutation,
useGenerateTotpSecretQuery,
} from '@/graphql/generated/output';
import { useCurrent } from '@/hooks/useCurrent';
import {
type TypeEnableTotpSchema,
enableTotpSchema,
} from '@/schemas/user/enable-totp.schema';
export const EnableTotp = () => {
const t = useTranslations('dashboard.settings.account.twoFactor.enable');
const [isOpen, setIsOpen] = useState(false);
const { refetch } = useCurrent();
const { data, loading: isLoadingGenerate } = useGenerateTotpSecretQuery();
const twoFactorAuth = data?.generateTotpSecret;
const form = useForm<TypeEnableTotpSchema>({
resolver: zodResolver(enableTotpSchema),
defaultValues: {
pin: '',
},
});
const [enable, { loading: isLoadingEnable }] = useEnableTotpMutation({
onCompleted() {
void refetch();
setIsOpen(false);
toast.success(t('successMessage'));
},
onError() {
toast.error(t('errorMessage'));
},
});
const { isValid } = form.formState;
function onSubmit(values: TypeEnableTotpSchema) {
void enable({
variables: {
data: {
secret: twoFactorAuth?.secret ?? '',
pin: values.pin,
},
},
});
}
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button>
{t('trigger')}
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
{t('heading')}
</DialogTitle>
</DialogHeader>
<Form {...form}>
<form
className="flex flex-col gap-4"
onSubmit={form.handleSubmit(onSubmit)}
>
<div className="flex flex-col items-center justify-center gap-4">
<span className="text-sm text-muted-foreground">
{twoFactorAuth?.qrcodeUrl
? t('qrInstructions')
: ''}
</span>
<Image
alt="QR"
className="rounded-lg"
height={300}
src={twoFactorAuth?.qrcodeUrl ?? ''}
width={300}
/>
</div>
<div className="flex flex-col gap-2">
<span className="text-center text-sm text-muted-foreground">
{twoFactorAuth?.secret
? t('secretCodeLabel')
+ twoFactorAuth.secret
: ''}
</span>
</div>
<FormField
control={form.control}
name="pin"
render={({ field }) => (
<FormItem className="flex flex-col justify-center max-sm:items-center">
<FormLabel>
{t('pinLabel')}
</FormLabel>
<FormControl>
<InputOTP maxLength={6} {...field}>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
</FormControl>
<FormDescription>
{t('pinDescription')}
</FormDescription>
</FormItem>
)}
/>
<DialogFooter>
<Button
disabled={
!isValid
|| isLoadingGenerate
|| isLoadingEnable
}
type="submit"
>
{t('submitButton')}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,34 @@
'use client';
import { useTranslations } from 'next-intl';
import { Skeleton } from '@/components/ui/common/Skeleton';
import { CardContainer } from '@/components/ui/elements/CardContainer';
import { useCurrent } from '@/hooks/useCurrent';
import { DisableTotp } from './DisableTotp';
import { EnableTotp } from './EnableTotp';
export const WrapperTotpSkeleton = () => <Skeleton className="h-24 w-full" />;
export const WrapperTotp = () => {
const t = useTranslations('dashboard.settings.account.twoFactor');
const { user, isLoadingProfile } = useCurrent();
return isLoadingProfile
? (
<WrapperTotpSkeleton />
)
: (
<CardContainer
description={t('description')}
heading={t('heading')}
rightContent={(
<div className="flex items-center gap-x-4">
{!user?.isTotpEnabled ? <EnableTotp /> : <DisableTotp />}
</div>
)}
/>
);
};

View File

@ -0,0 +1,47 @@
'use client';
import { Check } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { CardContainer } from '@/components/ui/elements/CardContainer';
import { useConfig } from '@/hooks/useConfig';
import { BASE_COLORS } from '@/libs/constants/colors.constants';
import type { CSSProperties } from 'react';
export const ChangeColorForm = () => {
const t = useTranslations('dashboard.settings.appearance.color');
const config = useConfig();
return (
<CardContainer
description={t('description')}
heading={t('heading')}
rightContent={(
<div className="grid grid-cols-4 gap-2 md:grid-cols-8">
{BASE_COLORS.map((theme) => {
const isActive = config.theme === theme.name;
return (
<button
key={theme.name}
style={
{
'--theme-primary': `hsl(${theme.color})`,
} as CSSProperties
}
type="button"
onClick={() => { config.setTheme(theme.name); }}
>
<span className="flex size-9 shrink-0 -translate-x-1 items-center justify-center rounded-lg bg-(--theme-primary) hover:border-2 hover:border-foreground">
{isActive ? <Check className="size-5 text-white" /> : null}
</span>
</button>
);
})}
</div>
)}
/>
);
};

View File

@ -0,0 +1,94 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useLocale, useTranslations } from 'next-intl';
import { useTransition } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { Form, FormField } from '@/components/ui/common/Form';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/common/Select';
import { CardContainer } from '@/components/ui/elements/CardContainer';
import { setCurrentLanguage } from '@/libs/i18n/language';
import {
changeLanguageSchema,
} from '@/schemas/user/change-language.schema';
import type { TypeChangeLanguageSchema } from '@/schemas/user/change-language.schema';
const languages = {
ru: 'Русский',
en: 'English',
};
export const ChangeLanguageForm = () => {
const t = useTranslations('dashboard.settings.appearance.language');
const [isPending, startTransition] = useTransition();
const locale = useLocale();
const form = useForm<TypeChangeLanguageSchema>({
resolver: zodResolver(changeLanguageSchema),
values: {
language: locale,
},
});
function onSubmit(data: TypeChangeLanguageSchema) {
startTransition(async () => {
try {
await setCurrentLanguage(data.language);
} catch (error) {
toast.success(t('successMessage'));
}
});
}
return (
<CardContainer
description={t('description')}
heading={t('heading')}
rightContent={(
<Form {...form}>
<FormField
control={form.control}
name="language"
render={({ field }) => (
<Select
value={field.value}
onValueChange={(value: string) => {
field.onChange(value);
void form.handleSubmit(onSubmit)();
}}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder={t('selectPlaceholder')} />
</SelectTrigger>
<SelectContent>
{Object.entries(languages).map(
([code, name]) => (
<SelectItem
key={code}
disabled={isPending}
value={code}
>
{name}
</SelectItem>
),
)}
</SelectContent>
</Select>
)}
/>
</Form>
)}
/>
);
};

View File

@ -0,0 +1,53 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useTranslations } from 'next-intl';
import { useTheme } from 'next-themes';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { Form, FormField } from '@/components/ui/common/Form';
import { ToggleCard } from '@/components/ui/elements/ToggleCard';
import {
changeThemeSchema,
} from '@/schemas/user/change-theme.schema';
import type { TypeChangeThemeSchema } from '@/schemas/user/change-theme.schema';
export const ChangeThemeForm = () => {
const t = useTranslations('dashboard.settings.appearance.theme');
const { theme, setTheme } = useTheme();
const form = useForm<TypeChangeThemeSchema>({
resolver: zodResolver(changeThemeSchema),
values: {
theme: theme === 'dark' ? 'dark' : 'light',
},
});
const onChange = (value: boolean) => {
const newTheme = value ? 'dark' : 'light';
setTheme(newTheme);
form.setValue('theme', newTheme);
toast.success(t('successMessage'));
};
return (
<Form {...form}>
<FormField
control={form.control}
name="theme"
render={({ field }) => (
<ToggleCard
description={t('description')}
heading={t('heading')}
value={field.value === 'dark'}
onChange={onChange}
/>
)}
/>
</Form>
);
};

View File

@ -0,0 +1,101 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useTranslations } from 'next-intl';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { Form, FormField } from '@/components/ui/common/Form';
import {
ToggleCard,
ToggleCardSkeleton,
} from '@/components/ui/elements/ToggleCard';
import { useChangeNotificationsSettingsMutation } from '@/graphql/generated/output';
import { useCurrent } from '@/hooks/useCurrent';
import {
type TypeChangeNotificationsSettingsSchema,
changeNotificationsSettingsSchema,
} from '@/schemas/user/change-notifications-settings.schema';
export const ChangeNotificationsSettingsForm = () => {
const t = useTranslations('dashboard.settings.notifications');
const { user, isLoadingProfile, refetch } = useCurrent();
const form = useForm<TypeChangeNotificationsSettingsSchema>({
resolver: zodResolver(changeNotificationsSettingsSchema),
values: {
siteNotifications:
user?.notificationSettings?.siteNotifications ?? false,
telegramNotifications:
user?.notificationSettings?.telegramNotifications ?? false,
},
});
const [update, { loading: isLoadingUpdate }] = useChangeNotificationsSettingsMutation({
onCompleted(data) {
void refetch();
toast.success(t('successMessage'));
if (data.changeNotificationSettings.telegramAuthToken) {
window.open(
`https://t.me/ksv741_teastream_bot?start=${data.changeNotificationSettings.telegramAuthToken}`,
'_blank',
);
}
},
onError() {
toast.error(t('errorMessage'));
},
});
function onChange(
field: keyof TypeChangeNotificationsSettingsSchema,
value: boolean,
) {
form.setValue(field, value);
void update({
variables: {
data: { ...form.getValues(), [field]: value },
},
});
}
return isLoadingProfile
? Array.from({ length: 2 }).map((_, index) => (
// eslint-disable-next-line react/no-array-index-key
<ToggleCardSkeleton key={index} />
))
: (
<Form {...form}>
<FormField
control={form.control}
name="siteNotifications"
render={({ field }) => (
<ToggleCard
description={t('siteNotifications.description')}
heading={t('siteNotifications.heading')}
isDisabled={isLoadingUpdate}
value={field.value}
onChange={(value) => { onChange('siteNotifications', value); }}
/>
)}
/>
<FormField
control={form.control}
name="telegramNotifications"
render={({ field }) => (
<ToggleCard
description={t('telegramNotifications.description')}
heading={t('telegramNotifications.heading')}
isDisabled={isLoadingUpdate}
value={field.value}
onChange={(value) => { onChange('telegramNotifications', value); }}
/>
)}
/>
</Form>
);
};

View File

@ -0,0 +1,159 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trash } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { type ChangeEvent, useRef } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { Button } from '@/components/ui/common/Button';
import { Form, FormField } from '@/components/ui/common/Form';
import { Skeleton } from '@/components/ui/common/Skeleton';
import { ChannelAvatar } from '@/components/ui/elements/ChannelAvatar';
import { ConfirmModal } from '@/components/ui/elements/ConfirmModal';
import { FormWrapper } from '@/components/ui/elements/FormWrapper';
import {
useChangeProfileAvatarMutation,
useRemoveProfileAvatarMutation,
} from '@/graphql/generated/output';
import { useCurrent } from '@/hooks/useCurrent';
import {
type TypeUploadFileSchema,
uploadFileSchema,
} from '@/schemas/upload-file.schema';
export const ChangeAvatarFormSkeleton = () => <Skeleton className="h-52 w-full" />;
export const ChangeAvatarForm = () => {
const t = useTranslations('dashboard.settings.profile.avatar');
const { user, isLoadingProfile, refetch } = useCurrent();
const inputRef = useRef<HTMLInputElement>(null);
const form = useForm<TypeUploadFileSchema>({
resolver: zodResolver(uploadFileSchema),
values: {
file: user?.avatar ?? '',
},
});
const [update, { loading: isLoadingUpdate }] = useChangeProfileAvatarMutation({
onCompleted() {
void refetch();
toast.success(t('successUpdateMessage'));
},
onError() {
toast.error(t('errorUpdateMessage'));
},
});
const [remove, { loading: isLoadingRemove }] = useRemoveProfileAvatarMutation({
onCompleted() {
void refetch();
toast.success(t('successRemoveMessage'));
},
onError() {
toast.error(t('errorRemoveMessage'));
},
});
function handleImageChange(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (file) {
void update({ variables: { avatar: file } });
form.setValue('file', file);
}
}
if (!user) {
return null;
}
return isLoadingProfile
? (
<ChangeAvatarFormSkeleton />
)
: (
<FormWrapper heading={t('heading')}>
<Form {...form}>
<FormField
control={form.control}
name="file"
render={({ field }) => (
<div className="px-5 pb-5">
<div className="w-full items-center space-x-6 lg:flex">
<ChannelAvatar
channel={{
name: user.name,
avatar:
field.value instanceof File
? URL.createObjectURL(
field.value,
)
: field.value,
}}
size="xl"
/>
<div className="space-y-3">
<div className="flex items-center gap-x-3">
<input
ref={inputRef}
className="hidden"
type="file"
onChange={handleImageChange}
/>
<Button
disabled={
isLoadingUpdate
|| isLoadingRemove
}
variant="secondary"
onClick={() => inputRef.current?.click()}
>
{t('updateButton')}
</Button>
{user?.avatar
? (
<ConfirmModal
heading={t(
'confirmModal.heading',
)}
message={t(
'confirmModal.message',
)}
onConfirm={async () => remove()}
>
<Button
disabled={
isLoadingUpdate
|| isLoadingRemove
}
size="lgIcon"
variant="ghost"
>
<Trash className="size-4" />
</Button>
</ConfirmModal>
)
: null}
</div>
<p className="text-sm text-muted-foreground">
{t('info')}
</p>
</div>
</div>
</div>
)}
/>
</Form>
</FormWrapper>
);
};

View File

@ -0,0 +1,165 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useTranslations } from 'next-intl';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { Button } from '@/components/ui/common/Button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
} from '@/components/ui/common/Form';
import { Input } from '@/components/ui/common/Input';
import { Separator } from '@/components/ui/common/Separator';
import { Skeleton } from '@/components/ui/common/Skeleton';
import { Textarea } from '@/components/ui/common/Textarea';
import { FormWrapper } from '@/components/ui/elements/FormWrapper';
import { useChangeProfileInfoMutation } from '@/graphql/generated/output';
import { useCurrent } from '@/hooks/useCurrent';
import {
type TypeChangeInfoSchema,
changeInfoSchema,
} from '@/schemas/user/change-info.schema';
export const ChangeInfoFormSkeleton = () => <Skeleton className="h-96 w-full" />;
export const ChangeInfoForm = () => {
const t = useTranslations('dashboard.settings.profile.info');
const { user, isLoadingProfile, refetch } = useCurrent();
const form = useForm<TypeChangeInfoSchema>({
resolver: zodResolver(changeInfoSchema),
values: {
name: user?.name ?? '',
displayName: user?.displayName ?? '',
bio: user?.bio ?? '',
},
});
const [update, { loading: isLoadingUpdate }] = useChangeProfileInfoMutation(
{
onCompleted() {
void refetch();
toast.success(t('successMessage'));
},
onError() {
toast.error(t('errorMessage'));
},
},
);
const { isValid, isDirty } = form.formState;
function onSubmit(data: TypeChangeInfoSchema) {
void update({ variables: { data } });
}
return isLoadingProfile
? (
<ChangeInfoFormSkeleton />
)
: (
<FormWrapper heading={t('heading')}>
<Form {...form}>
<form
className="grid gap-y-3"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem className="px-5">
<FormLabel>
{t('usernameLabel')}
</FormLabel>
<FormControl>
<Input
disabled={isLoadingUpdate}
placeholder={t('usernamePlaceholder')}
{...field}
/>
</FormControl>
<FormDescription>
{t('usernameDescription')}
</FormDescription>
</FormItem>
)}
/>
<Separator />
<FormField
control={form.control}
name="displayName"
render={({ field }) => (
<FormItem className="px-5 pb-3">
<FormLabel>
{t('displayNameLabel')}
</FormLabel>
<FormControl>
<Input
disabled={isLoadingUpdate}
placeholder={t(
'displayNamePlaceholder',
)}
{...field}
/>
</FormControl>
<FormDescription>
{t('displayNameDescription')}
</FormDescription>
</FormItem>
)}
/>
<Separator />
<FormField
control={form.control}
name="bio"
render={({ field }) => (
<FormItem className="px-5 pb-3">
<FormLabel>
{t('bioLabel')}
</FormLabel>
<FormControl>
<Textarea
disabled={isLoadingUpdate}
placeholder={t('bioPlaceholder')}
{...field}
/>
</FormControl>
<FormDescription>
{t('bioDescription')}
</FormDescription>
</FormItem>
)}
/>
<Separator />
<div className="flex justify-end p-5">
<Button
disabled={!isValid || !isDirty || isLoadingUpdate}
>
{t('submitButton')}
</Button>
</div>
</form>
</Form>
</FormWrapper>
);
};

View File

@ -0,0 +1,199 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { GripVertical, Pencil, Trash2 } from 'lucide-react';
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 {
Form,
FormControl,
FormField,
FormItem,
} from '@/components/ui/common/Form';
import { Input } from '@/components/ui/common/Input';
import {
type FindSocialLinksQuery,
useFindSocialLinksQuery,
useRemoveSocialLinkMutation,
useUpdateSocialLinkMutation,
} from '@/graphql/generated/output';
import {
type TypeSocialLinksSchema,
socialLinksSchema,
} from '@/schemas/user/social-links.schema';
import type { DraggableProvided } from '@hello-pangea/dnd';
type SocialLinkItemProps = {
socialLink: FindSocialLinksQuery['findSocialLinks'][0];
provided: DraggableProvided;
};
export const SocialLinkItem = ({ socialLink, provided }: SocialLinkItemProps) => {
const t = useTranslations('dashboard.settings.profile.socialLinks.editForm');
const [editingId, setEditingId] = useState<string | null>(null);
const { refetch } = useFindSocialLinksQuery();
const form = useForm<TypeSocialLinksSchema>({
resolver: zodResolver(socialLinksSchema),
values: {
title: socialLink.title ?? '',
url: socialLink.url ?? '',
},
});
const { isValid, isDirty } = form.formState;
function toggleEditing(id: string | null) {
setEditingId(id);
}
const [update, { loading: isLoadingUpdate }] = useUpdateSocialLinkMutation({
onCompleted() {
toggleEditing(null);
void refetch();
toast.success(t('successUpdateMessage'));
},
onError() {
toast.error(t('errorUpdateMessage'));
},
});
const [remove, { loading: isLoadingRemove }] = useRemoveSocialLinkMutation({
onCompleted() {
void refetch();
toast.success(t('successRemoveMessage'));
},
onError() {
toast.error(t('errorRemoveMessage'));
},
});
function onSubmit(data: TypeSocialLinksSchema) {
void update({ variables: { id: socialLink.id, data } });
}
return (
<div
ref={provided.innerRef}
className="mb-4 flex items-center gap-x-2 rounded-md border border-border bg-background text-sm"
{...provided.draggableProps}
>
<div
className="rounded-l-md border-r border-r-border px-2 py-9 text-foreground transition"
{...provided.dragHandleProps}
>
<GripVertical className="size-5" />
</div>
<div className="space-y-1 px-2">
{editingId === socialLink.id
? (
<Form {...form}>
<form
className="flex gap-x-6"
onSubmit={form.handleSubmit(onSubmit)}
>
<div className="w-96 space-y-2">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
className="h-8"
disabled={
isLoadingUpdate
|| isLoadingRemove
}
placeholder="YouTube"
{...field}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="url"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
className="h-8"
disabled={
isLoadingUpdate
|| isLoadingRemove
}
placeholder="https://youtube.com/@TeaCoder52"
{...field}
/>
</FormControl>
</FormItem>
)}
/>
</div>
<div className="flex items-center gap-x-4">
<Button
variant="secondary"
onClick={() => { toggleEditing(null); }}
>
{t('cancelButton')}
</Button>
<Button
disabled={
!isDirty
|| !isValid
|| isLoadingUpdate
|| isLoadingRemove
}
>
{t('submitButton')}
</Button>
</div>
</form>
</Form>
)
: (
<>
<h2 className="text-[17px] font-semibold text-foreground">
{socialLink.title}
</h2>
<p className="text-muted-foreground">
{socialLink.url}
</p>
</>
)}
</div>
<div className="ml-auto flex items-center gap-x-2 pr-4">
{editingId !== socialLink.id && (
<Button
size="lgIcon"
variant="ghost"
onClick={() => { toggleEditing(socialLink.id); }}
>
<Pencil className="size-4 text-muted-foreground" />
</Button>
)}
<Button
size="lgIcon"
variant="ghost"
onClick={async () => remove({ variables: { id: socialLink.id } })}
>
<Trash2 className="size-4 text-muted-foreground" />
</Button>
</div>
</div>
);
};

View File

@ -0,0 +1,140 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useTranslations } from 'next-intl';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { Button } from '@/components/ui/common/Button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
} from '@/components/ui/common/Form';
import { Input } from '@/components/ui/common/Input';
import { Separator } from '@/components/ui/common/Separator';
import { Skeleton } from '@/components/ui/common/Skeleton';
import { FormWrapper } from '@/components/ui/elements/FormWrapper';
import {
useCreateSocialLinkMutation,
useFindSocialLinksQuery,
} from '@/graphql/generated/output';
import {
type TypeSocialLinksSchema,
socialLinksSchema,
} from '@/schemas/user/social-links.schema';
import { SocialLinksList } from './SocialLinksList';
export const SocialLinksFormSkeleton = () => <Skeleton className="h-72 w-full" />;
export const SocialLinksForm = () => {
const t = useTranslations(
'dashboard.settings.profile.socialLinks.createForm',
);
const { loading: isLoadingLinks, refetch } = useFindSocialLinksQuery();
const form = useForm<TypeSocialLinksSchema>({
resolver: zodResolver(socialLinksSchema),
defaultValues: {
title: '',
url: '',
},
});
const [create, { loading: isLoadingCreate }] = useCreateSocialLinkMutation({
onCompleted() {
form.reset();
void refetch();
toast.success(t('successMessage'));
},
onError() {
toast.error(t('errorMessage'));
},
});
const { isValid } = form.formState;
function onSubmit(data: TypeSocialLinksSchema) {
void create({ variables: { data } });
}
return isLoadingLinks
? (
<SocialLinksFormSkeleton />
)
: (
<FormWrapper heading={t('heading')}>
<Form {...form}>
<form
className="grid gap-y-3"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem className="px-5">
<FormLabel>
{t('titleLabel')}
</FormLabel>
<FormControl>
<Input
disabled={isLoadingCreate}
placeholder={t('titlePlaceholder')}
{...field}
/>
</FormControl>
<FormDescription>
{t('titleDescription')}
</FormDescription>
</FormItem>
)}
/>
<Separator />
<FormField
control={form.control}
name="url"
render={({ field }) => (
<FormItem className="px-5 pb-3">
<FormLabel>
{t('urlLabel')}
</FormLabel>
<FormControl>
<Input
disabled={isLoadingCreate}
placeholder={t('urlPlaceholder')}
{...field}
/>
</FormControl>
<FormDescription>
{t('urlDescription')}
</FormDescription>
</FormItem>
)}
/>
<Separator />
<div className="flex justify-end p-5">
<Button disabled={!isValid || isLoadingCreate}>
{t('submitButton')}
</Button>
</div>
</form>
</Form>
<SocialLinksList />
</FormWrapper>
);
};

View File

@ -0,0 +1,102 @@
'use client';
import {
DragDropContext,
Draggable,
type DropResult,
Droppable,
} from '@hello-pangea/dnd';
import { useTranslations } from 'next-intl';
import { useEffect, useState } from 'react';
import { toast } from 'sonner';
import { Separator } from '@/components/ui/common/Separator';
import {
useFindSocialLinksQuery,
useReorderSocialLinksMutation,
} from '@/graphql/generated/output';
import { SocialLinkItem } from './SocialLinkItem';
export const SocialLinksList = () => {
const t = useTranslations('dashboard.settings.profile.socialLinks');
const { data, refetch } = useFindSocialLinksQuery();
// eslint-disable-next-line react-hooks/exhaustive-deps
const items = data?.findSocialLinks ?? [];
const [socialLinks, setSocialLinks] = useState(items);
useEffect(() => {
setSocialLinks(items);
}, [items]);
const [reorder, { loading: isLoadingReorder }] = useReorderSocialLinksMutation({
onCompleted() {
void refetch();
toast.success(t('successReorderMessage'));
},
onError() {
toast.error(t('errorReorderMessage'));
},
});
const onDragEnd = (result: DropResult) => {
if (!result.destination) return;
// eslint-disable-next-line @typescript-eslint/no-shadow
const items = Array.from(socialLinks);
const [reorderItem] = items.splice(result.source.index, 1);
items.splice(result.destination.index, 0, reorderItem);
const bulkUpdateData = items.map((socialLink, index) => ({
id: socialLink.id,
position: index,
}));
setSocialLinks(items);
void reorder({ variables: { list: bulkUpdateData } });
};
return socialLinks.length
? (
<>
<Separator />
<div className="mt-5 px-5">
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="socialLinks">
{(dropProvided) => (
<div
{...dropProvided.droppableProps}
ref={dropProvided.innerRef}
>
{socialLinks.map((socialLink, index) => (
<Draggable
key={socialLink.id}
draggableId={socialLink.id}
index={index}
isDragDisabled={isLoadingReorder}
>
{(dragProvided) => (
<SocialLinkItem
key={socialLink.id}
provided={dragProvided}
socialLink={socialLink}
/>
)}
</Draggable>
))}
{dropProvided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
</div>
</>
)
: null;
};

View File

@ -0,0 +1,69 @@
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
import { Button } from '@/components/ui/common/Button';
import { CardContainer } from '@/components/ui/elements/CardContainer';
import { ConfirmModal } from '@/components/ui/elements/ConfirmModal';
import {
type FindSessionsByUserQuery,
useFindSessionsByUserQuery,
useRemoveSessionMutation,
} from '@/graphql/generated/output';
import { getBrowserIcon } from '@/utils/get-browser-icon';
import { SessionModal } from './SessionModal';
type SessionItemProps = {
session: FindSessionsByUserQuery['findSessionsByUser'][0];
isCurrentSession?: boolean;
};
export const SessionItem = ({ session, isCurrentSession }: SessionItemProps) => {
const t = useTranslations('dashboard.settings.sessions.sessionItem');
const { refetch } = useFindSessionsByUserQuery();
const [remove, { loading: isLoadingRemove }] = useRemoveSessionMutation({
onCompleted() {
void refetch();
toast.success(t('successMessage'));
},
onError() {
toast.error(t('errorMessage'));
},
});
const Icon = getBrowserIcon(session.metadata.device.browser);
return (
<CardContainer
description={`${session.metadata.location.country}, ${session.metadata.location.city}`}
heading={`${session.metadata.device.browser}, ${session.metadata.device.os}`}
Icon={Icon}
rightContent={(
<div className="flex items-center gap-x-4">
{!isCurrentSession && (
<ConfirmModal
heading={t('confirmModal.heading')}
message={t('confirmModal.message')}
onConfirm={async () => remove({ variables: { id: session.id } })}
>
<Button
disabled={isLoadingRemove}
variant="secondary"
>
{t('deleteButton')}
</Button>
</ConfirmModal>
)}
<SessionModal session={session}>
<Button>
{t('detailsButton')}
</Button>
</SessionModal>
</div>
)}
/>
);
};

View File

@ -0,0 +1,110 @@
import { Map, Placemark, YMaps } from '@pbe/react-yandex-maps';
import { useTranslations } from 'next-intl';
import {
Dialog,
DialogContent,
DialogTitle,
DialogTrigger,
} from '@/components/ui/common/Dialog';
import { formatDate } from '@/utils/format-date';
import type { FindSessionsByUserQuery } from '@/graphql/generated/output';
import type { PropsWithChildren } from 'react';
type SessionModalProps = {
session: FindSessionsByUserQuery['findSessionsByUser'][0];
};
export const SessionModal = ({
children,
session,
}: PropsWithChildren<SessionModalProps>) => {
const t = useTranslations('dashboard.settings.sessions.sessionModal');
const center = [
session.metadata.location.latitude,
session.metadata.location.longitude,
];
return (
<Dialog>
<DialogTrigger asChild>
{children}
</DialogTrigger>
<DialogContent>
<DialogTitle className="text-xl">
{t('heading')}
</DialogTitle>
<div className="space-y-3">
<div className="flex items-center">
<span className="font-medium">
{t('device')}
</span>
<span className="ml-2 text-muted-foreground">
{session.metadata.device.browser}
,
{' '}
{session.metadata.device.os}
</span>
</div>
<div className="flex items-center">
<span className="font-medium">
{t('location')}
</span>
<span className="ml-2 text-muted-foreground">
{session.metadata.location.country}
,
{' '}
{session.metadata.location.city}
</span>
</div>
<div className="flex items-center">
<span className="font-medium">
{t('ipAddress')}
</span>
<span className="ml-2 text-muted-foreground">
{session.metadata.ip}
</span>
</div>
<div className="flex items-center">
<span className="font-medium">
{t('createdAt')}
</span>
<span className="ml-2 text-muted-foreground">
{formatDate(session.createdAt, true)}
</span>
</div>
<YMaps>
<div style={{ width: '100%', height: '300px' }}>
<Map
defaultState={{
center,
zoom: 11,
}}
height="100%"
width="100%"
>
<Placemark geometry={center} />
</Map>
</div>
</YMaps>
</div>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,58 @@
'use client';
import { useTranslations } from 'next-intl';
import { Heading } from '@/components/ui/elements/Heading';
import { ToggleCardSkeleton } from '@/components/ui/elements/ToggleCard';
import {
useFindCurrentSessionQuery,
useFindSessionsByUserQuery,
} from '@/graphql/generated/output';
import { SessionItem } from './SessionItem';
export const SessionsList = () => {
const t = useTranslations('dashboard.settings.sessions');
const { data: sessionData, loading: isLoadingCurrent } = useFindCurrentSessionQuery();
const currentSession = sessionData?.findCurrentSession;
const { data: sessionsData, loading: isLoadingSessions } = useFindSessionsByUserQuery();
const sessions = sessionsData?.findSessionsByUser ?? [];
if (!currentSession) {
return null;
}
return (
<div className="space-y-6">
<Heading size="sm" title={t('info.current')} />
{isLoadingCurrent
? (
<ToggleCardSkeleton />
)
: (
<SessionItem isCurrentSession session={currentSession} />
)}
<Heading size="sm" title={t('info.active')} />
{/* eslint-disable-next-line no-nested-ternary */}
{isLoadingSessions
? Array.from({ length: 3 }).map((_, index) => (
// eslint-disable-next-line react/no-array-index-key
<ToggleCardSkeleton key={index} />
))
: sessions.length
? sessions.map((session, index) => (
<SessionItem key={session.id} session={session} />
))
: (
<div className="text-muted-foreground">
{t('info.notFound')}
</div>
)}
</div>
);
};

View File

@ -0,0 +1,44 @@
export const LogoImage = () => (
<svg
height={42}
viewBox="0 0 2400 2800"
width={42}
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
xmlSpace="preserve"
>
<title>Logo</title>
<g>
<polygon
className="fill-white"
points="2200,1300 1800,1700 1400,1700 1050,2050 1050,1700 600,1700 600,200 2200,200"
/>
<g>
<g id="Layer_1-2">
<path
className="fill-primary"
d="M500,0L0,500v1800h600v500l500-500h400l900-900V0H500z M2200,1300l-400,400h-400l-350,350v-350H600V200h1600 V1300z"
/>
<rect
className="fill-primary"
height="600"
width="200"
x="1700"
y="550"
/>
<rect
className="fill-primary"
height="600"
width="200"
x="1150"
y="550"
/>
</g>
</g>
</g>
</svg>
);

View File

@ -0,0 +1,31 @@
'use client';
import { type PropsWithChildren, useEffect } from 'react';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { useSidebar } from '@/hooks/useSidebar';
import { cn } from '@/utils/tw-merge';
export const LayoutContainer = ({ children }: PropsWithChildren) => {
const isMobile: boolean = useMediaQuery('(max-width: 1024px)');
const { isCollapsed, open, close } = useSidebar();
useEffect(() => {
if (isMobile) {
if (!isCollapsed) close();
} else if (isCollapsed) open();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isMobile]);
return (
<main
className={cn(
'mt-[75px] flex-1 p-8',
isCollapsed ? 'ml-16' : 'ml-16 lg:ml-64',
)}
>
{children}
</main>
);
};

View File

@ -0,0 +1,13 @@
import { HeaderMenu } from './HeaderMenu';
import { Logo } from './Logo';
import { Search } from './Search';
export const Header = () => (
<header className="flex h-full items-center gap-x-4 border-b border-border bg-card p-4">
<Logo />
<Search />
<HeaderMenu />
</header>
);

View File

@ -0,0 +1,38 @@
'use client';
import Link from 'next/link';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/common/Button';
import { useAuth } from '@/hooks/useAuth';
import { ProfileMenu } from './ProfileMenu';
export const HeaderMenu = () => {
const t = useTranslations('layout.header.headerMenu');
const { isAuthenticated } = useAuth();
return (
<div className="ml-auto flex items-center gap-x-4">
{isAuthenticated
? (
<ProfileMenu />
)
: (
<>
<Link href="/account/login">
<Button variant="secondary">
{t('login')}
</Button>
</Link>
<Link href="/account/create">
<Button>
{t('register')}
</Button>
</Link>
</>
)}
</div>
);
};

View File

@ -0,0 +1,29 @@
'use client';
import Link from 'next/link';
import { useTranslations } from 'next-intl';
import { LogoImage } from '@/components/images/LogoImage';
export const Logo = () => {
const t = useTranslations('layout.header.logo');
return (
<Link
className="flex items-center gap-x-4 transition-opacity hover:opacity-75"
href="/"
>
<LogoImage />
<div className="hidden leading-tight lg:block">
<h2 className="text-lg font-semibold tracking-wider text-accent-foreground">
TeaStream
</h2>
<p className="text-sm text-muted-foreground">
{t('platform')}
</p>
</div>
</Link>
);
};

View File

@ -0,0 +1,90 @@
'use client';
import {
LayoutDashboard, Loader, LogOut, User,
} from 'lucide-react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
import { Notifications } from '@/components/layout/header/notifications/Notification';
import {
DropdownMenu, DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/common/DropdownMenu';
import { ChannelAvatar } from '@/components/ui/elements/ChannelAvatar';
import { useLogoutUserMutation } from '@/graphql/generated/output';
import { useAuth } from '@/hooks/useAuth';
import { useCurrent } from '@/hooks/useCurrent';
export const ProfileMenu = () => {
const t = useTranslations('layout.header.headerMenu.profileMenu');
const router = useRouter();
const { exit } = useAuth();
const { user, isLoadingProfile } = useCurrent();
const [logout] = useLogoutUserMutation({
onCompleted() {
exit();
toast.success(t('successMessage'));
router.push('/account/login');
},
onError() {
toast.error(t('errorMessage'));
},
});
return isLoadingProfile || !user
? (
<Loader className="size-6 animate-spin text-muted-foreground" />
)
: (
<>
<Notifications />
<DropdownMenu>
<DropdownMenuTrigger>
<ChannelAvatar channel={user} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[230px]">
<div className="flex items-center gap-x-3 p-2">
<ChannelAvatar channel={user} />
<h2 className="font-medium text-foreground">
{user.name}
</h2>
</div>
<DropdownMenuSeparator />
<Link href={`/${user.name}`}>
<DropdownMenuItem>
<User className="mr-2 size-2" />
{t('channel')}
</DropdownMenuItem>
</Link>
<Link href="/dashboard/settings">
<DropdownMenuItem>
<LayoutDashboard className="mr-2 size-2" />
{t('dashboard')}
</DropdownMenuItem>
</Link>
<DropdownMenuItem onClick={async () => logout()}>
<LogOut className="mr-2 size-2" />
{t('logout')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
);
};

View File

@ -0,0 +1,44 @@
'use client';
import { SearchIcon } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { type FormEvent, useState } from 'react';
import { Button } from '@/components/ui/common/Button';
import { Input } from '@/components/ui/common/Input';
export const Search = () => {
const t = useTranslations('layout.header.search');
const [searchTerm, setSearchTerm] = useState('');
const router = useRouter();
function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (searchTerm.trim()) {
router.push(`/streams?searchTerm=${searchTerm}`);
} else {
router.push('/streams');
}
}
return (
<div className="ml-auto hidden lg:block">
<form className="relative flex items-center" onSubmit={onSubmit}>
<Input
className="w-full rounded-full pl-4 pr-10 lg:w-[400px]"
placeholder={t('placeholder')}
type="text"
value={searchTerm}
onChange={(e) => { setSearchTerm(e.target.value); }}
/>
<Button className="absolute right-0.5 h-9" type="submit">
<SearchIcon className="absolute size-[18px]" />
</Button>
</form>
</div>
);
};

View File

@ -0,0 +1,40 @@
import { Bell } from 'lucide-react';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/common/Popover';
import { useFindUnreadNotificationsCountQuery } from '@/graphql/generated/output';
import { NotificationsList } from './NotificationsList';
export const Notifications = () => {
const { data, loading: isLoadingCount } = useFindUnreadNotificationsCountQuery();
const count = data?.findUnreadNotificationsCount ?? 0;
const displayCount = count > 10 ? '+9' : count;
if (isLoadingCount) return null;
return (
<Popover>
<PopoverTrigger>
{count !== 0 && (
<div className="absolute right-[72px] top-5 rounded-full bg-primary px-[5px] text-xs font-semibold text-white">
{displayCount}
</div>
)}
<Bell className="size-5 text-foreground" />
</PopoverTrigger>
<PopoverContent
align="end"
className="max-h-[500px] w-[320px] overflow-y-auto"
>
<NotificationsList />
</PopoverContent>
</Popover>
);
};

View File

@ -0,0 +1,73 @@
import parse from 'html-react-parser';
import { Loader2 } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { Fragment } from 'react';
import { Separator } from '@/components/ui/common/Separator';
import {
useFindNotificationByUserQuery,
useFindUnreadNotificationsCountQuery,
} from '@/graphql/generated/output';
import { getNotificationIcon } from '@/utils/get-notification-icon';
export const NotificationsList = () => {
const t = useTranslations(
'layout.header.headerMenu.profileMenu.notifications',
);
const { refetch } = useFindUnreadNotificationsCountQuery();
const { data, loading: isLoadingNotifications } = useFindNotificationByUserQuery({
onCompleted() {
void refetch();
},
});
const notifications = data?.findNotificationByUser ?? [];
return (
<>
<h2 className="text-center text-lg font-medium">
{t('heading')}
</h2>
<Separator className="my-3" />
{/* eslint-disable-next-line no-nested-ternary */}
{isLoadingNotifications
? (
<div className="flex items-center justify-center gap-x-2 text-sm text-foreground">
<Loader2 className="size-5 animate-spin" />
{t('loading')}
</div>
)
: notifications.length
? notifications.map((notification, index) => {
const Icon = getNotificationIcon(notification.type);
return (
<Fragment key={notification.id}>
<div className="flex items-center gap-x-3 text-sm">
<div className="rounded-full bg-foreground p-2">
<Icon className="size-6 text-secondary" />
</div>
<div>
{parse(notification.text)}
</div>
</div>
{index < notifications.length - 1 && (
<Separator className="my-3" />
)}
</Fragment>
);
})
: (
<div className="text-center text-muted-foreground">
{t('empty')}
</div>
)}
</>
);
};

View File

@ -0,0 +1,78 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { Button } from '@/components/ui/common/Button';
import { Skeleton } from '@/components/ui/common/Skeleton';
import { ChannelAvatar } from '@/components/ui/elements/ChannelAvatar';
import { ChannelVerified } from '@/components/ui/elements/ChannelVerified';
import { Hint } from '@/components/ui/elements/Hint';
import { LiveBadge } from '@/components/ui/elements/LiveBadge';
import { useSidebar } from '@/hooks/useSidebar';
import { cn } from '@/utils/tw-merge';
import type { FindRecommendedChannelsQuery } from '@/graphql/generated/output';
type ChannelItemProps = {
channel: FindRecommendedChannelsQuery['findRecommendedChannels'][0];
};
export const ChannelItem = ({ channel }: ChannelItemProps) => {
const pathname = usePathname();
const { isCollapsed } = useSidebar();
const isActive = pathname === `/${channel.name}`;
return isCollapsed
? (
<Hint asChild label={channel.name} side="right">
<Link
className="mt-3 flex w-full items-center justify-center"
href={`/${channel.name}`}
>
<ChannelAvatar
channel={channel}
isLive={channel.stream.isLive}
/>
</Link>
</Hint>
)
: (
<Button
asChild
className={cn(
'mt-2 h-11 w-full justify-start',
isActive && 'bg-accent',
)}
variant="ghost"
>
<Link
className="flex w-full items-center"
href={`/${channel.name}`}
>
<ChannelAvatar
channel={channel}
isLive={channel.stream.isLive}
size="sm"
/>
<h2 className="truncate pl-3">
{channel.name}
</h2>
{channel.isVerified ? <ChannelVerified size="sm" /> : null}
{channel.stream.isLive
? (
<div className="absolute right-5">
<LiveBadge />
</div>
)
: null}
</Link>
</Button>
);
};
export const ChannelItemSkeleton = () => <Skeleton className="mt-3 h-11 w-full rounded-full" />;

View File

@ -0,0 +1,64 @@
import {
Banknote,
DollarSign,
KeyRound,
Medal,
MessageSquare,
Settings,
Users,
} from 'lucide-react';
import { useTranslations } from 'next-intl';
import { SidebarItem } from './SidebarItem';
import type { Route } from './route.interface';
export const DashboardNav = () => {
const t = useTranslations('layout.sidebar.dashboardNav');
const routes: Route[] = [
{
label: t('settings'),
href: '/dashboard/settings',
icon: Settings,
},
{
label: t('keys'),
href: '/dashboard/keys',
icon: KeyRound,
},
{
label: t('chatSettings'),
href: '/dashboard/chat',
icon: MessageSquare,
},
{
label: t('followers'),
href: '/dashboard/followers',
icon: Users,
},
{
label: t('sponsors'),
href: '/dashboard/sponsors',
icon: Medal,
},
{
label: t('premium'),
href: '/dashboard/plans',
icon: DollarSign,
},
{
label: t('transactions'),
href: '/dashboard/transactions',
icon: Banknote,
},
];
return (
<div className="space-y-2 px-2 pt-4 lg:pt-0">
{routes.map((route) => (
<SidebarItem key={route.href} route={route} />
))}
</div>
);
};

View File

@ -0,0 +1,39 @@
'use client';
import { useTranslations } from 'next-intl';
import { Separator } from '@/components/ui/common/Separator';
import { useFindRecommendedChannelsQuery } from '@/graphql/generated/output';
import { useSidebar } from '@/hooks/useSidebar';
import { ChannelItem, ChannelItemSkeleton } from './ChannelItem';
export const RecommendedChannels = () => {
const t = useTranslations('layout.sidebar.recommended');
const { isCollapsed } = useSidebar();
const { data, loading: isLoadingRecommended } = useFindRecommendedChannelsQuery();
const channels = data?.findRecommendedChannels ?? [];
return (
<div>
<Separator className="mb-3" />
{!isCollapsed && (
<h2 className="mb-2 px-2 text-lg font-semibold text-foreground">
{t('heading')}
</h2>
)}
{isLoadingRecommended
? Array.from({ length: 7 }).map((_, index) => (
// eslint-disable-next-line react/no-array-index-key
<ChannelItemSkeleton key={index} />
))
: channels.map((channel) => (
<ChannelItem key={channel.id} channel={channel} />
))}
</div>
);
};

View File

@ -0,0 +1,31 @@
'use client';
import { usePathname } from 'next/navigation';
import { UserNav } from '@/components/layout/sidebar/UserNav';
import { useSidebar } from '@/hooks/useSidebar';
import { cn } from '@/utils/tw-merge';
import { DashboardNav } from './DashboardNav';
import { SidebarHeader } from './SidebarHeader';
export const Sidebar = () => {
const { isCollapsed } = useSidebar();
const pathname = usePathname();
const isDashboardPage = pathname.includes('/dashboard');
return (
<aside
className={cn(
'fixed left-0 z-50 mt-[75px] flex h-full flex-col border-r border-border bg-card transition-all duration-100 ease-in-out',
isCollapsed ? 'w-16' : 'w-64',
)}
>
<SidebarHeader />
{isDashboardPage ? <DashboardNav /> : <UserNav />}
</aside>
);
};

View File

@ -0,0 +1,42 @@
'use client';
import { ArrowLeftFromLine, ArrowRightFromLine } from 'lucide-react';
import { usePathname } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/common/Button';
import { Hint } from '@/components/ui/elements/Hint';
import { useSidebar } from '@/hooks/useSidebar';
export const SidebarHeader = () => {
const t = useTranslations('layout.sidebar.header');
const pathname = usePathname();
const { isCollapsed, open, close } = useSidebar();
const label = isCollapsed ? t('expand') : t('collapse');
return isCollapsed
? (
<div className="mb-4 hidden w-full items-center justify-center pt-4 lg:flex">
<Hint asChild label={label} side="right">
<Button size="icon" variant="ghost" onClick={() => { open(); }}>
<ArrowRightFromLine className="size-4" />
</Button>
</Hint>
</div>
)
: (
<div className="mb-2 flex w-full items-center justify-between p-3 pl-4">
<h2 className="text-lg font-semibold text-foreground">
{t('navigation')}
</h2>
<Hint asChild label={label} side="right">
<Button size="icon" variant="ghost" onClick={() => { close(); }}>
<ArrowLeftFromLine className="size-4" />
</Button>
</Hint>
</div>
);
};

View File

@ -0,0 +1,53 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { Button } from '@/components/ui/common/Button';
import { Hint } from '@/components/ui/elements/Hint';
import { useSidebar } from '@/hooks/useSidebar';
import { cn } from '@/utils/tw-merge';
import type { Route } from './route.interface';
type SidebarItemProps = {
route: Route;
};
export const SidebarItem = ({ route }: SidebarItemProps) => {
const pathname = usePathname();
const { isCollapsed } = useSidebar();
const isActive = pathname === route.href;
return isCollapsed
? (
<Hint asChild label={route.label} side="right">
<Button
asChild
className={cn(
'h-11 w-full justify-center',
isActive && 'bg-accent',
)}
variant="ghost"
>
<Link href={route.href}>
<route.icon className="mr-0 size-5" />
</Link>
</Button>
</Hint>
)
: (
<Button
asChild
className={cn('h-11 w-full justify-start', isActive && 'bg-accent')}
variant="ghost"
>
<Link className="flex items-start gap-x-4" href={route.href}>
<route.icon className="mr-0 size-5" />
{route.label}
</Link>
</Button>
);
};

View File

@ -0,0 +1,39 @@
import { Folder, Home, Radio } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { RecommendedChannels } from './RecommendedChannels';
import { SidebarItem } from './SidebarItem';
import type { Route } from './route.interface';
export const UserNav = () => {
const t = useTranslations('layout.sidebar.userNav');
const routes: Route[] = [
{
label: t('home'),
href: '/',
icon: Home,
},
{
label: t('categories'),
href: '/categories',
icon: Folder,
},
{
label: t('streams'),
href: '/streams',
icon: Radio,
},
];
return (
<div className="space-y-2 px-2 pt-4 lg:pt-0">
{routes.map((route) => (
<SidebarItem key={route.href} route={route} />
))}
<RecommendedChannels />
</div>
);
};

View File

@ -0,0 +1,7 @@
import type { LucideIcon } from 'lucide-react';
export type Route = {
label: string;
href: string;
icon: LucideIcon;
};

View File

@ -0,0 +1,63 @@
import { type VariantProps, cva } from 'class-variance-authority';
import { type HTMLAttributes, forwardRef } from 'react';
import { cn } from '@/utils/tw-merge';
const alertVariants = cva(
'relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground',
{
variants: {
variant: {
default: 'bg-background text-foreground',
destructive:
'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive',
},
},
defaultVariants: {
variant: 'default',
},
},
);
const Alert = forwardRef<
HTMLDivElement,
HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
className={cn(alertVariants({ variant }), className)}
role="alert"
{...props}
/>
));
Alert.displayName = 'Alert';
const AlertTitle = forwardRef<
HTMLParagraphElement,
HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
// eslint-disable-next-line jsx-a11y/heading-has-content
<h5
ref={ref}
className={cn('mb-1 font-medium leading-none tracking-wide', className)}
{...props}
/>
));
AlertTitle.displayName = 'AlertTitle';
const AlertDescription = forwardRef<
HTMLParagraphElement,
HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
'mt-2 text-sm text-muted-foreground [&_p]:leading-relaxed',
className,
)}
{...props}
/>
));
AlertDescription.displayName = 'AlertDescription';
export { Alert, AlertDescription, AlertTitle };

View File

@ -0,0 +1,147 @@
'use client';
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
import {
type ComponentPropsWithoutRef,
type ComponentRef,
type HTMLAttributes,
forwardRef,
} from 'react';
import { cn } from '@/utils/tw-merge';
import { buttonVariants } from './Button';
const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
const AlertDialogPortal = AlertDialogPrimitive.Portal;
const AlertDialogOverlay = forwardRef<
ComponentRef<typeof AlertDialogPrimitive.Overlay>,
ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className,
)}
{...props}
ref={ref}
/>
));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
const AlertDialogContent = forwardRef<
ComponentRef<typeof AlertDialogPrimitive.Content>,
ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className,
)}
{...props}
/>
</AlertDialogPortal>
));
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({
className,
...props
}: HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col space-y-2 text-center sm:text-left',
className,
)}
{...props}
/>
);
AlertDialogHeader.displayName = 'AlertDialogHeader';
const AlertDialogFooter = ({
className,
...props
}: HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
className,
)}
{...props}
/>
);
AlertDialogFooter.displayName = 'AlertDialogFooter';
const AlertDialogTitle = forwardRef<
ComponentRef<typeof AlertDialogPrimitive.Title>,
ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold', className)}
{...props}
/>
));
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription = forwardRef<
ComponentRef<typeof AlertDialogPrimitive.Description>,
ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
));
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
const AlertDialogAction = forwardRef<
ComponentRef<typeof AlertDialogPrimitive.Action>,
ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
));
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel = forwardRef<
ComponentRef<typeof AlertDialogPrimitive.Cancel>,
ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: 'secondary' }),
'mt-2 sm:mt-0',
className,
)}
{...props}
/>
));
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
};

View File

@ -0,0 +1,54 @@
'use client';
import * as AvatarPrimitive from '@radix-ui/react-avatar';
import {
type ComponentPropsWithoutRef,
type ComponentRef,
forwardRef,
} from 'react';
import { cn } from '@/utils/tw-merge';
const Avatar = forwardRef<
ComponentRef<typeof AvatarPrimitive.Root>,
ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
'relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full',
className,
)}
{...props}
/>
));
Avatar.displayName = AvatarPrimitive.Root.displayName;
const AvatarImage = forwardRef<
ComponentRef<typeof AvatarPrimitive.Image>,
ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn('aspect-square h-full w-full', className)}
{...props}
/>
));
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
const AvatarFallback = forwardRef<
ComponentRef<typeof AvatarPrimitive.Fallback>,
ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
'flex h-full w-full items-center justify-center rounded-full bg-muted',
className,
)}
{...props}
/>
));
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
export { Avatar, AvatarFallback, AvatarImage };

View File

@ -0,0 +1,51 @@
import { Slot } from '@radix-ui/react-slot';
import { type VariantProps, cva } from 'class-variance-authority';
import { type ButtonHTMLAttributes, forwardRef } from 'react';
import { cn } from '@/utils/tw-merge';
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 cursor-pointer whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground',
outline: 'border border-border bg-background',
secondary: 'bg-secondary text-secondary-foreground',
ghost: 'text-accent-foreground hover:bg-accent hover:text-accent-foreground',
},
size: {
default: 'h-10 px-5 py-2 rounded-full',
icon: 'size-8 rounded-full',
lgIcon: 'size-10 rounded-full',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);
export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<typeof buttonVariants> & {
asChild?: boolean;
};
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({
className, variant, size, asChild = false, ...props
}, ref) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp
ref={ref}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
},
);
Button.displayName = 'Button';
export { Button, buttonVariants };

View File

@ -0,0 +1,76 @@
import { type HTMLAttributes, forwardRef } from 'react';
import { cn } from '@/utils/tw-merge';
const Card = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
'bg-card text-card-foreground border-border rounded-lg border shadow-sm',
className,
)}
{...props}
/>
),
);
Card.displayName = 'Card';
const CardHeader = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex flex-col space-y-1.5 p-6', className)}
{...props}
/>
),
);
CardHeader.displayName = 'CardHeader';
const CardTitle = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
'text-2xl font-semibold leading-none tracking-wide',
className,
)}
{...props}
/>
),
);
CardTitle.displayName = 'CardTitle';
const CardDescription = forwardRef<
HTMLDivElement,
HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
));
CardDescription.displayName = 'CardDescription';
const CardContent = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
),
);
CardContent.displayName = 'CardContent';
const CardFooter = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex items-center p-6 pt-0', className)}
{...props}
/>
),
);
CardFooter.displayName = 'CardFooter';
export {
Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle,
};

View File

@ -0,0 +1,130 @@
'use client';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { X } from 'lucide-react';
import {
type ComponentPropsWithoutRef,
type ComponentRef,
type HTMLAttributes,
forwardRef,
} from 'react';
import { cn } from '@/utils/tw-merge';
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = forwardRef<
ComponentRef<typeof DialogPrimitive.Overlay>,
ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = forwardRef<
ComponentRef<typeof DialogPrimitive.Content>,
ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="size-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
...props
}: HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col space-y-1.5 text-center sm:text-left',
className,
)}
{...props}
/>
);
DialogHeader.displayName = 'DialogHeader';
const DialogFooter = ({
className,
...props
}: HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
className,
)}
{...props}
/>
);
DialogFooter.displayName = 'DialogFooter';
const DialogTitle = forwardRef<
ComponentRef<typeof DialogPrimitive.Title>,
ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
'text-lg font-semibold leading-none tracking-wide',
className,
)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = forwardRef<
ComponentRef<typeof DialogPrimitive.Description>,
ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('textcn-sm text-muted-foreground', className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};

View File

@ -0,0 +1,210 @@
'use client';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { Check, ChevronRight, Circle } from 'lucide-react';
import {
type ComponentPropsWithoutRef,
type ComponentRef,
type HTMLAttributes,
forwardRef,
} from 'react';
import { cn } from '@/utils/tw-merge';
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = forwardRef<
ComponentRef<typeof DropdownMenuPrimitive.SubTrigger>,
ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({
className, inset, children, ...props
}, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
'flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
inset && 'pl-8',
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = forwardRef<
ComponentRef<typeof DropdownMenuPrimitive.SubContent>,
ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
'z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className,
)}
{...props}
/>
));
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = forwardRef<
ComponentRef<typeof DropdownMenuPrimitive.Content>,
ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
className={cn(
'z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className,
)}
sideOffset={sideOffset}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = forwardRef<
ComponentRef<typeof DropdownMenuPrimitive.Item>,
ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
inset && 'pl-8',
className,
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = forwardRef<
ComponentRef<typeof DropdownMenuPrimitive.CheckboxItem>,
ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({
className, children, checked, ...props
}, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
checked={checked}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50',
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = forwardRef<
ComponentRef<typeof DropdownMenuPrimitive.RadioItem>,
ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50',
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = forwardRef<
ComponentRef<typeof DropdownMenuPrimitive.Label>,
ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
'px-2 py-1.5 text-sm font-semibold',
inset && 'pl-8',
className,
)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = forwardRef<
ComponentRef<typeof DropdownMenuPrimitive.Separator>,
ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({
className,
...props
}: HTMLAttributes<HTMLSpanElement>) => (
<span
className={cn(
'ml-auto text-xs tracking-widest opacity-60',
className,
)}
{...props}
/>
);
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
export {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
};

View File

@ -0,0 +1,192 @@
'use client';
import { Slot } from '@radix-ui/react-slot';
import {
type ComponentPropsWithoutRef,
type ComponentRef,
type HTMLAttributes,
createContext,
forwardRef,
useContext,
useId,
} from 'react';
import {
Controller,
type ControllerProps,
type FieldPath,
type FieldValues,
FormProvider,
useFormContext,
} from 'react-hook-form';
import { cn } from '@/utils/tw-merge';
import { Label } from './Label';
import type * as LabelPrimitive from '@radix-ui/react-label';
const Form = FormProvider;
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName;
};
const FormFieldContext = createContext<FormFieldContextValue>(
{} as FormFieldContextValue,
);
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => (
// eslint-disable-next-line react/jsx-no-constructed-context-values
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
);
type FormItemContextValue = {
id: string;
};
const FormItemContext = createContext<FormItemContextValue>(
{} as FormItemContextValue,
);
const FormItem = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
const id = useId();
return (
// eslint-disable-next-line react/jsx-no-constructed-context-values
<FormItemContext.Provider value={{ id }}>
<div
ref={ref}
className={cn('space-y-2', className)}
{...props}
/>
</FormItemContext.Provider>
);
},
);
FormItem.displayName = 'FormItem';
const useFormField = () => {
const fieldContext = useContext(FormFieldContext);
const itemContext = useContext(FormItemContext);
const { getFieldState, formState } = useFormContext();
const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext) {
throw new Error('useFormField should be used within <FormField>');
}
const { id } = itemContext;
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
};
};
const FormLabel = forwardRef<
ComponentRef<typeof LabelPrimitive.Root>,
ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField();
return (
<Label
ref={ref}
className={cn(error && 'text-destructive', className)}
htmlFor={formItemId}
{...props}
/>
);
});
FormLabel.displayName = 'FormLabel';
const FormControl = forwardRef<
ComponentRef<typeof Slot>,
ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const {
error, formItemId, formDescriptionId, formMessageId,
} = useFormField();
return (
<Slot
ref={ref}
aria-describedby={
!error
? formDescriptionId
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
id={formItemId}
{...props}
/>
);
});
FormControl.displayName = 'FormControl';
const FormDescription = forwardRef<
HTMLParagraphElement,
HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField();
return (
<p
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
id={formDescriptionId}
{...props}
/>
);
});
FormDescription.displayName = 'FormDescription';
const FormMessage = forwardRef<
HTMLParagraphElement,
HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField();
const body = error ? String(error?.message) : children;
if (!body) {
return null;
}
return (
<p
ref={ref}
className={cn('text-sm font-medium text-destructive', className)}
id={formMessageId}
{...props}
>
{body}
</p>
);
});
FormMessage.displayName = 'FormMessage';
export {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
useFormField,
};

View File

@ -0,0 +1,20 @@
import { type ComponentProps, forwardRef } from 'react';
import { cn } from '@/utils/tw-merge';
const Input = forwardRef<HTMLInputElement, ComponentProps<'input'>>(
({ className, type, ...props }, ref) => (
<input
ref={ref}
className={cn(
'flex h-10 w-full rounded-md border border-border bg-input px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus:border-primary focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
type={type}
{...props}
/>
),
);
Input.displayName = 'Input';
export { Input };

View File

@ -0,0 +1,85 @@
'use client';
import { OTPInput, OTPInputContext } from 'input-otp';
import { Dot } from 'lucide-react';
import {
type ComponentPropsWithoutRef,
type ComponentRef,
forwardRef,
useContext,
} from 'react';
import { cn } from '@/utils/tw-merge';
const InputOTP = forwardRef<
ComponentRef<typeof OTPInput>,
ComponentPropsWithoutRef<typeof OTPInput>
>(({ className, containerClassName, ...props }, ref) => (
<OTPInput
ref={ref}
className={cn('disabled:cursor-not-allowed', className)}
containerClassName={cn(
'flex items-center gap-2 has-disabled:opacity-50',
containerClassName,
)}
{...props}
/>
));
InputOTP.displayName = 'InputOTP';
const InputOTPGroup = forwardRef<
ComponentRef<'div'>,
ComponentPropsWithoutRef<'div'>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex items-center gap-x-3', className)}
{...props}
/>
));
InputOTPGroup.displayName = 'InputOTPGroup';
const InputOTPSlot = forwardRef<
ComponentRef<'div'>,
ComponentPropsWithoutRef<'div'> & { index: number }
>(({ index, className, ...props }, ref) => {
const inputOTPContext = useContext(OTPInputContext);
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index];
return (
<div
ref={ref}
className={cn(
'relative flex h-10 w-14 items-center justify-center rounded-md border border-border text-sm transition-all',
isActive && 'z-10 ring-2 ring-primary ring-offset-background',
className,
)}
{...props}
>
{char}
{hasFakeCaret
? (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="animate-caret-blink h-4 w-px bg-foreground duration-1000" />
</div>
)
: null}
</div>
);
});
InputOTPSlot.displayName = 'InputOTPSlot';
const InputOTPSeparator = forwardRef<
ComponentRef<'div'>,
ComponentPropsWithoutRef<'div'>
>(({ ...props }, ref) => (
<div ref={ref} role="separator" {...props}>
<Dot />
</div>
));
InputOTPSeparator.displayName = 'InputOTPSeparator';
export {
InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot,
};

View File

@ -0,0 +1,30 @@
'use client';
import * as LabelPrimitive from '@radix-ui/react-label';
import { type VariantProps, cva } from 'class-variance-authority';
import {
type ComponentPropsWithoutRef,
type ComponentRef,
forwardRef,
} from 'react';
import { cn } from '@/utils/tw-merge';
const labelVariants = cva(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
);
const Label = forwardRef<
ComponentRef<typeof LabelPrimitive.Root>,
ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
& VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };

View File

@ -0,0 +1,37 @@
'use client';
import * as PopoverPrimitive from '@radix-ui/react-popover';
import {
type ComponentPropsWithoutRef,
type ComponentRef,
forwardRef,
} from 'react';
import { cn } from '@/utils/tw-merge';
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverContent = forwardRef<
ComponentRef<typeof PopoverPrimitive.Content>,
ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({
className, align = 'center', sideOffset = 4, ...props
}, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
className={cn(
'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className,
)}
sideOffset={sideOffset}
{...props}
/>
</PopoverPrimitive.Portal>
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverContent, PopoverTrigger };

View File

@ -0,0 +1,170 @@
'use client';
import * as SelectPrimitive from '@radix-ui/react-select';
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
import {
type ComponentPropsWithoutRef,
type ComponentRef,
forwardRef,
} from 'react';
import { cn } from '@/utils/tw-merge';
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = forwardRef<
ComponentRef<typeof SelectPrimitive.Trigger>,
ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
'flex h-10 w-full items-center justify-between rounded-md border border-border bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = forwardRef<
ComponentRef<typeof SelectPrimitive.ScrollUpButton>,
ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
'flex cursor-default items-center justify-center py-1',
className,
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = forwardRef<
ComponentRef<typeof SelectPrimitive.ScrollDownButton>,
ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
'flex cursor-default items-center justify-center py-1',
className,
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = forwardRef<
ComponentRef<typeof SelectPrimitive.Content>,
ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({
className, children, position = 'popper', ...props
}, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
'relative z-50 max-h-96 min-w-32 overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
position === 'popper'
&& 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className,
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper'
&& 'h-(--radix-select-trigger-height) w-full min-w-(--radix-select-trigger-width)',
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = forwardRef<
ComponentRef<typeof SelectPrimitive.Label>,
ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn('py-1.5 pl-8 pr-2 text-sm font-semibold', className)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = forwardRef<
ComponentRef<typeof SelectPrimitive.Item>,
ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50',
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>
{children}
</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = forwardRef<
ComponentRef<typeof SelectPrimitive.Separator>,
ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};

View File

@ -0,0 +1,39 @@
'use client';
import * as SeparatorPrimitive from '@radix-ui/react-separator';
import {
type ComponentPropsWithoutRef,
type ComponentRef,
forwardRef,
} from 'react';
import { cn } from '@/utils/tw-merge';
const Separator = forwardRef<
ComponentRef<typeof SeparatorPrimitive.Root>,
ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{
className, orientation = 'horizontal', decorative = true, ...props
},
ref,
) => (
<SeparatorPrimitive.Root
ref={ref}
className={cn(
'shrink-0 bg-border',
orientation === 'horizontal'
? 'h-px w-full'
: 'h-full w-px',
className,
)}
decorative={decorative}
orientation={orientation}
{...props}
/>
),
);
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator };

View File

@ -0,0 +1,15 @@
import { cn } from '@/utils/tw-merge';
import type { HTMLAttributes } from 'react';
const Skeleton = ({ className, ...props }: HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'animate-pulse rounded-lg bg-card dark:bg-muted',
className,
)}
{...props}
/>
);
export { Skeleton };

View File

@ -0,0 +1,33 @@
'use client';
import * as SwitchPrimitives from '@radix-ui/react-switch';
import {
type ComponentPropsWithoutRef,
type ComponentRef,
forwardRef,
} from 'react';
import { cn } from '@/utils/tw-merge';
const Switch = forwardRef<
ComponentRef<typeof SwitchPrimitives.Root>,
ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-muted-foreground',
className,
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
'pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0',
)}
/>
</SwitchPrimitives.Root>
));
Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch };

View File

@ -0,0 +1,61 @@
'use client';
import * as TabsPrimitive from '@radix-ui/react-tabs';
import {
type ComponentPropsWithoutRef,
type ComponentRef,
forwardRef,
} from 'react';
import { cn } from '@/utils/tw-merge';
const Tabs = TabsPrimitive.Root;
const TabsList = forwardRef<
ComponentRef<typeof TabsPrimitive.List>,
ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
'inline-flex h-10 items-center justify-center rounded-md bg-card p-1 text-muted-foreground',
className,
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = forwardRef<
ComponentRef<typeof TabsPrimitive.Trigger>,
ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
'data-data-[state=active]:shadow-sm inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium text-foreground ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-accent',
className,
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = forwardRef<
ComponentRef<typeof TabsPrimitive.Content>,
ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
className,
)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export {
Tabs, TabsContent, TabsList, TabsTrigger,
};

View File

@ -0,0 +1,19 @@
import { type ComponentProps, forwardRef } from 'react';
import { cn } from '@/utils/tw-merge';
const Textarea = forwardRef<HTMLTextAreaElement, ComponentProps<'textarea'>>(
({ className, ...props }, ref) => (
<textarea
ref={ref}
className={cn(
'flex max-h-[80px] min-h-[80px] w-full rounded-md border border-border bg-input px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:border-primary focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
/>
),
);
Textarea.displayName = 'Textarea';
export { Textarea };

Some files were not shown because too many files have changed in this diff Show More