[frontend]: add followers page

This commit is contained in:
Sergey Krylov 2025-08-14 05:57:37 +03:00
parent ed59327cb7
commit 0f16f9d209
9 changed files with 472 additions and 0 deletions

View File

@ -30,6 +30,7 @@
"@radix-ui/react-switch": "1.2.5",
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-tooltip": "1.2.7",
"@tanstack/react-table": "8.21.3",
"apollo-upload-client": "18.0.1",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",

View File

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

View File

@ -0,0 +1,106 @@
'use client';
import { MoreHorizontal, User } from 'lucide-react';
import Link from 'next/link';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/common/Button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/common/DropdownMenu';
import { ChannelAvatar } from '@/components/ui/elements/ChannelAvatar';
import { ChannelVerified } from '@/components/ui/elements/ChannelVerified';
import { DataTable, DataTableSkeleton } from '@/components/ui/elements/DataTable';
import { Heading } from '@/components/ui/elements/Heading';
import {
type FindMyFollowersQuery,
useFindMyFollowersQuery,
} from '@/graphql/generated/output';
import { formatDate } from '@/utils/format-date';
import type { ColumnDef } from '@tanstack/react-table';
const FollowerCell = ({ row }: any) => (
<div className="flex items-center gap-x-2">
<ChannelAvatar channel={row.original.follower} size="sm" />
<h2>
{row.original.follower.name}
</h2>
{row.original.follower.isVerified ? <ChannelVerified size="sm" /> : null}
</div>
);
const FollowerActionCell = ({ row }: any) => {
const t = useTranslations('dashboard.followers');
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="size-8 p-0" variant="ghost">
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent side="right">
<Link
href={`/${row.original.follower.name}`}
target="_blank"
>
<DropdownMenuItem>
<User className="mr-2 size-4" />
{t('columns.viewChannel')}
</DropdownMenuItem>
</Link>
</DropdownMenuContent>
</DropdownMenu>
);
};
export const FollowersTable = () => {
const t = useTranslations('dashboard.followers');
const { data, loading: isLoadingFollowers } = useFindMyFollowersQuery();
const followers = data?.findMyFollowers ?? [];
const followersColumns: ColumnDef<FindMyFollowersQuery['findMyFollowers'][0]>[] = [
{
accessorKey: 'createdAt',
header: t('columns.date'),
cell: ({ row }) => formatDate(row.original.createdAt),
},
{
accessorKey: 'follower',
header: t('columns.user'),
cell: FollowerCell,
},
{
accessorKey: 'actions',
header: t('columns.actions'),
cell: FollowerActionCell,
},
];
return (
<div className="lg:px-10">
<Heading
description={t('header.description')}
size="lg"
title={t('header.heading')}
/>
<div className="mt-5">
{
isLoadingFollowers
? <DataTableSkeleton />
: <DataTable columns={followersColumns} data={followers} />
}
</div>
</div>
);
};

View File

@ -0,0 +1,124 @@
import {
type HTMLAttributes,
type TdHTMLAttributes,
type ThHTMLAttributes,
forwardRef,
} from 'react';
import { cn } from '@/utils/tw-merge';
const Table = forwardRef<HTMLTableElement, HTMLAttributes<HTMLTableElement>>(
({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn('w-full caption-bottom text-sm', className)}
{...props}
/>
</div>
),
);
Table.displayName = 'Table';
const TableHeader = forwardRef<
HTMLTableSectionElement,
HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
));
TableHeader.displayName = 'TableHeader';
const TableBody = forwardRef<
HTMLTableSectionElement,
HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn('[&_tr:last-child]:border-0', className)}
{...props}
/>
));
TableBody.displayName = 'TableBody';
const TableFooter = forwardRef<
HTMLTableSectionElement,
HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
'border-t bg-muted/50 font-medium [&>tr]:last:border-b-0',
className,
)}
{...props}
/>
));
TableFooter.displayName = 'TableFooter';
const TableRow = forwardRef<
HTMLTableRowElement,
HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
'border-b transition-colors hover:bg-card data-[state=selected]:bg-muted',
className,
)}
{...props}
/>
));
TableRow.displayName = 'TableRow';
const TableHead = forwardRef<
HTMLTableCellElement,
ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
'h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0',
className,
)}
{...props}
/>
));
TableHead.displayName = 'TableHead';
const TableCell = forwardRef<
HTMLTableCellElement,
TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn(
'px-4 py-2 align-middle [&:has([role=checkbox])]:pr-0',
className,
)}
{...props}
/>
));
TableCell.displayName = 'TableCell';
const TableCaption = forwardRef<
HTMLTableCaptionElement,
HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn('mt-4 text-sm text-muted-foreground', className)}
{...props}
/>
));
TableCaption.displayName = 'TableCaption';
export {
Table,
TableBody,
TableCaption,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
};

View File

@ -0,0 +1,99 @@
'use client';
import {
type ColumnDef,
flexRender,
getCoreRowModel,
useReactTable,
} from '@tanstack/react-table';
import { Loader } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { Card } from '../common/Card';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '../common/Table';
type DataTableProps<TData, TValue> = {
columns: ColumnDef<TData, TValue>[];
data: TData[];
};
export const DataTable = <TData, TValue>({
columns,
data,
}: DataTableProps<TData, TValue>) => {
const t = useTranslations('components.dataTable');
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
});
return (
<div className="rounded-lg border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef
.header,
header.getContext(),
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length
? table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
: (
<TableRow>
<TableCell
className="h-24 text-center"
colSpan={columns.length}
>
{t('notFound')}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
);
};
export const DataTableSkeleton = () => (
<div className="mx-auto mb-10 w-full max-w-(--breakpoint-2xl)">
<Card className="mt-6 flex h-[500px] w-full items-center justify-center">
<Loader className="size-8 animate-spin text-muted-foreground" />
</Card>
</div>
);

View File

@ -764,6 +764,16 @@ 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 FindMyFollowersQueryVariables = Exact<{ [key: string]: never; }>;
export type FindMyFollowersQuery = { __typename?: 'Query', findMyFollowers: Array<{ __typename?: 'FollowModel', createdAt: any, follower: { __typename?: 'UserModel', name: string, avatar?: string | null, isVerified: boolean } }> };
export type FindMyFollowingsQueryVariables = Exact<{ [key: string]: never; }>;
export type FindMyFollowingsQuery = { __typename?: 'Query', findMyFollowings: Array<{ __typename?: 'FollowModel', createdAt: any, followingId: string }> };
export type FindCurrentSessionQueryVariables = Exact<{ [key: string]: never; }>;
@ -1676,6 +1686,90 @@ export type FindRecommendedChannelsQueryHookResult = ReturnType<typeof useFindRe
export type FindRecommendedChannelsLazyQueryHookResult = ReturnType<typeof useFindRecommendedChannelsLazyQuery>;
export type FindRecommendedChannelsSuspenseQueryHookResult = ReturnType<typeof useFindRecommendedChannelsSuspenseQuery>;
export type FindRecommendedChannelsQueryResult = Apollo.QueryResult<FindRecommendedChannelsQuery, FindRecommendedChannelsQueryVariables>;
export const FindMyFollowersDocument = gql`
query FindMyFollowers {
findMyFollowers {
createdAt
follower {
name
avatar
isVerified
}
}
}
`;
/**
* __useFindMyFollowersQuery__
*
* To run a query within a React component, call `useFindMyFollowersQuery` and pass it any options that fit your needs.
* When your component renders, `useFindMyFollowersQuery` 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 } = useFindMyFollowersQuery({
* variables: {
* },
* });
*/
export function useFindMyFollowersQuery(baseOptions?: Apollo.QueryHookOptions<FindMyFollowersQuery, FindMyFollowersQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<FindMyFollowersQuery, FindMyFollowersQueryVariables>(FindMyFollowersDocument, options);
}
export function useFindMyFollowersLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<FindMyFollowersQuery, FindMyFollowersQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<FindMyFollowersQuery, FindMyFollowersQueryVariables>(FindMyFollowersDocument, options);
}
export function useFindMyFollowersSuspenseQuery(baseOptions?: Apollo.SkipToken | Apollo.SuspenseQueryHookOptions<FindMyFollowersQuery, FindMyFollowersQueryVariables>) {
const options = baseOptions === Apollo.skipToken ? baseOptions : {...defaultOptions, ...baseOptions}
return Apollo.useSuspenseQuery<FindMyFollowersQuery, FindMyFollowersQueryVariables>(FindMyFollowersDocument, options);
}
export type FindMyFollowersQueryHookResult = ReturnType<typeof useFindMyFollowersQuery>;
export type FindMyFollowersLazyQueryHookResult = ReturnType<typeof useFindMyFollowersLazyQuery>;
export type FindMyFollowersSuspenseQueryHookResult = ReturnType<typeof useFindMyFollowersSuspenseQuery>;
export type FindMyFollowersQueryResult = Apollo.QueryResult<FindMyFollowersQuery, FindMyFollowersQueryVariables>;
export const FindMyFollowingsDocument = gql`
query FindMyFollowings {
findMyFollowings {
createdAt
followingId
}
}
`;
/**
* __useFindMyFollowingsQuery__
*
* To run a query within a React component, call `useFindMyFollowingsQuery` and pass it any options that fit your needs.
* When your component renders, `useFindMyFollowingsQuery` 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 } = useFindMyFollowingsQuery({
* variables: {
* },
* });
*/
export function useFindMyFollowingsQuery(baseOptions?: Apollo.QueryHookOptions<FindMyFollowingsQuery, FindMyFollowingsQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<FindMyFollowingsQuery, FindMyFollowingsQueryVariables>(FindMyFollowingsDocument, options);
}
export function useFindMyFollowingsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<FindMyFollowingsQuery, FindMyFollowingsQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<FindMyFollowingsQuery, FindMyFollowingsQueryVariables>(FindMyFollowingsDocument, options);
}
export function useFindMyFollowingsSuspenseQuery(baseOptions?: Apollo.SkipToken | Apollo.SuspenseQueryHookOptions<FindMyFollowingsQuery, FindMyFollowingsQueryVariables>) {
const options = baseOptions === Apollo.skipToken ? baseOptions : {...defaultOptions, ...baseOptions}
return Apollo.useSuspenseQuery<FindMyFollowingsQuery, FindMyFollowingsQueryVariables>(FindMyFollowingsDocument, options);
}
export type FindMyFollowingsQueryHookResult = ReturnType<typeof useFindMyFollowingsQuery>;
export type FindMyFollowingsLazyQueryHookResult = ReturnType<typeof useFindMyFollowingsLazyQuery>;
export type FindMyFollowingsSuspenseQueryHookResult = ReturnType<typeof useFindMyFollowingsSuspenseQuery>;
export type FindMyFollowingsQueryResult = Apollo.QueryResult<FindMyFollowingsQuery, FindMyFollowingsQueryVariables>;
export const FindCurrentSessionDocument = gql`
query FindCurrentSession {
findCurrentSession {

View File

@ -0,0 +1,10 @@
query FindMyFollowers {
findMyFollowers {
createdAt
follower {
name
avatar
isVerified
}
}
}

View File

@ -0,0 +1,6 @@
query FindMyFollowings {
findMyFollowings {
createdAt
followingId
}
}

View File

@ -2169,6 +2169,18 @@
postcss "^8.4.41"
tailwindcss "4.1.11"
"@tanstack/react-table@8.21.3":
version "8.21.3"
resolved "https://registry.yarnpkg.com/@tanstack/react-table/-/react-table-8.21.3.tgz#2c38c747a5731c1a07174fda764b9c2b1fb5e91b"
integrity sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==
dependencies:
"@tanstack/table-core" "8.21.3"
"@tanstack/table-core@8.21.3":
version "8.21.3"
resolved "https://registry.yarnpkg.com/@tanstack/table-core/-/table-core-8.21.3.tgz#2977727d8fc8dfa079112d9f4d4c019110f1732c"
integrity sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==
"@theguild/federation-composition@^0.19.0":
version "0.19.1"
resolved "https://registry.yarnpkg.com/@theguild/federation-composition/-/federation-composition-0.19.1.tgz#b3907bfcbdbd69d4628a3b1270936615f56b904f"