[frontend]: add followers page
This commit is contained in:
parent
ed59327cb7
commit
0f16f9d209
@ -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",
|
||||
|
||||
20
frontend/src/app/(site)/dashboard/followers/page.tsx
Normal file
20
frontend/src/app/(site)/dashboard/followers/page.tsx
Normal 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;
|
||||
106
frontend/src/components/features/follow/table/FollowersTable.tsx
Normal file
106
frontend/src/components/features/follow/table/FollowersTable.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
124
frontend/src/components/ui/common/Table.tsx
Normal file
124
frontend/src/components/ui/common/Table.tsx
Normal 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,
|
||||
};
|
||||
99
frontend/src/components/ui/elements/DataTable.tsx
Normal file
99
frontend/src/components/ui/elements/DataTable.tsx
Normal 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>
|
||||
);
|
||||
@ -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 {
|
||||
|
||||
10
frontend/src/graphql/queries/follow/FindMyFollowers.graphql
Normal file
10
frontend/src/graphql/queries/follow/FindMyFollowers.graphql
Normal file
@ -0,0 +1,10 @@
|
||||
query FindMyFollowers {
|
||||
findMyFollowers {
|
||||
createdAt
|
||||
follower {
|
||||
name
|
||||
avatar
|
||||
isVerified
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
query FindMyFollowings {
|
||||
findMyFollowings {
|
||||
createdAt
|
||||
followingId
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user