feat: added grid layout for person list

Signed-off-by: Varun Raj <mailme@varunraj.in>
This commit is contained in:
Varun Raj
2024-08-18 01:15:25 +05:30
parent 38b736c5ee
commit 971bc77b6e
21 changed files with 477 additions and 117 deletions
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -1,6 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
reactStrictMode: false,
};
export default nextConfig;
+3
View File
@@ -12,8 +12,11 @@
"@radix-ui/react-avatar": "^1.1.0",
"@radix-ui/react-dialog": "^1.1.1",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-popover": "^1.1.1",
"@radix-ui/react-scroll-area": "^1.1.0",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-switch": "^1.1.0",
"@tanstack/react-table": "^8.20.1",
"@types/qs": "^6.9.15",
"axios": "^1.7.4",
-6
View File
@@ -1,11 +1,6 @@
import { Inter as FontSans } from "next/font/google"
import { cn } from "@/lib/utils"
const fontSans = FontSans({
subsets: ["latin"],
variable: "--font-sans",
})
type RootLayoutProps = {
children: React.ReactNode
@@ -15,7 +10,6 @@ export default function RootLayout({ children }: RootLayoutProps) {
return (
<div className={cn(
"min-h-screen bg-background font-sans antialiased",
fontSans.variable
)}>
{children}
</div>
+25 -9
View File
@@ -1,5 +1,4 @@
import { listPeople } from "@/handlers/api/people.handler";
import { IListData } from "@/types/common";
import { IPerson } from "@/types/person";
import React, { useEffect, useState } from "react";
import Loader from "../ui/loader";
@@ -9,6 +8,9 @@ import { Table } from "../ui/table";
import PersonBirthdayCell from "./PersonBirthdayCell";
import Link from "next/link";
import { ENV } from "@/config/environment";
import PersonHideCell from "./PersonHideCell";
import { PeoplePagination } from "./PeoplePagination";
import { useRouter } from "next/router";
const columns: ColumnDef<IPerson>[] = [
{
@@ -25,7 +27,7 @@ const columns: ColumnDef<IPerson>[] = [
header: "Hidden",
accessorKey: "isHidden",
cell: ({ row }) => (
<span>{row.original.isHidden ? "Yes" : "No"}</span>
<PersonHideCell person={row.original} />
),
},
{
@@ -46,15 +48,18 @@ const columns: ColumnDef<IPerson>[] = [
},
];
export default function PeopleList() {
const router = useRouter()
const { page = 1 } = router.query as { page: string }
const [people, setPeople] = useState<IPerson[]>([]);
const [loading, setLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const fetchData = async () => {
return listPeople()
setLoading(true);
return listPeople({
page
})
.then((response) => {
setPeople(response.people);
})
@@ -67,17 +72,28 @@ export default function PeopleList() {
};
useEffect(() => {
if (!router.isReady) return;
fetchData();
}, []);
}, [page]);
const renderContent = () => {
if (loading) return <Loader />;
if (errorMessage) return <div>{errorMessage}</div>;
return (
<div className="grid grid-cols-8 gap-4 p-2">
{people.map((person) => (
<PersonItem person={person} />
))}
</div>
)
}
return (
<div>
<div className="flex flex-col gap-2">
<Table<IPerson> columns={columns} data={people} />
{renderContent()}
<div className="fixed bottom-0 w-full bg-gray-200 py-2 flex justify-center">
<PeoplePagination />
</div>
</div>
);
@@ -0,0 +1,47 @@
import { useRouter } from "next/router"
import { useMemo } from "react"
import { Button } from "../ui/button"
import { ArrowLeft, ArrowRight } from "lucide-react"
import Link from "next/link"
export function PeoplePagination() {
const router = useRouter()
const { pathname } = router;
const { page = 1 } = router.query as { page: string }
const [nextPage, prevPage] = useMemo(() => {
const pageNum = parseInt(page.toString() || "1", 10)
return [pageNum + 1, pageNum - 1]
}, [page])
return (
<div className="flex gap-2">
<Link href={{
pathname,
query: { page: prevPage }
}}>
<Button
className="w-[150px]"
disabled={prevPage < 1}
>
<ArrowLeft className="mr-2" size={16} />
Previous Page
</Button>
</Link>
<Link href={{
pathname,
query: { page: nextPage }
}}>
<Button
className="w-[150px]"
>
Next Page
<ArrowRight className="ml-2" size={16} />
</Button>
</Link>
</div>
)
}
+1 -5
View File
@@ -33,9 +33,5 @@ export default function PersonBirthdayCell(
return (
<div>
<DatePicker date={person.birthDate} onSelect={handleEdit}/>
</div>
)
return <DatePicker date={person.birthDate} onSelect={handleEdit}/>
}
+45
View File
@@ -0,0 +1,45 @@
import { IPerson } from "@/types/person";
import React, { useState } from "react";
import { DatePicker } from "../ui/datepicker";
import { updatePerson } from "@/handlers/api/people.handler";
import { formatDate } from "@/helpers/date.helper";
import { Button } from "../ui/button";
import { Switch } from "../ui/switch";
import { Label } from "../ui/label";
interface IProps {
person: IPerson;
onUpdate?: (person: IPerson) => any;
}
export default function PersonHideCell({ person, onUpdate }: IProps) {
const [loading, setLoading] = useState(false);
const [hidden, setHidden] = useState(person.isHidden);
const handleEdit = (hidden: boolean) => {
setLoading(true);
return updatePerson(person.id, {
isHidden: hidden,
})
.then(() => {
setHidden(hidden);
onUpdate?.({
...person,
isHidden: hidden,
})
})
.catch(() => {})
.finally(() => {
setLoading(false);
});
};
return (
<button
onClick={() => handleEdit(!hidden)}
className="bg-gray-200 rounded-lg px-2 py-1 text-sm border"
>
{hidden ? "Show" : "Hide"}
</button>
);
}
+32 -9
View File
@@ -3,6 +3,9 @@ import React, { useState } from "react";
import { Avatar } from "../ui/avatar";
import { updatePerson } from "@/handlers/api/people.handler";
import { PersonMergeDropdown } from "./PersonMergeDropdown";
import PersonBirthdayCell from "./PersonBirthdayCell";
import PersonHideCell from "./PersonHideCell";
import clsx from "clsx";
interface IProps {
person: IPerson;
@@ -27,25 +30,44 @@ export default function PersonItem({ person }: IProps) {
};
return (
<div className="flex items-center gap-2">
<Avatar
className="w-12 h-12"
src={person.thumbnailPath}
alt={person.name}
/>
<div
className={clsx("flex flex-col rounded-lg pb-2 border border-2 border-transparent items-center gap-2", {
"opacity-50": formData.isHidden,
"border border-blue-500": formData.name,
})}
>
<div className="relative w-full h-auto">
<Avatar
className="w-full h-auto rounded-lg"
src={person.thumbnailPath}
alt={person.name}
/>
<div className="absolute top-2 right-2">
<PersonHideCell
person={person}
onUpdate={(newData) => {
setFormData(newData);
}}
/>
</div>
</div>
{!editMode ? (
<h2
className="text-lg font-semibold"
className="text-lg text-center font-semibold"
onClick={() => {
setEditMode((prev) => !prev);
}}
>
{formData.name ? formData.name : <span className="text-gray-400">Unknown</span>}
{formData.name ? (
formData.name
) : (
<span className="text-gray-400">Unknown</span>
)}
</h2>
) : (
<input
type="text"
className="text-lg font-semibold"
className="text-lg font-semibold text-center"
defaultValue={formData.name}
autoFocus
onChange={(e) => {
@@ -56,6 +78,7 @@ export default function PersonItem({ person }: IProps) {
/>
)}
<PersonMergeDropdown person={person} />
<PersonBirthdayCell person={person} />
</div>
);
}
@@ -90,7 +90,6 @@ export function PersonMergeDropdown(
})
};
console.log(searchedPeople);
return (
<div className="flex items-center space-x-4">
<Popover open={open} onOpenChange={setOpen}>
+1 -1
View File
@@ -32,7 +32,7 @@ export function DatePicker(
<Button
variant={"outline"}
className={cn(
"w-[280px] justify-start text-left font-normal",
"justify-start text-left font-normal",
!date && "text-muted-foreground"
)}
>
+24
View File
@@ -0,0 +1,24 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.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 }
+2
View File
@@ -2,6 +2,7 @@ import React from "react";
export default function Loader() {
return (
<div className="flex justify-center items-center py-4">
<div role="status">
<svg
aria-hidden="true"
@@ -21,5 +22,6 @@ export default function Loader() {
</svg>
<span className="sr-only">Loading...</span>
</div>
</div>
);
}
+121
View File
@@ -0,0 +1,121 @@
import * as React from "react"
import {
ChevronLeftIcon,
ChevronRightIcon,
DotsHorizontalIcon,
} from "@radix-ui/react-icons"
import { cn } from "@/lib/utils"
import { ButtonProps, buttonVariants } from "@/components/ui/button"
const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
<nav
role="navigation"
aria-label="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
)
Pagination.displayName = "Pagination"
const PaginationContent = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
className={cn("flex flex-row items-center gap-1", className)}
{...props}
/>
))
PaginationContent.displayName = "PaginationContent"
const PaginationItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ className, ...props }, ref) => (
<li ref={ref} className={cn("", className)} {...props} />
))
PaginationItem.displayName = "PaginationItem"
type PaginationLinkProps = {
isActive?: boolean
} & Pick<ButtonProps, "size"> &
React.ComponentProps<"a">
const PaginationLink = ({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) => (
<a
aria-current={isActive ? "page" : undefined}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className
)}
{...props}
/>
)
PaginationLink.displayName = "PaginationLink"
const PaginationPrevious = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 pl-2.5", className)}
{...props}
>
<ChevronLeftIcon className="h-4 w-4" />
<span>Previous</span>
</PaginationLink>
)
PaginationPrevious.displayName = "PaginationPrevious"
const PaginationNext = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 pr-2.5", className)}
{...props}
>
<span>Next</span>
<ChevronRightIcon className="h-4 w-4" />
</PaginationLink>
)
PaginationNext.displayName = "PaginationNext"
const PaginationEllipsis = ({
className,
...props
}: React.ComponentProps<"span">) => (
<span
aria-hidden
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<DotsHorizontalIcon className="h-4 w-4" />
<span className="sr-only">More pages</span>
</span>
)
PaginationEllipsis.displayName = "PaginationEllipsis"
export {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
}
+46
View File
@@ -0,0 +1,46 @@
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }
+27
View File
@@ -0,0 +1,27 @@
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }
+92 -81
View File
@@ -1,29 +1,41 @@
import * as React from "react"
import * as React from "react";
import { cn } from "@/lib/utils"
import { ColumnDef, flexRender, getCoreRowModel, getFilteredRowModel, getPaginationRowModel, getSortedRowModel, TableOptions, useReactTable } from "@tanstack/react-table"
import { cn } from "@/lib/utils";
import {
ColumnDef,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
TableOptions,
useReactTable,
} from "@tanstack/react-table";
import { ScrollArea } from "./scroll-area";
const TableRoot = React.forwardRef<
HTMLTableElement,
React.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>
))
TableRoot.displayName = "Table"
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
));
TableRoot.displayName = "Table";
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
<thead
ref={ref}
className={cn("[&_tr]:border-b sticky top-0 bg-secondary", className)}
{...props}
/>
));
TableHeader.displayName = "TableHeader";
const TableBody = React.forwardRef<
HTMLTableSectionElement,
@@ -34,8 +46,8 @@ const TableBody = React.forwardRef<
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
))
TableBody.displayName = "TableBody"
));
TableBody.displayName = "TableBody";
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
@@ -49,8 +61,8 @@ const TableFooter = React.forwardRef<
)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
));
TableFooter.displayName = "TableFooter";
const TableRow = React.forwardRef<
HTMLTableRowElement,
@@ -64,8 +76,8 @@ const TableRow = React.forwardRef<
)}
{...props}
/>
))
TableRow.displayName = "TableRow"
));
TableRow.displayName = "TableRow";
const TableHead = React.forwardRef<
HTMLTableCellElement,
@@ -79,8 +91,8 @@ const TableHead = React.forwardRef<
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
));
TableHead.displayName = "TableHead";
const TableCell = React.forwardRef<
HTMLTableCellElement,
@@ -94,8 +106,8 @@ const TableCell = React.forwardRef<
)}
{...props}
/>
))
TableCell.displayName = "TableCell"
));
TableCell.displayName = "TableCell";
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
@@ -106,16 +118,16 @@ const TableCaption = React.forwardRef<
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
));
TableCaption.displayName = "TableCaption";
interface ITableProps<T> extends Partial<TableOptions<T>> {
columns: ColumnDef<T>[]
data: T[]
columns: ColumnDef<T>[];
data: T[];
className?: string;
}
const Table = <T, >(props: ITableProps<T>) => {
const Table = <T,>(props: ITableProps<T>) => {
const table = useReactTable({
...props,
getCoreRowModel: getCoreRowModel(),
@@ -124,56 +136,55 @@ const Table = <T, >(props: ITableProps<T>) => {
});
return (
<TableRoot>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
)
})}
<ScrollArea className={cn("rounded-md border overflow-y-auto")}>
<TableRoot>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody className={cn("overflow-y-auto", props.className)}>
{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>
))}
</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
colSpan={props.columns.length}
className="h-24 text-center"
>
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</TableRoot>
)
}
))
) : (
<TableRow>
<TableCell
colSpan={props.columns.length}
className="h-24 text-center"
>
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</TableRoot>
</ScrollArea>
);
};
export {
Table,
@@ -185,4 +196,4 @@ export {
TableRow,
TableCell,
TableCaption,
}
};
+5 -2
View File
@@ -3,8 +3,11 @@ import { cleanUpPerson } from "@/helpers/person.helper";
import API from "@/lib/api"
import { IPeopleListResponse, IPerson } from "@/types/person"
export const listPeople = (): Promise<IPeopleListResponse> => {
return API.get(LIST_PEOPLE_PATH).then((response) => {
interface IPersonListFilters {
page: string | number;
}
export const listPeople = (filters: IPersonListFilters): Promise<IPeopleListResponse> => {
return API.get(LIST_PEOPLE_PATH, filters).then((response) => {
return {
...response,
people: response.people.map(cleanUpPerson),
+2 -1
View File
@@ -8,7 +8,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const targetUrl = `${ENV.IMMICH_URL}/api${proxyPath}`;
console.log('targetUrl:', req.method)
console.log('targetUrl:', targetUrl)
try {
const response = await fetch(targetUrl, {
@@ -17,6 +17,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
'x-api-key': ENV.IMMICH_API_KEY,
'Content-Type': 'application/json',
},
body: req.method !== 'GET' && req.method !== 'HEAD' ? JSON.stringify(req.body) : null,
})
+2
View File
@@ -1,3 +1,5 @@
@import url("https://fonts.googleapis.com/css2?family=Inter");
@tailwind base;
@tailwind components;
@tailwind utilities;
+1 -1
View File
@@ -12,7 +12,7 @@ const config = {
prefix: "",
theme: {
fontFamily: {
sans: ["var(--font-sans)", ...fontFamily.sans],
},
container: {
center: true,