diff --git a/bun.lockb b/bun.lockb index 7208272..2b98866 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 81d9afb..65334d7 100644 --- a/package.json +++ b/package.json @@ -9,11 +9,16 @@ "lint": "next lint" }, "dependencies": { + "@radix-ui/react-avatar": "^1.1.0", "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-slot": "^1.1.0", + "@tanstack/react-table": "^8.20.1", + "@types/qs": "^6.9.15", + "axios": "^1.7.4", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "next": "14.2.5", + "qs": "^6.13.0", "react": "^18", "react-dom": "^18", "sass": "^1.77.8", diff --git a/src/components/people/PeopleList.tsx b/src/components/people/PeopleList.tsx new file mode 100644 index 0000000..372708d --- /dev/null +++ b/src/components/people/PeopleList.tsx @@ -0,0 +1,66 @@ +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"; +import PersonItem from "./PersonItem"; +import { ColumnDef } from "@tanstack/react-table"; +import { Table } from "../ui/table"; +import PersonBirthdayCell from "./PersonBirthdayCell"; + +const columns: ColumnDef[] = [ + { + header: "Name", + accessorKey: "name", + cell: ({ row }) => , + }, + { + header: "Birth Date", + accessorKey: "birthDate", + cell: ({ row }) => , + }, + { + header: "Updated At", + accessorKey: "updatedAt", + cell: ({ row }) => {new Date( + row.original.updatedAt + ).toDateString()}, + }, +]; + + + +export default function PeopleList() { + const [people, setPeople] = useState([]); + const [loading, setLoading] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + + const fetchData = async () => { + return listPeople() + .then((response) => { + setPeople(response.people); + }) + .catch((error) => { + setErrorMessage(error.message); + }) + .finally(() => { + setLoading(false); + }); + }; + + useEffect(() => { + fetchData(); + }, []); + + if (loading) return ; + + if (errorMessage) return
{errorMessage}
; + + return ( +
+
+ columns={columns} data={people} /> +
+
+ ); +} diff --git a/src/components/people/PersonBirthdayCell.tsx b/src/components/people/PersonBirthdayCell.tsx new file mode 100644 index 0000000..8537d0b --- /dev/null +++ b/src/components/people/PersonBirthdayCell.tsx @@ -0,0 +1,20 @@ +import { IPerson } from '@/types/person' +import React from 'react' + +interface IProps { + person: IPerson +} + +export default function PersonBirthdayCell( + { person }: IProps +) { + return ( +
+ {person.birthDate ? ( + {person.birthDate.toDateString()} + ) : ( + Unknown + )} +
+ ) +} diff --git a/src/components/people/PersonItem.tsx b/src/components/people/PersonItem.tsx new file mode 100644 index 0000000..5e9d714 --- /dev/null +++ b/src/components/people/PersonItem.tsx @@ -0,0 +1,59 @@ +import { IPerson } from "@/types/person"; +import React, { useState } from "react"; +import { Avatar } from "../ui/avatar"; +import { updatePerson } from "@/handlers/api/people.handler"; + +interface IProps { + person: IPerson; +} +export default function PersonItem({ person }: IProps) { + const [editMode, setEditMode] = useState(false); + const [loading, setLoading] = useState(false); + const [formData, setFormData] = useState(person); + + const handleEdit = () => { + setLoading(true); + return updatePerson(person.id, { + name: formData.name, + }) + .then(() => { + setEditMode(!editMode); + }) + .catch(() => {}) + .finally(() => { + setLoading(false); + }); + }; + + return ( +
+ + {!editMode ? ( +

{ + setEditMode((prev) => !prev); + }} + > + {formData.name ? formData.name : Unknown} +

+ ) : ( + { + setFormData({ ...formData, name: e.target.value }); + }} + disabled={loading} + onBlur={handleEdit} + /> + )} +
+ ); +} diff --git a/src/components/ui/avatar.tsx b/src/components/ui/avatar.tsx new file mode 100644 index 0000000..c9d7e66 --- /dev/null +++ b/src/components/ui/avatar.tsx @@ -0,0 +1,63 @@ +import * as React from "react" +import * as AvatarPrimitive from "@radix-ui/react-avatar" + +import { cn } from "@/lib/utils" + +const AvatarRoot = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarRoot.displayName = AvatarPrimitive.Root.displayName + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarImage.displayName = AvatarPrimitive.Image.displayName + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName + +interface AvatarProps { + src: string + alt: string + className?: string +} + +const Avatar = (props: AvatarProps) => { + const { src, alt } = props + return ( + + {src && } + {alt && !src && {alt[0]}} + + ) +} +export { Avatar, AvatarRoot, AvatarImage, AvatarFallback } diff --git a/src/components/ui/loader.tsx b/src/components/ui/loader.tsx new file mode 100644 index 0000000..26936a9 --- /dev/null +++ b/src/components/ui/loader.tsx @@ -0,0 +1,25 @@ +import React from "react"; + +export default function Loader() { + return ( +
+ + Loading... +
+ ); +} diff --git a/src/components/ui/table.tsx b/src/components/ui/table.tsx new file mode 100644 index 0000000..46fe0d1 --- /dev/null +++ b/src/components/ui/table.tsx @@ -0,0 +1,188 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" +import { ColumnDef, flexRender, getCoreRowModel, getFilteredRowModel, getPaginationRowModel, getSortedRowModel, TableOptions, useReactTable } from "@tanstack/react-table" + +const TableRoot = React.forwardRef< + HTMLTableElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+ + +)) +TableRoot.displayName = "Table" + +const TableHeader = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableHeader.displayName = "TableHeader" + +const TableBody = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableBody.displayName = "TableBody" + +const TableFooter = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + tr]:last:border-b-0", + className + )} + {...props} + /> +)) +TableFooter.displayName = "TableFooter" + +const TableRow = React.forwardRef< + HTMLTableRowElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableRow.displayName = "TableRow" + +const TableHead = React.forwardRef< + HTMLTableCellElement, + React.ThHTMLAttributes +>(({ className, ...props }, ref) => ( +
[role=checkbox]]:translate-y-[2px]", + className + )} + {...props} + /> +)) +TableHead.displayName = "TableHead" + +const TableCell = React.forwardRef< + HTMLTableCellElement, + React.TdHTMLAttributes +>(({ className, ...props }, ref) => ( + [role=checkbox]]:translate-y-[2px]", + className + )} + {...props} + /> +)) +TableCell.displayName = "TableCell" + +const TableCaption = React.forwardRef< + HTMLTableCaptionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +TableCaption.displayName = "TableCaption" + +interface ITableProps extends Partial> { + columns: ColumnDef[] + data: T[] +} + +const Table = (props: ITableProps) => { + + const table = useReactTable({ + ...props, + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getSortedRowModel: getSortedRowModel(), + }); + + return ( + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ) + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + )) + ) : ( + + + No results. + + + )} + + + ) +} + +export { + Table, + TableRoot, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +} diff --git a/src/config/environment.ts b/src/config/environment.ts new file mode 100644 index 0000000..aa34b85 --- /dev/null +++ b/src/config/environment.ts @@ -0,0 +1,4 @@ +export const ENV = { + IMMICH_URL: process.env.NEXT_PUBLIC_IMMICH_URL as string, + IMMICH_API_KEY: process.env.IMMICH_API_KEY as string +} \ No newline at end of file diff --git a/src/config/routes.ts b/src/config/routes.ts new file mode 100644 index 0000000..e6d18c0 --- /dev/null +++ b/src/config/routes.ts @@ -0,0 +1,5 @@ +export const BASE_ENDPOINT = '/api/immich-proxy'; + +export const LIST_PEOPLE_PATH = BASE_ENDPOINT + "/people"; +export const PERSON_THUBNAIL_PATH = (id: string) => BASE_ENDPOINT + "/thumbnail/" + id; +export const UPDATE_PERSON_PATH = (id: string) => BASE_ENDPOINT + "/people/" + id; \ No newline at end of file diff --git a/src/handlers/api/people.handler.ts b/src/handlers/api/people.handler.ts new file mode 100644 index 0000000..b33b6cd --- /dev/null +++ b/src/handlers/api/people.handler.ts @@ -0,0 +1,17 @@ +import { LIST_PEOPLE_PATH, UPDATE_PERSON_PATH } from "@/config/routes" +import { cleanUpPerson } from "@/helpers/person.helper"; +import API from "@/lib/api" +import { IPeopleListResponse, IPerson } from "@/types/person" + +export const listPeople = (): Promise => { + return API.get(LIST_PEOPLE_PATH).then((response) => { + return { + ...response, + people: response.people.map(cleanUpPerson), + } + }); +} + +export const updatePerson = (id: string, data: Partial) => { + return API.put(UPDATE_PERSON_PATH(id), data) +} \ No newline at end of file diff --git a/src/helpers/person.helper.ts b/src/helpers/person.helper.ts new file mode 100644 index 0000000..9a89d7d --- /dev/null +++ b/src/helpers/person.helper.ts @@ -0,0 +1,11 @@ +import { PERSON_THUBNAIL_PATH } from "@/config/routes" +import { IPerson } from "@/types/person" + +export const cleanUpPerson = (person: IPerson): IPerson => { + return { + ...person, + thumbnailPath: PERSON_THUBNAIL_PATH(person.id), + birthDate: person.birthDate ? new Date(person.birthDate) : null, + updatedAt: new Date(person.updatedAt), + } +} \ No newline at end of file diff --git a/src/lib/api.ts b/src/lib/api.ts new file mode 100644 index 0000000..4b64e33 --- /dev/null +++ b/src/lib/api.ts @@ -0,0 +1,148 @@ +import type { AxiosError, AxiosRequestConfig, Method } from 'axios' +import axios from 'axios' +import qs from 'qs' + + +// const handleErrors = (error: AxiosError | any) => { +// console.error('error_message', error.message) +// if (error.message === 'canceled') { +// const cancelledError = { +// code: 'ERR_CANCELED', +// message: 'Request canceled', +// } +// return Promise.reject(cancelledError) +// } +// let errorObject = {} +// if (error.response) { +// const { status } = error.response +// if (status === 401) { +// errorObject = { +// ...error.response.data, +// message: error.response.data.message, +// } +// if (typeof window !== 'undefined' && window.location) { +// // return resetSession().then(() => { +// // window.location.href = '/auth/signin' +// // return Promise.reject(errorObject) +// // }) +// } +// } else if (status === 403) { +// errorObject = { +// ...error.response.data, +// message: +// 'You do not have access to this page. Please contact your administrator!', +// } +// if (typeof window !== 'undefined' && window.location) { +// // deleteLocalHashData() +// window.location.href = '/404' +// return Promise.reject(errorObject) +// } +// } else if (status === 422) { +// errorObject = { +// ...error.response.data, +// message: 'Unable to process this request, please try again later', +// } +// } else { +// errorObject = { +// ...error.response.data, +// } +// } +// return Promise.reject(errorObject) +// } +// errorObject = { +// message: 'Network Error. Please check your connection and try again!', +// } +// return Promise.reject(errorObject) +// } + +export default class API { + + + static setBaseURL(url: string) { + if (url) { + axios.defaults.baseURL = url + } + } + + static setHeader(headers: any = {}): any { + axios.defaults.headers.common = { + ...axios.defaults.headers.common, + ...headers, + } + return { + ...axios.defaults.headers.common, + ...headers, + } + } + + static headers(contentType: string = 'application/json'): any { + return { + ...axios.defaults.headers.common, + Accept: 'application/json', + 'Content-Type': contentType, + } + } + + static get( + route: string, + params: any = {}, + headers: any = {}, + otherOptions: any = {} + ): Promise { + return this.xhr(route, params, 'GET', undefined, headers, otherOptions) + } + + static put(route: string, params: any = {}): Promise { + return this.xhr(route, params, 'PUT') + } + + static post(route: string, params: any = {}): Promise { + return this.xhr(route, params, 'POST') + } + + static postWithForm(route: string, params: any = {}): Promise { + return this.xhr( + route, + qs.stringify(params), + 'POST', + 'application/x-www-form-urlencoded' + ) + } + + static postWithFiles(route: string, params: any = {}): Promise { + return this.xhr(route, params, 'POST', 'multipart/form-data') + } + + static putWithFiles(route: string, params: any = {}): Promise { + return this.xhr(route, params, 'PUT', 'multipart/form-data') + } + + static delete(route: string, params: any = {}): Promise { + return this.xhr(route, params, 'DELETE') + } + + static patch(route: string, params: any): Promise { + return this.xhr(route, params, 'PATCH') + } + + static xhr( + route: string, + params: any, + verb: Method, + contentType: string = 'application/json', + headers: any = {}, + otherOptions: any = {} + ) { + const dataOption: AxiosRequestConfig = {} + if (params && verb === 'GET') { + dataOption.params = params + } else { + dataOption.data = params + } + const options = { method: verb, url: route, ...dataOption, ...otherOptions } + options.headers = Object.assign(API.headers(contentType), headers) + return axios(options) + .then((responseJson) => responseJson.data) + // .catch(handleErrors) + } +} diff --git a/src/pages/api/immich-proxy/[...path].ts b/src/pages/api/immich-proxy/[...path].ts new file mode 100644 index 0000000..ade6621 --- /dev/null +++ b/src/pages/api/immich-proxy/[...path].ts @@ -0,0 +1,47 @@ +// pages/api/proxy/[...path].ts + +import { ENV } from '@/config/environment'; +import { NextApiRequest, NextApiResponse } from 'next' + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const proxyPath = req.url?.replace('/api/immich-proxy', ''); + + const targetUrl = `${ENV.IMMICH_URL}/api${proxyPath}`; + + console.log('targetUrl:', req.method) + + try { + const response = await fetch(targetUrl, { + method: req.method, + headers: { + 'x-api-key': ENV.IMMICH_API_KEY, + 'Content-Type': 'application/json', + }, + body: req.method !== 'GET' && req.method !== 'HEAD' ? JSON.stringify(req.body) : null, + }) + + + // Forward the status code + res.status(response.status) + + // Forward the headers + const responseHeaders = response.headers + responseHeaders.forEach((value, key) => { + res.setHeader(key, value) + }) + + // Stream the response body + const reader = response.body?.getReader() + if (reader) { + while (true) { + const { done, value } = await reader.read() + if (done) break + res.write(value) + } + } + res.end() + } catch (error) { + console.error('Proxy error:', error) + res.status(500).json({ error: 'Internal Server Error' }) + } +} \ No newline at end of file diff --git a/src/pages/api/immich-proxy/thumbnail/[id].ts b/src/pages/api/immich-proxy/thumbnail/[id].ts new file mode 100644 index 0000000..669be33 --- /dev/null +++ b/src/pages/api/immich-proxy/thumbnail/[id].ts @@ -0,0 +1,47 @@ +// pages/api/proxy.js + +import { ENV } from '@/config/environment'; +import { NextApiRequest, NextApiResponse } from 'next' + +export const config = { + api: { + bodyParser: false, + }, +} + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'GET') { + return res.status(405).json({ message: 'Method Not Allowed' }) + } + + const { id } = req.query; + const targetUrl = `${ENV.IMMICH_URL}/api/people/${id}/thumbnail`; + + try { + // Forward the request to the target API + const response = await fetch(targetUrl, { + method: 'GET', + headers: { + 'Content-Type': 'application/octet-stream', + 'x-api-key': ENV.IMMICH_API_KEY, + }, + }) + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`) + } + + // Get the image data from the response + const imageBuffer = await response.arrayBuffer() + + // Set the appropriate headers for the image response + res.setHeader('Content-Type', response.headers.get('Content-Type') || 'image/png') + res.setHeader('Content-Length', imageBuffer.byteLength) + + // Send the image data + res.send(Buffer.from(imageBuffer)) + } catch (error) { + console.error('Error:', error) + res.status(500).json({ message: 'Internal Server Error' }) + } +} \ No newline at end of file diff --git a/src/pages/index.tsx b/src/pages/index.tsx index 8da11df..9eb810c 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -1,13 +1,10 @@ -import Image from "next/image"; import { Inter } from "next/font/google"; -import { Button } from "@/components/ui/button"; +import PeopleList from "@/components/people/PeopleList"; const inter = Inter({ subsets: ["latin"] }); export default function Home() { return ( - + ); } diff --git a/src/types/common.d.ts b/src/types/common.d.ts new file mode 100644 index 0000000..306e4b5 --- /dev/null +++ b/src/types/common.d.ts @@ -0,0 +1,5 @@ +export interface IListData { + hasNextPage: boolean, + total: number, + hidden: number, +} \ No newline at end of file diff --git a/src/types/person.d.ts b/src/types/person.d.ts new file mode 100644 index 0000000..8c711a3 --- /dev/null +++ b/src/types/person.d.ts @@ -0,0 +1,12 @@ +export interface IPerson { + id: string; + name: string; + birthDate: Date | null; + thumbnailPath: string; + isHidden: boolean; + updatedAt: Date; +} + +interface IPeopleListResponse extends IListData{ + people: IPerson[] +} \ No newline at end of file