[frontend]: add recommended channels list

This commit is contained in:
Sergey Krylov 2025-08-07 05:21:33 +03:00
parent 7eca41f8fb
commit 586f2ce77f
13 changed files with 255 additions and 16 deletions

View File

@ -3,8 +3,8 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "yarn run codegen && next dev --turbopack", "dev": "next dev --turbopack",
"build": "yarn run codegen && next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",
"codegen": "graphql-codegen --config graphql.config.ts --watch" "codegen": "graphql-codegen --config graphql.config.ts --watch"

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,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

@ -1,6 +1,7 @@
import { Folder, Home, Radio } from 'lucide-react'; import { Folder, Home, Radio } from 'lucide-react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { RecommendedChannels } from './RecommendedChannels';
import { SidebarItem } from './SidebarItem'; import { SidebarItem } from './SidebarItem';
import type { Route } from './route.interface'; import type { Route } from './route.interface';
@ -32,7 +33,7 @@ export const UserNav = () => {
<SidebarItem key={route.href} route={route} /> <SidebarItem key={route.href} route={route} />
))} ))}
{/* <RecommededChannels /> */} <RecommendedChannels />
</div> </div>
); );
}; };

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,34 @@
import { type VariantProps, cva } from 'class-variance-authority';
import { Check } from 'lucide-react';
import { cn } from '@/utils/tw-merge';
const channelVerifiedSizes = cva('', {
variants: {
size: {
sm: 'size-3',
default: 'size-4',
},
},
defaultVariants: {
size: 'default',
},
});
type ChannelVerifiedProps = VariantProps<typeof channelVerifiedSizes> & {};
export const ChannelVerified = ({ size }: ChannelVerifiedProps) => (
<span
className={cn(
'flex items-center justify-center rounded-full bg-primary',
channelVerifiedSizes({ size }),
)}
>
<Check
className={cn(
'stroke-[4px] p-[3px] text-white',
size === 'sm' ? 'size-2' : 'size-4',
)}
/>
</span>
);

View File

@ -0,0 +1,13 @@
'use client';
import { useTranslations } from 'next-intl';
export const LiveBadge = () => {
const t = useTranslations('components.liveBadge');
return (
<div className="rounded-full bg-rose-500 p-0.5 px-2 text-center text-xs font-semibold uppercase tracking-wide text-white">
{t('text')}
</div>
);
};

View File

@ -1,10 +0,0 @@
import type messages from '../public/languages/ru.json';
const locales = ['ru', 'en'] as const;
declare module 'next-intl' {
type AppConfig = {
Locale: (typeof locales)[number];
Messages: typeof messages;
};
}

View File

@ -636,6 +636,11 @@ export type VerifyAccountMutationVariables = Exact<{
export type VerifyAccountMutation = { __typename?: 'Mutation', verifyAccount: { __typename?: 'AuthModel', message?: string | null, user?: { __typename?: 'UserModel', isEmailVerified: boolean } | null } }; export type VerifyAccountMutation = { __typename?: 'Mutation', verifyAccount: { __typename?: 'AuthModel', message?: string | null, user?: { __typename?: 'UserModel', isEmailVerified: boolean } | null } };
export type FindRecommendedChannelsQueryVariables = Exact<{ [key: string]: never; }>;
export type FindRecommendedChannelsQuery = { __typename?: 'Query', findRecommendedChannels: Array<{ __typename?: 'UserModel', id: string, name: string, avatar?: string | null, isVerified: boolean, stream: { __typename?: 'StreamModel', isLive: boolean } }> };
export type FindChannelByUsernameQueryVariables = Exact<{ export type FindChannelByUsernameQueryVariables = Exact<{
name: Scalars['String']['input']; name: Scalars['String']['input'];
}>; }>;
@ -890,6 +895,51 @@ export function useVerifyAccountMutation(baseOptions?: Apollo.MutationHookOption
export type VerifyAccountMutationHookResult = ReturnType<typeof useVerifyAccountMutation>; export type VerifyAccountMutationHookResult = ReturnType<typeof useVerifyAccountMutation>;
export type VerifyAccountMutationResult = Apollo.MutationResult<VerifyAccountMutation>; export type VerifyAccountMutationResult = Apollo.MutationResult<VerifyAccountMutation>;
export type VerifyAccountMutationOptions = Apollo.BaseMutationOptions<VerifyAccountMutation, VerifyAccountMutationVariables>; export type VerifyAccountMutationOptions = Apollo.BaseMutationOptions<VerifyAccountMutation, VerifyAccountMutationVariables>;
export const FindRecommendedChannelsDocument = gql`
query FindRecommendedChannels {
findRecommendedChannels {
id
name
avatar
isVerified
stream {
isLive
}
}
}
`;
/**
* __useFindRecommendedChannelsQuery__
*
* To run a query within a React component, call `useFindRecommendedChannelsQuery` and pass it any options that fit your needs.
* When your component renders, `useFindRecommendedChannelsQuery` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI.
*
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useFindRecommendedChannelsQuery({
* variables: {
* },
* });
*/
export function useFindRecommendedChannelsQuery(baseOptions?: Apollo.QueryHookOptions<FindRecommendedChannelsQuery, FindRecommendedChannelsQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<FindRecommendedChannelsQuery, FindRecommendedChannelsQueryVariables>(FindRecommendedChannelsDocument, options);
}
export function useFindRecommendedChannelsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<FindRecommendedChannelsQuery, FindRecommendedChannelsQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<FindRecommendedChannelsQuery, FindRecommendedChannelsQueryVariables>(FindRecommendedChannelsDocument, options);
}
export function useFindRecommendedChannelsSuspenseQuery(baseOptions?: Apollo.SkipToken | Apollo.SuspenseQueryHookOptions<FindRecommendedChannelsQuery, FindRecommendedChannelsQueryVariables>) {
const options = baseOptions === Apollo.skipToken ? baseOptions : {...defaultOptions, ...baseOptions}
return Apollo.useSuspenseQuery<FindRecommendedChannelsQuery, FindRecommendedChannelsQueryVariables>(FindRecommendedChannelsDocument, options);
}
export type FindRecommendedChannelsQueryHookResult = ReturnType<typeof useFindRecommendedChannelsQuery>;
export type FindRecommendedChannelsLazyQueryHookResult = ReturnType<typeof useFindRecommendedChannelsLazyQuery>;
export type FindRecommendedChannelsSuspenseQueryHookResult = ReturnType<typeof useFindRecommendedChannelsSuspenseQuery>;
export type FindRecommendedChannelsQueryResult = Apollo.QueryResult<FindRecommendedChannelsQuery, FindRecommendedChannelsQueryVariables>;
export const FindChannelByUsernameDocument = gql` export const FindChannelByUsernameDocument = gql`
query FindChannelByUsername($name: String!) { query FindChannelByUsername($name: String!) {
findChannelByUsername(name: $name) { findChannelByUsername(name: $name) {

View File

@ -0,0 +1,11 @@
query FindRecommendedChannels {
findRecommendedChannels {
id
name
avatar
isVerified
stream {
isLive
}
}
}

View File

@ -19,12 +19,9 @@ export function useCurrent() {
useEffect(() => { useEffect(() => {
if (error) { if (error) {
console.log('error', error);
if (isAuthenticated) { if (isAuthenticated) {
console.log('CLEAR');
void clear(); void clear();
} }
console.log('exit');
exit(); exit();
} }
}, [isAuthenticated, exit, clear]); }, [isAuthenticated, exit, clear]);

View File

@ -1,4 +1,5 @@
import { ApolloClient, createHttpLink, InMemoryCache } from '@apollo/client'; import { ApolloClient, createHttpLink, InMemoryCache } from '@apollo/client';
import { SERVER_URL } from './constants/url.constants'; import { SERVER_URL } from './constants/url.constants';
const httpLink = createHttpLink({ const httpLink = createHttpLink({

10
frontend/src/libs/i18n/global.d.ts vendored Normal file
View File

@ -0,0 +1,10 @@
import type { Language } from './config';
import type messages from '../../../public/languages/ru.json';
export declare module 'next-intl' {
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
interface AppConfig {
Locale: Language;
Messages: typeof messages;
}
}