diff --git a/.github/workflows/ci-item-service.yaml b/.github/workflows/ci-item-service.yaml index 4f8bcbf8..4719e3b5 100644 --- a/.github/workflows/ci-item-service.yaml +++ b/.github/workflows/ci-item-service.yaml @@ -88,7 +88,7 @@ jobs: uses: actions/checkout@v4 - name: Start containers - run: docker compose -f container-compose.test.yaml up -d + run: docker compose -f docker-compose.test.yaml up -d - name: Test with coverage run: docker exec nshm-item-test-api-1 bun test --coverage diff --git a/services/item/.containerignore b/services/item/.dockerignore similarity index 100% rename from services/item/.containerignore rename to services/item/.dockerignore diff --git a/services/item/.env.example b/services/item/.env.example index 8f1893ad..f8a0dc3b 100644 --- a/services/item/.env.example +++ b/services/item/.env.example @@ -1,5 +1,9 @@ # Refer to `src/global.d.ts` for explanations of each environment variable. PORT= +JWT_SECRET= MONGO_DB_URI= MONGO_DB_NAME= -JWT_SECRET= +S3_ACCESS_KEY_ID= +S3_SECRET_ACCESS_KEY= +S3_REGION= +S3_BUCKET_NAME= diff --git a/services/item/.gitignore b/services/item/.gitignore index 83631f81..064b798a 100644 --- a/services/item/.gitignore +++ b/services/item/.gitignore @@ -1,3 +1,5 @@ dist/ node_modules/ +resources/ +uploads/ *.tsbuildinfo diff --git a/services/item/Containerfile b/services/item/Dockerfile similarity index 72% rename from services/item/Containerfile rename to services/item/Dockerfile index 2e36dcd5..e9b17388 100644 --- a/services/item/Containerfile +++ b/services/item/Dockerfile @@ -1,4 +1,4 @@ -FROM docker.io/oven/bun AS build +FROM oven/bun AS build WORKDIR /app @@ -10,7 +10,7 @@ COPY . . RUN bun run build -FROM docker.io/oven/bun AS production +FROM oven/bun AS production WORKDIR /app diff --git a/services/item/Containerfile.dev b/services/item/Dockerfile.dev similarity index 83% rename from services/item/Containerfile.dev rename to services/item/Dockerfile.dev index 22f4c1de..1b9148f0 100644 --- a/services/item/Containerfile.dev +++ b/services/item/Dockerfile.dev @@ -1,4 +1,4 @@ -FROM docker.io/oven/bun +FROM oven/bun WORKDIR /app diff --git a/services/item/Containerfile.test b/services/item/Dockerfile.test similarity index 83% rename from services/item/Containerfile.test rename to services/item/Dockerfile.test index 11475eaf..33da867e 100644 --- a/services/item/Containerfile.test +++ b/services/item/Dockerfile.test @@ -1,4 +1,4 @@ -FROM docker.io/oven/bun +FROM oven/bun WORKDIR /app diff --git a/services/item/bun.lockb b/services/item/bun.lockb index 6ed7a66a..15bcc87f 100755 Binary files a/services/item/bun.lockb and b/services/item/bun.lockb differ diff --git a/services/item/container-compose.test.yaml b/services/item/docker-compose.test.yaml similarity index 81% rename from services/item/container-compose.test.yaml rename to services/item/docker-compose.test.yaml index ecbe99e9..6da61482 100644 --- a/services/item/container-compose.test.yaml +++ b/services/item/docker-compose.test.yaml @@ -4,8 +4,9 @@ services: api: build: context: . - dockerfile: Containerfile.test + dockerfile: Dockerfile.test environment: + - JWT_SECRET=nshm-item-service - MONGO_DB_URI=mongodb://mongo:27017 - MONGO_DB_NAME=nshm depends_on: @@ -14,7 +15,7 @@ services: - bridge mongo: - image: docker.io/library/mongo + image: mongo environment: - MONGO_INITDB_DATABASE=nshm volumes: diff --git a/services/item/package.json b/services/item/package.json index 140e9c2d..c8c4c45e 100644 --- a/services/item/package.json +++ b/services/item/package.json @@ -8,6 +8,7 @@ "start": "bun dist/index.js" }, "dependencies": { + "@aws-sdk/client-s3": "^3.665.0", "bun-compression": "^0.0.4", "hono": "^4.6.3", "hono-rate-limiter": "^0.4.0", diff --git a/services/item/src/global.d.ts b/services/item/src/global.d.ts index bd7ffadd..52fd1fd8 100644 --- a/services/item/src/global.d.ts +++ b/services/item/src/global.d.ts @@ -6,6 +6,12 @@ declare module "bun" { */ PORT?: number; + /** + * The secret key for JWT decoding. + * Required. + */ + JWT_SECRET: string; + /** * The URI of the MongoDB database. * Format: mongodb://:@: @@ -20,9 +26,27 @@ declare module "bun" { MONGO_DB_NAME: string; /** - * The secret key for JWT decoding. + * The access key ID for the S3 bucket. * Required. */ - JWT_SECRET: string; + S3_ACCESS_KEY_ID: string; + + /** + * The secret access key for the S3 bucket. + * Required. + */ + S3_SECRET_ACCESS_KEY: string; + + /** + * The name of the S3 bucket. + * Required. + */ + S3_BUCKET_NAME: string; + + /** + * The region of the S3 bucket. + * Required. + */ + S3_REGION: string; } } diff --git a/services/item/src/items/controller.ts b/services/item/src/items/controller.ts index bac8a887..b555e066 100644 --- a/services/item/src/items/controller.ts +++ b/services/item/src/items/controller.ts @@ -29,21 +29,35 @@ itemsController.get( }, ); +const fileSchema = z.custom((data) => { + return ( + data instanceof File && + ["image/jpeg", "image/png", "image/webp", "image/avif"].includes( + data.type, + ) && + data.size <= 5 * 1024 * 1024 + ); +}); + itemsController.post( "/", auth(true), validator( - "json", + "form", z.object({ name: z.string().min(1).max(50), description: z.string().min(1).max(500), price: z.coerce.number().positive(), - photo_urls: z.array(z.string().url()).max(5), + photos: z + .array(fileSchema) + .max(5) + .or(fileSchema.transform((file) => [file])) + .default([]), }), ), async (c) => { - const dto = c.req.valid("json"); - const item = await itemsService.createItem(dto, c.var.user); - return c.json(item, 201); + const form = c.req.valid("form"); + const result = await itemsService.createItem(form, c.var.user); + return c.json(result, 201); }, ); diff --git a/services/item/src/items/repository.ts b/services/item/src/items/repository.ts index f441d151..c2de11da 100644 --- a/services/item/src/items/repository.ts +++ b/services/item/src/items/repository.ts @@ -1,6 +1,6 @@ import { ItemStatus, type Item, type SingleItem } from "@/types"; import { itemsCollection } from "@/utils/db"; -import type { Filter, FindOptions } from "mongodb"; +import { UUID, type Filter, type FindOptions } from "mongodb"; /** * Data access layer for items. @@ -34,7 +34,7 @@ type InsertOneDto = { async function insertOne(dto: InsertOneDto) { const { insertedId } = await itemsCollection.insertOne({ ...dto, - id: crypto.randomUUID(), + id: new UUID().toString(), type: "single", status: ItemStatus.FOR_SALE, created_at: new Date().toISOString(), diff --git a/services/item/src/items/service.ts b/services/item/src/items/service.ts index 4914cd6d..2b64b3c6 100644 --- a/services/item/src/items/service.ts +++ b/services/item/src/items/service.ts @@ -1,4 +1,5 @@ import { ItemStatus, type Account } from "@/types"; +import { photoManager } from "@/utils/photo-manager"; import { ObjectId } from "mongodb"; import { itemsRepository } from "./repository"; @@ -47,12 +48,17 @@ type CreateItemDto = { name: string; description: string; price: number; - photo_urls: string[]; + photos: File[]; }; async function createItem(dto: CreateItemDto, user: Account) { + const photoUrls = await Promise.all(dto.photos.map(photoManager.save)); + const item = await itemsRepository.insertOne({ - ...dto, + name: dto.name, + description: dto.description, + price: dto.price, + photo_urls: photoUrls, seller: { id: user.id, nickname: user.nickname, diff --git a/services/item/src/utils/photo-manager/index.ts b/services/item/src/utils/photo-manager/index.ts new file mode 100644 index 00000000..ae057ca0 --- /dev/null +++ b/services/item/src/utils/photo-manager/index.ts @@ -0,0 +1,3 @@ +import { PhotoManagerFactory } from "./photo-manager-factory"; + +export const photoManager = PhotoManagerFactory.createPhotoManager(); diff --git a/services/item/src/utils/photo-manager/local-photo-manager.ts b/services/item/src/utils/photo-manager/local-photo-manager.ts new file mode 100644 index 00000000..50866b2c --- /dev/null +++ b/services/item/src/utils/photo-manager/local-photo-manager.ts @@ -0,0 +1,27 @@ +import { existsSync, mkdirSync } from "fs"; +import { rm, writeFile } from "fs/promises"; +import { join } from "path"; +import type { PhotoManager } from "./photo-manager-interface"; + +/** + * Store photos in local directory. + */ +export class LocalPhotoManager implements PhotoManager { + private static UPLOAD_DIR = "./uploads"; + + public constructor() { + if (!existsSync(LocalPhotoManager.UPLOAD_DIR)) { + mkdirSync(LocalPhotoManager.UPLOAD_DIR); + } + } + + public async save(photo: File) { + const filePath = join(LocalPhotoManager.UPLOAD_DIR, photo.name); + await writeFile(filePath, new Uint8Array(await photo.arrayBuffer())); + return filePath; + } + + public async remove(photoUrl: string) { + await rm(photoUrl); + } +} diff --git a/services/item/src/utils/photo-manager/photo-manager-factory.ts b/services/item/src/utils/photo-manager/photo-manager-factory.ts new file mode 100644 index 00000000..e6ffa0be --- /dev/null +++ b/services/item/src/utils/photo-manager/photo-manager-factory.ts @@ -0,0 +1,16 @@ +import { LocalPhotoManager } from "./local-photo-manager"; +import type { PhotoManager } from "./photo-manager-interface"; +import { S3PhotoManager } from "./s3-photo-manager"; + +/** + * Create a photo manager based on the running environment. + */ +export class PhotoManagerFactory { + static createPhotoManager(): PhotoManager { + if (Bun.env.NODE_ENV === "production") { + return new S3PhotoManager(); + } + + return new LocalPhotoManager(); + } +} diff --git a/services/item/src/utils/photo-manager/photo-manager-interface.ts b/services/item/src/utils/photo-manager/photo-manager-interface.ts new file mode 100644 index 00000000..96ea6868 --- /dev/null +++ b/services/item/src/utils/photo-manager/photo-manager-interface.ts @@ -0,0 +1,4 @@ +export interface PhotoManager { + save: (photo: File) => Promise; + remove: (photoUrl: string) => Promise; +} diff --git a/services/item/src/utils/photo-manager/s3-photo-manager.ts b/services/item/src/utils/photo-manager/s3-photo-manager.ts new file mode 100644 index 00000000..da80502b --- /dev/null +++ b/services/item/src/utils/photo-manager/s3-photo-manager.ts @@ -0,0 +1,48 @@ +import { + DeleteObjectCommand, + PutObjectCommand, + S3Client, +} from "@aws-sdk/client-s3"; +import type { PhotoManager } from "./photo-manager-interface"; + +/** + * Store photos in Amazon S3. + */ +export class S3PhotoManager implements PhotoManager { + private static client = new S3Client({ + credentials: { + accessKeyId: Bun.env.S3_ACCESS_KEY_ID, + secretAccessKey: Bun.env.S3_SECRET_ACCESS_KEY, + }, + region: Bun.env.S3_REGION, + }); + + private static BASE_URL = `https://${Bun.env.S3_BUCKET_NAME}.s3.${Bun.env.S3_REGION}.amazonaws.com`; + + private static UPLOAD_DIR = "item-photos"; + + public async save(photo: File) { + const [, extension] = photo.name.split("."); + + const key = `${S3PhotoManager.UPLOAD_DIR}/${crypto.randomUUID()}.${extension}`; + + await S3PhotoManager.client.send( + new PutObjectCommand({ + Bucket: Bun.env.S3_BUCKET_NAME, + Key: key, + Body: new Uint8Array(await photo.arrayBuffer()), + }), + ); + + return `${S3PhotoManager.BASE_URL}/${key}`; + } + + public async remove(photoUrl: string): Promise { + await S3PhotoManager.client.send( + new DeleteObjectCommand({ + Bucket: Bun.env.S3_BUCKET_NAME, + Key: photoUrl.replace(`${S3PhotoManager.BASE_URL}/`, ""), + }), + ); + } +} diff --git a/services/item/tests/items/create-item.test.ts b/services/item/tests/items/create-item.test.ts new file mode 100644 index 00000000..a2f55df5 --- /dev/null +++ b/services/item/tests/items/create-item.test.ts @@ -0,0 +1,254 @@ +import { ItemStatus, type Item } from "@/types"; +import { itemsCollection } from "@/utils/db"; +import { expect, it } from "bun:test"; +import { request } from "../utils"; + +type ExpectedResponse = Item; + +const ORIGINAL_COUNT = 31; +const me = { + id: 1, + nickname: "test", + avatar_url: "https://example.com/test.jpg", +}; +const MY_JWT = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwibmlja25hbWUiOiJ0ZXN0IiwiYXZhdGFyX3VybCI6Imh0dHBzOi8vZXhhbXBsZS5jb20vdGVzdC5qcGciLCJpYXQiOjE3MjgxOTc0OTUsIm5iZiI6MTcyODE5NzQ5NSwiZXhwIjozNDU2Mzk0OTgxfQ.IWELaGDOCNYDOei6KQxMSm4FOjCiGKXgMZqhWMLnMx8"; + +it("creates a new item", async () => { + // Without photo. + + const formData = new FormData(); + formData.append("name", "Test item name"); + formData.append("description", "Test item description"); + formData.append("price", "100"); + + const res1 = await request( + "/", + { + method: "POST", + body: formData, + headers: { + Cookie: `access_token=${MY_JWT}`, + }, + }, + "form", + ); + const body1 = (await res1.json()) as ExpectedResponse; + + expect(res1.status).toEqual(201); + expect(body1).toMatchObject({ + id: expect.any(String), + type: "single", + seller: me, + name: "Test item name", + description: "Test item description", + price: 100, + photo_urls: [], + status: ItemStatus.FOR_SALE, + created_at: expect.any(String), + deleted_at: null, + }); + + const currentCount1 = await itemsCollection.estimatedDocumentCount(); + expect(currentCount1).toEqual(ORIGINAL_COUNT + 1); + + // With exactly one photo. + + formData.append( + "photos", + new File(["foo"], "test1.png", { type: "image/png" }), + "test1.png", + ); + + const res2 = await request( + "/", + { + method: "POST", + body: formData, + headers: { + Cookie: `access_token=${MY_JWT}`, + }, + }, + "form", + ); + const body2 = (await res2.json()) as ExpectedResponse; + + expect(res2.status).toEqual(201); + expect(body2).toMatchObject({ + id: expect.any(String), + type: "single", + seller: me, + name: "Test item name", + description: "Test item description", + price: 100, + photo_urls: ["uploads/test1.png"], + status: ItemStatus.FOR_SALE, + created_at: expect.any(String), + deleted_at: null, + }); + + const currentCount2 = await itemsCollection.estimatedDocumentCount(); + expect(currentCount2).toEqual(ORIGINAL_COUNT + 2); + + // With multiple photos. + + formData.append( + "photos", + new File(["foo"], "test2.jpg", { type: "image/jpeg" }), + "test2.jpg", + ); + + const res3 = await request( + "/", + { + method: "POST", + body: formData, + headers: { + Cookie: `access_token=${MY_JWT}`, + }, + }, + "form", + ); + const body3 = (await res3.json()) as ExpectedResponse; + + expect(res3.status).toEqual(201); + expect(body3).toMatchObject({ + id: expect.any(String), + type: "single", + seller: me, + name: "Test item name", + description: "Test item description", + price: 100, + photo_urls: ["uploads/test1.png", "uploads/test2.jpg"], + status: ItemStatus.FOR_SALE, + created_at: expect.any(String), + deleted_at: null, + }); + + const currentCount3 = await itemsCollection.estimatedDocumentCount(); + expect(currentCount3).toEqual(ORIGINAL_COUNT + 3); + + await itemsCollection.deleteMany({ name: "Test item name" }); +}); + +it("returns 400 when the MIME type is wrong", async () => { + const formData = new FormData(); + formData.append("name", "Test item name"); + formData.append("description", "Test item description"); + formData.append("price", "100"); + formData.append( + "photos", + new File(["foo"], "test1.txt", { type: "text/plain" }), + "test1.txt", + ); + + const res = await request( + "/", + { + method: "POST", + body: formData, + headers: { + Cookie: `access_token=${MY_JWT}`, + }, + }, + "form", + ); + + expect(res.status).toEqual(400); +}); + +it("returns 400 when the file size is too large", async () => { + const formData = new FormData(); + formData.append("name", "Test item name"); + formData.append("description", "Test item description"); + formData.append("price", "100"); + formData.append( + "photos", + new File(["1".repeat(6 * 1024 * 1024)], "test1.png", { type: "image/png" }), + "test1.png", + ); + + const res = await request( + "/", + { + method: "POST", + body: formData, + headers: { + Cookie: `access_token=${MY_JWT}`, + }, + }, + "form", + ); + + expect(res.status).toEqual(400); +}); + +it("returns 400 when the request body is invalid", async () => { + const formData = new FormData(); + formData.append("name", ""); + formData.append("description", ""); + formData.append("price", "-1"); + + const res = await request( + "/", + { + method: "POST", + body: formData, + headers: { + Cookie: `access_token=${MY_JWT}`, + }, + }, + "form", + ); + + expect(res.status).toEqual(400); +}); + +it("returns 401 when the user is not authenticated", async () => { + const formData = new FormData(); + formData.append("name", "Test item name"); + formData.append("description", "Test item description"); + formData.append("price", "100"); + formData.append( + "photos", + new File(["foo"], "test1.png", { type: "image/png" }), + "test1.png", + ); + + const res = await request( + "/", + { + method: "POST", + body: formData, + }, + "form", + ); + + expect(res.status).toEqual(401); +}); + +it("returns 401 when the JWT is invalid", async () => { + const formData = new FormData(); + formData.append("name", "Test item name"); + formData.append("description", "Test item description"); + formData.append("price", "100"); + formData.append( + "photos", + new File(["foo"], "test1.png", { type: "image/png" }), + "test1.png", + ); + + const res = await request( + "/", + { + method: "POST", + body: formData, + headers: { + Cookie: "access_token=invalid", + }, + }, + "form", + ); + + expect(res.status).toEqual(401); +}); diff --git a/services/item/tests/utils.ts b/services/item/tests/utils.ts index 2908c12f..9aeb60ca 100644 --- a/services/item/tests/utils.ts +++ b/services/item/tests/utils.ts @@ -3,15 +3,31 @@ import app from "@/index"; /** * Fake a request to the server. */ -export async function request(endpoint: string, init: RequestInit = {}) { - const res = await app.request(endpoint, init, { - server: { - requestIP: () => ({ - address: "localhost", - port: 8080, - }), +export async function request( + endpoint: string, + init: RequestInit = {}, + contentType: "json" | "form" = "json", +) { + const res = await app.request( + endpoint, + { + ...init, + headers: { + ...(contentType === "json" + ? { "Content-Type": "application/json" } + : {}), + ...init.headers, + }, }, - }); + { + server: { + requestIP: () => ({ + address: "localhost", + port: 8080, + }), + }, + }, + ); return res; } diff --git a/services/web/package.json b/services/web/package.json index fd2ee3cb..05ba84ab 100644 --- a/services/web/package.json +++ b/services/web/package.json @@ -10,6 +10,7 @@ "lint": "tsc && eslint . --cache --cache-location node_modules/.cache/eslint/.eslint-cache" }, "dependencies": { + "@formkit/auto-animate": "^0.8.2", "@radix-ui/react-alert-dialog": "^1.1.2", "@radix-ui/react-avatar": "^1.1.1", "@radix-ui/react-dialog": "^1.1.2", @@ -25,6 +26,7 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "tailwind-merge": "^2.5.3", + "tailwind-scrollbar": "^3.1.0", "tailwindcss-animate": "^1.0.7", "valibot": "1.0.0-beta.0" }, diff --git a/services/web/pnpm-lock.yaml b/services/web/pnpm-lock.yaml index c712457b..9908ec0a 100644 --- a/services/web/pnpm-lock.yaml +++ b/services/web/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@formkit/auto-animate': + specifier: ^0.8.2 + version: 0.8.2 '@radix-ui/react-alert-dialog': specifier: ^1.1.2 version: 1.1.2(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -53,6 +56,9 @@ importers: tailwind-merge: specifier: ^2.5.3 version: 2.5.3 + tailwind-scrollbar: + specifier: ^3.1.0 + version: 3.1.0(tailwindcss@3.4.13) tailwindcss-animate: specifier: ^1.0.7 version: 1.0.7(tailwindcss@3.4.13) @@ -293,6 +299,9 @@ packages: '@floating-ui/utils@0.2.8': resolution: {integrity: sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==} + '@formkit/auto-animate@0.8.2': + resolution: {integrity: sha512-SwPWfeRa5veb1hOIBMdzI+73te5puUBHmqqaF1Bu7FjvxlYSz/kJcZKSa9Cg60zL0uRNeJL2SbRxV6Jp6Q1nFQ==} + '@humanfs/core@0.19.0': resolution: {integrity: sha512-2cbWIHbZVEweE853g8jymffCA+NCMiuqeECeBBLm8dg2oFdjuGJhgN4UAbI+6v0CKbbhvtXA4qV8YR5Ji86nmw==} engines: {node: '>=18.18.0'} @@ -2491,6 +2500,12 @@ packages: tailwind-merge@2.5.3: resolution: {integrity: sha512-d9ZolCAIzom1nf/5p4LdD5zvjmgSxY0BGgdSvmXIoMYAiPdAW/dSpP7joCDYFY7r/HkEa2qmPtkgsu0xjQeQtw==} + tailwind-scrollbar@3.1.0: + resolution: {integrity: sha512-pmrtDIZeHyu2idTejfV59SbaJyvp1VRjYxAjZBH0jnyrPRo6HL1kD5Glz8VPagasqr6oAx6M05+Tuw429Z8jxg==} + engines: {node: '>=12.13.0'} + peerDependencies: + tailwindcss: 3.x + tailwindcss-animate@1.0.7: resolution: {integrity: sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==} peerDependencies: @@ -2926,6 +2941,8 @@ snapshots: '@floating-ui/utils@0.2.8': {} + '@formkit/auto-animate@0.8.2': {} + '@humanfs/core@0.19.0': {} '@humanfs/node@0.16.5': @@ -5311,6 +5328,10 @@ snapshots: tailwind-merge@2.5.3: {} + tailwind-scrollbar@3.1.0(tailwindcss@3.4.13): + dependencies: + tailwindcss: 3.4.13 + tailwindcss-animate@1.0.7(tailwindcss@3.4.13): dependencies: tailwindcss: 3.4.13 diff --git a/services/web/src/app/api/items/route.ts b/services/web/src/app/api/items/route.ts index d3a16d8d..96e775e2 100644 --- a/services/web/src/app/api/items/route.ts +++ b/services/web/src/app/api/items/route.ts @@ -1,3 +1,4 @@ +import { ItemStatus } from "@/types"; import { NextRequest, NextResponse } from "next/server"; const mockAccounts = [ @@ -388,3 +389,21 @@ export async function GET(request: NextRequest) { { status: 200 }, ); } + +export async function POST() { + return NextResponse.json( + { + id: crypto.randomUUID(), + type: "single", + seller: { + id: 1, + nickname: "Johnny", + avatar_url: "https://avatars.githubusercontent.com/u/78269445?v=4", + }, + status: ItemStatus.FOR_SALE, + created_at: new Date().toISOString(), + deleted_at: null, + }, + { status: 201 }, + ); +} diff --git a/services/web/src/app/page.tsx b/services/web/src/app/page.tsx index 01a93bfd..96ca7ecc 100644 --- a/services/web/src/app/page.tsx +++ b/services/web/src/app/page.tsx @@ -1,4 +1,5 @@ import { ItemCardList } from "@/components/item"; +import { PublishItem } from "@/components/item/publish-item"; import { ItemStatus } from "@/types"; import type { Metadata } from "next"; @@ -9,11 +10,14 @@ export const metadata: Metadata = { export default function Home() { return (
-
-

Marketplace

-

- We found something you might be interested in! -

+
+
+

Marketplace

+

+ We found something you might be interested in! +

+
+
diff --git a/services/web/src/components/item/publish-item.tsx b/services/web/src/components/item/publish-item.tsx new file mode 100644 index 00000000..6dffd60f --- /dev/null +++ b/services/web/src/components/item/publish-item.tsx @@ -0,0 +1,273 @@ +"use client"; + +import { useToast } from "@/hooks/use-toast"; +import type { SingleItem } from "@/types"; +import { ClientRequester } from "@/utils/requester/client"; +import { useAutoAnimate } from "@formkit/auto-animate/react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { Loader2Icon, PlusIcon, XIcon } from "lucide-react"; +import Image from "next/image"; +import { useState, type ChangeEvent, type FormEvent } from "react"; +import * as v from "valibot"; +import { Button } from "../ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "../ui/dialog"; +import { Input } from "../ui/input"; +import { Label } from "../ui/label"; +import { Textarea } from "../ui/textarea"; + +const formSchema = v.object({ + name: v.pipe(v.string(), v.minLength(1), v.maxLength(50)), + description: v.pipe(v.string(), v.minLength(1), v.maxLength(500)), + price: v.pipe(v.string(), v.transform(Number), v.minValue(0.01)), + photos: v.pipe( + v.array( + v.pipe( + v.file(), + v.mimeType(["image/jpeg", "image/png", "image/webp", "image/avif"]), + v.maxSize(1024 * 1024 * 5, "Photo size should not exceed 5MB."), + ), + ), + v.maxLength(5, "You can only upload up to 5 photos."), + ), +}); + +export function PublishItem() { + const [isDialogOpen, setIsDialogOpen] = useState(false); + + const [photoObjects, setPhotoObjects] = useState< + { id: number; file: File; previewUrl: string }[] + >([]); + + const removePhotoObject = (id: number) => + setPhotoObjects((photoObjects) => + photoObjects.filter((photoObject) => photoObject.id !== id), + ); + + const [photoListRef] = useAutoAnimate(); + + const handleSelectPhotos = (event: ChangeEvent) => { + const newPhotos = event.target.files; + + if (!newPhotos || newPhotos.length === 0) { + return; + } + + const nextPhotoObjects = [...photoObjects]; + + for (const photo of Array.from(newPhotos)) { + if (nextPhotoObjects.length >= 5) { + break; + } + + if (photo.size > 1024 * 1024 * 5) { + toast({ + variant: "destructive", + title: "Photo size exceeds 5MB", + description: `"${photo.name}" (${Math.floor((photo.size * 10) / 1024 / 1024) / 10}MB) is too big for us to handle. 🥲 Please select another smaller one.`, + }); + continue; + } + + nextPhotoObjects.push({ + id: Math.random(), + file: photo, + previewUrl: URL.createObjectURL(photo), + }); + } + + setPhotoObjects(nextPhotoObjects); + }; + + const { toast } = useToast(); + + const queryClient = useQueryClient(); + + const { mutate, isPending } = useMutation({ + mutationFn: async (event: FormEvent) => { + event.preventDefault(); + + const target = event.target as HTMLFormElement; + const formData = Object.fromEntries(new FormData(target)); + + const validFormData = v.parse(formSchema, { + ...formData, + photos: photoObjects.map(({ file }) => file), + }); + + const data = new FormData(); + data.append("name", validFormData.name); + data.append("description", validFormData.description); + data.append("price", validFormData.price.toString()); + validFormData.photos.forEach((photo) => data.append("photos", photo)); + + return await new ClientRequester().postForm("/items", data); + }, + onSuccess: () => { + setIsDialogOpen(false); + setPhotoObjects([]); + + queryClient.invalidateQueries({ queryKey: ["items"] }); + + toast({ + title: "Item published", + description: "Your item has been published successfully.", + }); + }, + onError: (error) => { + toast({ + variant: "destructive", + title: "Failed to publish item", + description: error.message, + }); + }, + }); + + return ( + + + + + + + Publish an item + + Got something you no longer need? Publish it here and let others + know! + + +
+
+ + +
+
+ +