diff --git a/.env.example b/.env.example index 6337b9aed..dea3f489d 100644 --- a/.env.example +++ b/.env.example @@ -6,7 +6,20 @@ DOCKER_DATABASE_NAME="startui" DOCKER_DATABASE_USERNAME="startui" DOCKER_DATABASE_PASSWORD="startui" +DOCKER_MINIO_API_PORT="9000" +DOCKER_MINIO_UI_PORT="9001" +DOCKER_MINIO_USERNAME="minioadmin" +DOCKER_MINIO_PASSWORD="minioadmin" + +# S3 +S3_ENDPOINT="http://127.0.0.1:${DOCKER_MINIO_API_PORT}" +S3_BUCKET_NAME="default" +S3_ACCESS_KEY_ID="startui-access-key" +S3_SECRET_ACCESS_KEY="startui-secret-key" +S3_REGION="default" + # PUBLIC CONFIG +VITE_S3_BUCKET_PUBLIC_URL="${S3_ENDPOINT}/${S3_BUCKET_NAME}" VITE_BASE_URL="http://localhost:${VITE_PORT}" # Use the following environment variables to show the environment name. VITE_ENV_NAME="LOCAL" diff --git a/docker-compose.yml b/docker-compose.yml index 2a5dad1b8..022137310 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,13 +4,55 @@ services: env_file: - .env ports: - - '${DOCKER_DATABASE_PORT:-5432}:5432' + - "${DOCKER_DATABASE_PORT:-5432}:5432" environment: POSTGRES_DB: $DOCKER_DATABASE_NAME POSTGRES_USER: $DOCKER_DATABASE_USERNAME POSTGRES_PASSWORD: $DOCKER_DATABASE_PASSWORD healthcheck: - test: ['CMD-SHELL', 'pg_isready -U $DOCKER_DATABASE_NAME'] + test: ["CMD-SHELL", "pg_isready -U $DOCKER_DATABASE_NAME"] interval: 10s timeout: 5s retries: 5 + + minio: + image: minio/minio:RELEASE.2025-07-23T15-54-02Z-cpuv1 + ports: + - "${DOCKER_MINIO_API_PORT}:9000" + - "${DOCKER_MINIO_UI_PORT}:9001" + environment: + - MINIO_ROOT_USER=${DOCKER_MINIO_USERNAME} + - MINIO_ROOT_PASSWORD=${DOCKER_MINIO_PASSWORD} + volumes: + - minio:/data/minio + command: minio server /data/minio --console-address :${DOCKER_MINIO_UI_PORT} + healthcheck: + test: ["CMD", "mc", "ready", "local"] + interval: 5s + timeout: 5s + retries: 5 + + createbucket: + image: minio/mc:RELEASE.2025-08-13T08-35-41Z-cpuv1 + profiles: + - init + depends_on: + minio: + condition: service_healthy + entrypoint: [""] + command: [ + "sh", + "-c", + ' + mc alias set default http://minio:9000 "${DOCKER_MINIO_USERNAME}" "${DOCKER_MINIO_PASSWORD}"; + mc admin user add default ${S3_ACCESS_KEY_ID} ${S3_SECRET_ACCESS_KEY}; + mc admin policy attach default readwrite --user ${S3_ACCESS_KEY_ID}; + mc mb --ignore-existing default/${S3_BUCKET_NAME} 2>/dev/null; + mc anonymous set download default/${S3_BUCKET_NAME}; + echo ''Bucket configuration completed successfully''; + ', + ] + restart: "no" + +volumes: + minio: diff --git a/package.json b/package.json index 886c8ce7a..00e19abfc 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "test:ui": "vitest", "e2e": "dotenv -- cross-env playwright test", "e2e:ui": "dotenv -- cross-env playwright test --ui", - "dk:init": "docker compose up -d", + "dk:init": "docker compose --profile init up -d", "dk:start": "docker compose start", "dk:stop": "docker compose stop", "dk:clear": "docker compose down --volumes", @@ -45,6 +45,8 @@ "@base-ui-components/react": "1.0.0-beta.1", "@bearstudio/ui-state": "1.0.2", "@better-auth/expo": "1.3.27", + "@better-upload/client": "3.0.2", + "@better-upload/server": "3.0.2", "@fontsource-variable/inter": "5.2.8", "@headlessui/react": "2.2.9", "@hookform/resolvers": "5.2.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 75678fea9..7bbc1c5f5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,12 @@ importers: '@better-auth/expo': specifier: 1.3.27 version: 1.3.27(45a7aa97bc666058466a19360edccb13) + '@better-upload/client': + specifier: 3.0.2 + version: 3.0.2(react@19.2.0) + '@better-upload/server': + specifier: 3.0.2 + version: 3.0.2 '@fontsource-variable/inter': specifier: 5.2.8 version: 5.2.8 @@ -118,7 +124,7 @@ importers: version: 0.4.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0) nitro: specifier: npm:nitro-nightly@3.0.1-20251023-125324-a6f9b591 - version: nitro-nightly@3.0.1-20251023-125324-a6f9b591(chokidar@4.0.3)(ioredis@5.8.0)(vite@7.1.11(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.19.3)(yaml@2.8.1)) + version: nitro-nightly@3.0.1-20251023-125324-a6f9b591(aws4fetch@1.0.20)(chokidar@4.0.3)(ioredis@5.8.0)(vite@7.1.11(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.19.3)(yaml@2.8.1)) nodemailer: specifier: 7.0.9 version: 7.0.9 @@ -439,6 +445,10 @@ packages: resolution: {integrity: sha512-FfEM25hLEs4LoXsLXQ/q6X6L4JmKkKkbVFpKD4mwfVHtRVQG6QxJiCPcrkcPISquiy6esbwK2eh64TWbiD60cg==} engines: {node: '>=18.0.0'} + '@aws-sdk/types@3.922.0': + resolution: {integrity: sha512-eLA6XjVobAUAMivvM7DBL79mnHyrm+32TkXNWZua5mnxF+6kQCfblKKJvxMZLGosO53/Ex46ogim8IY5Nbqv2w==} + engines: {node: '>=18.0.0'} + '@aws-sdk/util-arn-parser@3.893.0': resolution: {integrity: sha512-u8H4f2Zsi19DGnwj5FSZzDMhytYF/bCh37vAtBsn3cNDL3YG578X5oc+wSX54pM3tOxS+NY7tvOAo52SW7koUA==} engines: {node: '>=18.0.0'} @@ -1073,6 +1083,14 @@ packages: '@better-fetch/fetch@1.1.18': resolution: {integrity: sha512-rEFOE1MYIsBmoMJtQbl32PGHHXuG2hDxvEd7rUHE0vCBoFQVSDqaVs9hkZEtHCxRoY+CljXKFCOuJ8uxqw1LcA==} + '@better-upload/client@3.0.2': + resolution: {integrity: sha512-wNdRmee0/X55MK+TugZ6EvuSTN1IAowmNzRWm5aH7ymSZQmdWBCo0SS5mj7A0Fw65VYbr2EtLzwM1KoE5Mliyw==} + peerDependencies: + react: '*' + + '@better-upload/server@3.0.2': + resolution: {integrity: sha512-NNYAsVgjZvwXVtxRdrg6ARwGxAL6aNQof7UAdrxJrOJ9unNeZQplF1YyNt/BXYGLDDZ4I97E+cqfkaomF0RXwA==} + '@code-inspector/core@1.2.10': resolution: {integrity: sha512-xTkR4oBrTlRA/S2cXTuZLttCX6+wQgUpBpEK4Ad/e9KBIUIDRne5yoxuvrdy3xkTMkURS2V4SnCTzjFcu4OELQ==} @@ -3302,6 +3320,10 @@ packages: resolution: {integrity: sha512-4lI9C8NzRPOv66FaY1LL1O/0v0aLVrq/mXP/keUa9mJOApEeae43LsLd2kZRUJw91gxOQfLIrV3OvqPgWz1YsA==} engines: {node: '>=18.0.0'} + '@smithy/types@4.8.1': + resolution: {integrity: sha512-N0Zn0OT1zc+NA+UVfkYqQzviRh5ucWwO7mBV3TmHHprMnfcJNfhlPicDkBHi0ewbh+y3evR6cNAW0Raxvb01NA==} + engines: {node: '>=18.0.0'} + '@smithy/url-parser@4.2.0': resolution: {integrity: sha512-AlBmD6Idav2ugmoAL6UtR6ItS7jU5h5RNqLMZC7QrLCoITA9NzIN3nx9GWi8g4z1pfWh2r9r96SX/jHiNwPJ9A==} engines: {node: '>=18.0.0'} @@ -4351,6 +4373,9 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} + aws4fetch@1.0.20: + resolution: {integrity: sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g==} + axe-core@4.10.3: resolution: {integrity: sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg==} engines: {node: '>=4'} @@ -5546,6 +5571,10 @@ packages: resolution: {integrity: sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==} hasBin: true + fast-xml-parser@5.3.2: + resolution: {integrity: sha512-n8v8b6p4Z1sMgqRmqLJm3awW4NX7NkaKPfb3uJIBTSH7Pdvufi3PQ3/lJLQrvxcMYl7JI2jnDO90siPEpD8JBA==} + hasBin: true + fastq@1.16.0: resolution: {integrity: sha512-ifCoaXsDrsdkWTtiNJX5uzHDsrck5TzfKKDcuFFTIrrc/BS076qgEIfoIy1VeZqViznfKiysPYTh/QeHtnIsYA==} @@ -8961,7 +8990,7 @@ snapshots: '@aws-crypto/sha256-js': 5.2.0 '@aws-crypto/supports-web-crypto': 5.2.0 '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.901.0 + '@aws-sdk/types': 3.922.0 '@aws-sdk/util-locate-window': 3.893.0 '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 @@ -8969,7 +8998,7 @@ snapshots: '@aws-crypto/sha256-js@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.901.0 + '@aws-sdk/types': 3.922.0 tslib: 2.8.1 '@aws-crypto/supports-web-crypto@5.2.0': @@ -8978,7 +9007,7 @@ snapshots: '@aws-crypto/util@5.2.0': dependencies: - '@aws-sdk/types': 3.901.0 + '@aws-sdk/types': 3.922.0 '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 @@ -9302,6 +9331,11 @@ snapshots: '@smithy/types': 4.6.0 tslib: 2.8.1 + '@aws-sdk/types@3.922.0': + dependencies: + '@smithy/types': 4.8.1 + tslib: 2.8.1 + '@aws-sdk/util-arn-parser@3.893.0': dependencies: tslib: 2.8.1 @@ -10090,6 +10124,16 @@ snapshots: '@better-fetch/fetch@1.1.18': {} + '@better-upload/client@3.0.2(react@19.2.0)': + dependencies: + react: 19.2.0 + + '@better-upload/server@3.0.2': + dependencies: + aws4fetch: 1.0.20 + fast-xml-parser: 5.3.2 + zod: 4.1.12 + '@code-inspector/core@1.2.10': dependencies: '@vue/compiler-dom': 3.5.17 @@ -12722,6 +12766,10 @@ snapshots: dependencies: tslib: 2.8.1 + '@smithy/types@4.8.1': + dependencies: + tslib: 2.8.1 + '@smithy/url-parser@4.2.0': dependencies: '@smithy/querystring-parser': 4.2.0 @@ -14091,6 +14139,8 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 + aws4fetch@1.0.20: {} + axe-core@4.10.3: {} babel-dead-code-elimination@1.0.10: @@ -15602,6 +15652,10 @@ snapshots: dependencies: strnum: 2.1.1 + fast-xml-parser@5.3.2: + dependencies: + strnum: 2.1.1 + fastq@1.16.0: dependencies: reusify: 1.0.4 @@ -17102,7 +17156,7 @@ snapshots: nice-try@1.0.5: {} - nitro-nightly@3.0.1-20251023-125324-a6f9b591(chokidar@4.0.3)(ioredis@5.8.0)(vite@7.1.11(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.19.3)(yaml@2.8.1)): + nitro-nightly@3.0.1-20251023-125324-a6f9b591(aws4fetch@1.0.20)(chokidar@4.0.3)(ioredis@5.8.0)(vite@7.1.11(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.19.3)(yaml@2.8.1)): dependencies: consola: 3.4.2 cookie-es: 2.0.0 @@ -17120,7 +17174,7 @@ snapshots: srvx: 0.8.16 undici: 7.16.0 unenv: 2.0.0-rc.21 - unstorage: 2.0.0-alpha.3(chokidar@4.0.3)(db0@0.3.4)(ioredis@5.8.0)(ofetch@1.4.1) + unstorage: 2.0.0-alpha.3(aws4fetch@1.0.20)(chokidar@4.0.3)(db0@0.3.4)(ioredis@5.8.0)(ofetch@1.4.1) optionalDependencies: vite: 7.1.11(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.19.3)(yaml@2.8.1) transitivePeerDependencies: @@ -18798,8 +18852,9 @@ snapshots: picomatch: 4.0.3 webpack-virtual-modules: 0.6.2 - unstorage@2.0.0-alpha.3(chokidar@4.0.3)(db0@0.3.4)(ioredis@5.8.0)(ofetch@1.4.1): + unstorage@2.0.0-alpha.3(aws4fetch@1.0.20)(chokidar@4.0.3)(db0@0.3.4)(ioredis@5.8.0)(ofetch@1.4.1): optionalDependencies: + aws4fetch: 1.0.20 chokidar: 4.0.3 db0: 0.3.4 ioredis: 5.8.0 diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 437b0e446..b16544e93 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -94,6 +94,7 @@ model Book { genre Genre? @relation(fields: [genreId], references: [id]) genreId String publisher String? + coverId String? @@unique([title, author]) @@map("book") diff --git a/src/components/ui/upload-button.stories.tsx b/src/components/ui/upload-button.stories.tsx new file mode 100644 index 000000000..e1d58b427 --- /dev/null +++ b/src/components/ui/upload-button.stories.tsx @@ -0,0 +1,73 @@ +import type { Meta } from '@storybook/react-vite'; +import { UploadIcon } from 'lucide-react'; + +import { UploadButton } from '@/components/ui/upload-button'; + +export default { + title: 'Upload Button', +} satisfies Meta; + +export const Default = () => { + return ( + console.log('uploaded file', file)} + /> + ); +}; + +export const WithChildren = () => { + return ( +
+ console.log('uploaded file', file)} + > + + Upload a new file + + + console.log('uploaded file', file)} + > + Upload a new file + + + + console.log('uploaded file', file)} + > + Upload a new file + +
+ ); +}; + +export const Disabled = () => { + return ( +
+ console.log('uploaded file', file)} + > + + Upload a new file + + + console.log('uploaded file', file)} + > + Upload a new file + + +
+ ); +}; diff --git a/src/components/ui/upload-button.tsx b/src/components/ui/upload-button.tsx new file mode 100644 index 000000000..87fb11138 --- /dev/null +++ b/src/components/ui/upload-button.tsx @@ -0,0 +1,117 @@ +import { + type ClientUploadError, + type FileUploadInfo, + uploadFile, + type UploadStatus, +} from '@better-upload/client'; +import { useIsMutating, useMutation } from '@tanstack/react-query'; +import { UploadIcon } from 'lucide-react'; +import { + type ChangeEvent, + type ComponentProps, + type ReactElement, + useId, + useRef, +} from 'react'; + +import { cn } from '@/lib/tailwind/utils'; + +import { Button } from '@/components/ui/button'; + +import type { UploadRoutes } from '@/routes/api/upload'; + +export type UploadButtonProps = { + uploadRoute: UploadRoutes; + /** + * Called only if the file was uploaded successfully. + */ + onUploadSuccess?: (file: FileUploadInfo<'complete'>) => void; + onUploadStateChange?: ( + file: FileUploadInfo + ) => void; + onError?: (error: Error | ClientUploadError) => void; + inputProps?: ComponentProps<'input'>; + icon?: ReactElement; +} & Omit, 'onChange'>; + +export const useIsUploadingFiles = (uploadRoute: UploadRoutes) => + useIsMutating({ + mutationKey: ['fileUpload', uploadRoute], + }) > 0; + +export const UploadButton = ({ + children, + inputProps, + onUploadStateChange, + onUploadSuccess, + onError, + disabled, + icon, + uploadRoute, + ...rest +}: UploadButtonProps) => { + const innerId = useId(); + + const uploadMutation = useMutation({ + mutationKey: ['fileUpload', uploadRoute], + mutationFn: async (file: File) => { + return uploadFile({ + file, + route: uploadRoute, + onFileStateChange: ({ file }) => { + onUploadStateChange?.(file); + }, + }); + }, + onSuccess: ({ file }) => { + onUploadSuccess?.(file); + }, + onError: (error) => { + onError?.(error); + }, + }); + + const handleFileChange = (event: ChangeEvent) => { + const file = event.target.files?.[0]; + if (file) { + uploadMutation.mutate(file); + } + }; + + const inputRef = useRef(null); + + return ( + <> + + { + handleFileChange(onChangeEvent); + inputProps?.onChange?.(onChangeEvent); + }} + /> + + ); +}; diff --git a/src/env/client.ts b/src/env/client.ts index 1400af82f..ca6508ba7 100644 --- a/src/env/client.ts +++ b/src/env/client.ts @@ -42,6 +42,13 @@ export const envClient = createEnv({ .string() .optional() .transform((value) => value ?? (isDev ? 'gold' : 'plum')), + VITE_S3_BUCKET_PUBLIC_URL: z + .url() + .optional() + .transform( + (value) => + value ?? (isDev ? 'http://127.0.0.1:9000/default' : undefined) + ), }, runtimeEnv: { ...envMetaOrProcess, diff --git a/src/env/server.ts b/src/env/server.ts index 9d8b0af68..c319230e5 100644 --- a/src/env/server.ts +++ b/src/env/server.ts @@ -33,6 +33,15 @@ export const envServer = createEnv({ .enum(['true', 'false']) .prefault(isProd ? 'false' : 'true') .transform((value) => value === 'true'), + DOCKER_MINIO_API_PORT: z.string().default('9000'), + DOCKER_MINIO_UI_PORT: z.string().default('9001'), + DOCKER_MINIO_USERNAME: z.string(), + DOCKER_MINIO_PASSWORD: z.string(), + S3_ACCESS_KEY_ID: z.string(), + S3_SECRET_ACCESS_KEY: z.string(), + S3_BUCKET_NAME: z.string().default('default'), + S3_REGION: z.string().default('default'), + S3_ENDPOINT: z.string(), }, runtimeEnv: process.env, emptyStringAsUndefined: true, diff --git a/src/features/book/book-cover.tsx b/src/features/book/book-cover.tsx index 30d20535a..8dbe4d052 100644 --- a/src/features/book/book-cover.tsx +++ b/src/features/book/book-cover.tsx @@ -2,14 +2,16 @@ import { useTranslation } from 'react-i18next'; import { cn } from '@/lib/tailwind/utils'; +import { envClient } from '@/env/client'; import { Book } from '@/features/book/schema'; export const BookCover = (props: { - book: Partial>; + book: Partial>; variant?: 'default' | 'tiny'; className?: string; }) => { const { t } = useTranslation(['book']); + return (
+ {!!props.book.coverId && ( + + )}
diff --git a/src/features/book/manager/form-book-cover.tsx b/src/features/book/manager/form-book-cover.tsx index 8aaf3ce92..2906a770d 100644 --- a/src/features/book/manager/form-book-cover.tsx +++ b/src/features/book/manager/form-book-cover.tsx @@ -1,12 +1,25 @@ import { useQuery } from '@tanstack/react-query'; import { useFormContext, useWatch } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { join } from 'remeda'; import { orpc } from '@/lib/orpc/client'; +import { + FormField, + FormFieldController, + FormFieldError, +} from '@/components/form'; +import { UploadButton } from '@/components/ui/upload-button'; + import { BookCover } from '@/features/book/book-cover'; -import { FormFieldsBook } from '@/features/book/schema'; +import { + bookCoverAcceptedFileTypes, + FormFieldsBook, +} from '@/features/book/schema'; export const FormBookCover = () => { + const { t } = useTranslation(['book']); const form = useFormContext(); const genresQuery = useQuery(orpc.genre.getAll.queryOptions()); const title = useWatch({ @@ -21,14 +34,51 @@ export const FormBookCover = () => { name: 'author', control: form.control, }); + const coverId = useWatch({ + name: 'coverId', + control: form.control, + }); + const genre = genresQuery.data?.items.find((item) => item.id === genreId); + return ( - + + { + return ( + <> +
+ {t('book:manager.uploadCover')} + + + + field.onChange(file.objectInfo.key) + } + /> +
+ + + ); + }} + /> +
); }; diff --git a/src/features/book/manager/page-book-new.tsx b/src/features/book/manager/page-book-new.tsx index a80739e47..98b5856f7 100644 --- a/src/features/book/manager/page-book-new.tsx +++ b/src/features/book/manager/page-book-new.tsx @@ -13,6 +13,7 @@ import { Form } from '@/components/form'; import { PreventNavigation } from '@/components/prevent-navigation'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; +import { useIsUploadingFiles } from '@/components/ui/upload-button'; import { FormBook } from '@/features/book/manager/form-book'; import { FormBookCover } from '@/features/book/manager/form-book-cover'; @@ -36,9 +37,12 @@ export const PageBookNew = () => { author: '', genreId: '', publisher: '', + coverId: '', }, }); + const isUploadingFiles = useIsUploadingFiles('bookCover'); + const bookCreate = useMutation( orpc.book.create.mutationOptions({ onSuccess: async () => { @@ -90,6 +94,7 @@ export const PageBookNew = () => { type="submit" className="min-w-20" loading={bookCreate.isPending} + disabled={isUploadingFiles} > {t('book:manager.new.createButton.label')} diff --git a/src/features/book/manager/page-book-update.tsx b/src/features/book/manager/page-book-update.tsx index 7fd2aaa18..4be931923 100644 --- a/src/features/book/manager/page-book-update.tsx +++ b/src/features/book/manager/page-book-update.tsx @@ -13,6 +13,7 @@ import { Form } from '@/components/form'; import { PreventNavigation } from '@/components/prevent-navigation'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; +import { useIsUploadingFiles } from '@/components/ui/upload-button'; import { FormBook } from '@/features/book/manager/form-book'; import { FormBookCover } from '@/features/book/manager/form-book-cover'; @@ -39,9 +40,12 @@ export const PageBookUpdate = (props: { params: { id: string } }) => { author: bookQuery.data?.author ?? '', genreId: bookQuery.data?.genre?.id ?? null!, publisher: bookQuery.data?.publisher ?? '', + coverId: bookQuery.data?.coverId ?? '', }, }); + const isUploadingFiles = useIsUploadingFiles('bookCover'); + const bookUpdate = useMutation( orpc.book.updateById.mutationOptions({ onSuccess: async () => { @@ -92,6 +96,7 @@ export const PageBookUpdate = (props: { params: { id: string } }) => { size="sm" type="submit" className="min-w-20" + disabled={isUploadingFiles} loading={bookUpdate.isPending} > {t('book:manager.update.updateButton.label')} diff --git a/src/features/book/schema.ts b/src/features/book/schema.ts index 094a221ba..ab4070d3e 100644 --- a/src/features/book/schema.ts +++ b/src/features/book/schema.ts @@ -16,10 +16,18 @@ export const zBook = () => publisher: zu.fieldText.nullish(), createdAt: z.date(), updatedAt: z.date(), + coverId: z.string().nullish(), }); export type FormFieldsBook = z.infer>; export const zFormFieldsBook = () => zBook() - .pick({ title: true, author: true, publisher: true }) + .pick({ title: true, author: true, publisher: true, coverId: true }) .extend({ genreId: zu.fieldText.required() }); + +export const bookCoverAcceptedFileTypes = [ + 'image/png', + 'image/jpeg', + 'image/webp', + 'image/gif', +]; diff --git a/src/lib/s3/index.ts b/src/lib/s3/index.ts new file mode 100644 index 000000000..19367a774 --- /dev/null +++ b/src/lib/s3/index.ts @@ -0,0 +1,12 @@ +import { minio } from '@better-upload/server/clients'; + +import { envServer } from '@/env/server'; + +// cf. https://better-upload.com/docs/helpers-server#s3-clients to +// see all available clients. +export const uploadClient = minio({ + endpoint: envServer.S3_ENDPOINT, + accessKeyId: envServer.S3_ACCESS_KEY_ID, + secretAccessKey: envServer.S3_SECRET_ACCESS_KEY, + region: envServer.S3_REGION, +}); diff --git a/src/locales/ar/book.json b/src/locales/ar/book.json index db191ae94..d015ee232 100644 --- a/src/locales/ar/book.json +++ b/src/locales/ar/book.json @@ -58,6 +58,20 @@ "label": "حفظ" }, "updateError": "فشل في تحديث الكتاب" + }, + "uploadCover": "رفع الغلاف", + "uploadErrors": { + "NOT_AUTHENTICATED": "تحتاج إلى تسجيل الدخول لتتمكن من رفع غلاف جديد", + "UNAUTHORIZED": "غير مسموح لك بتغيير غلاف الكتاب", + "unknown": "حدث خطأ غير معروف أثناء الرفع", + "invalid_request": "طلب رفع غير صالح", + "no_files": "لم يتم اختيار ملفات للرفع", + "s3_upload": "فشل في رفع الملف إلى التخزين", + "file_too_large": "الملف كبير جداً. الحد الأقصى للحجم 100 ميجابايت", + "invalid_file_type": "نوع الملف غير صالح. مسموح فقط بصور PNG و JPEG و WebP و GIF", + "rejected": "تم رفض الرفع", + "too_many_files": "تم اختيار عدد كبير من الملفات", + "aborted": "تم إلغاء الرفع" } } } diff --git a/src/locales/en/book.json b/src/locales/en/book.json index eb3660064..cb86885d3 100644 --- a/src/locales/en/book.json +++ b/src/locales/en/book.json @@ -58,6 +58,20 @@ "label": "Save" }, "updateError": "Failed to update a book" + }, + "uploadCover": "Upload Cover", + "uploadErrors": { + "NOT_AUTHENTICATED": "You need to be authenticated to be able to upload a new cover", + "UNAUTHORIZED": "You are not allowed to change book cover", + "unknown": "An unknown error occurred during upload", + "invalid_request": "Invalid upload request", + "no_files": "No files selected for upload", + "s3_upload": "Failed to upload file to storage", + "file_too_large": "File is too large. Maximum size is 100MB", + "invalid_file_type": "Invalid file type. Only PNG, JPEG, WebP, and GIF images are allowed", + "rejected": "Upload was rejected", + "too_many_files": "Too many files selected", + "aborted": "Upload was cancelled" } } } diff --git a/src/locales/fr/book.json b/src/locales/fr/book.json index 749ae0cd3..6c96c0106 100644 --- a/src/locales/fr/book.json +++ b/src/locales/fr/book.json @@ -58,6 +58,20 @@ "label": "Sauvegarder" }, "updateError": "Échec de la modification du livre" + }, + "uploadCover": "Modifier", + "uploadErrors": { + "NOT_AUTHENTICATED": "Vous devez être authentifié pour pouvoir télécharger une nouvelle couverture", + "UNAUTHORIZED": "Vous n'êtes pas autorisé à changer la couverture du livre", + "unknown": "Une erreur inconnue s'est produite lors du téléchargement", + "invalid_request": "Demande de téléchargement invalide", + "no_files": "Aucun fichier sélectionné pour le téléchargement", + "s3_upload": "Échec du téléchargement du fichier vers le stockage", + "file_too_large": "Le fichier est trop volumineux. Taille maximale : 100 Mo", + "invalid_file_type": "Type de fichier invalide. Seules les images PNG, JPEG, WebP et GIF sont autorisées", + "rejected": "Le téléchargement a été rejeté", + "too_many_files": "Trop de fichiers sélectionnés", + "aborted": "Le téléchargement a été annulé" } } } diff --git a/src/locales/sw/book.json b/src/locales/sw/book.json index 256e41d88..edf82c013 100644 --- a/src/locales/sw/book.json +++ b/src/locales/sw/book.json @@ -58,6 +58,20 @@ "label": "Hifadhi" }, "updateError": "Imeshindikana kusasisha kitabu" + }, + "uploadCover": "Pakia Jalada", + "uploadErrors": { + "NOT_AUTHENTICATED": "Unahitaji kuwa umeingia ili uweze kupakia jalada jipya", + "UNAUTHORIZED": "Hauruhusiwi kubadilisha jalada la kitabu", + "unknown": "Hitilafu isiyojulikana imetokea wakati wa kupakia", + "invalid_request": "Ombi la kupakia si halali", + "no_files": "Hakuna faili zilizochaguliwa kwa kupakia", + "s3_upload": "Imeshindwa kupakia faili kwenye hifadhi", + "file_too_large": "Faili ni kubwa sana. Ukubwa wa juu ni 100MB", + "invalid_file_type": "Aina ya faili si halali. Picha za PNG, JPEG, WebP, na GIF tu ndizo zinazoruhusiwa", + "rejected": "Kupakia kumekataliwa", + "too_many_files": "Faili nyingi sana zimechaguliwa", + "aborted": "Kupakia kumeghairiwa" } } } diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 40ef49a78..e1babf500 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -16,6 +16,7 @@ import { Route as IndexRouteImport } from './routes/index' import { Route as ManagerIndexRouteImport } from './routes/manager/index' import { Route as LoginIndexRouteImport } from './routes/login/index' import { Route as AppIndexRouteImport } from './routes/app/index' +import { Route as ApiUploadRouteImport } from './routes/api/upload' import { Route as ManagerUsersIndexRouteImport } from './routes/manager/users/index' import { Route as ManagerDashboardIndexRouteImport } from './routes/manager/dashboard.index' import { Route as ManagerBooksIndexRouteImport } from './routes/manager/books/index' @@ -75,6 +76,11 @@ const AppIndexRoute = AppIndexRouteImport.update({ path: '/', getParentRoute: () => AppRouteRoute, } as any) +const ApiUploadRoute = ApiUploadRouteImport.update({ + id: '/api/upload', + path: '/api/upload', + getParentRoute: () => rootRouteImport, +} as any) const ManagerUsersIndexRoute = ManagerUsersIndexRouteImport.update({ id: '/users/', path: '/users/', @@ -198,6 +204,7 @@ export interface FileRoutesByFullPath { '/app': typeof AppRouteRouteWithChildren '/login': typeof LoginRouteRouteWithChildren '/manager': typeof ManagerRouteRouteWithChildren + '/api/upload': typeof ApiUploadRoute '/app/': typeof AppIndexRoute '/login/': typeof LoginIndexRoute '/manager/': typeof ManagerIndexRoute @@ -227,6 +234,7 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { '/': typeof IndexRoute + '/api/upload': typeof ApiUploadRoute '/app': typeof AppIndexRoute '/login': typeof LoginIndexRoute '/manager': typeof ManagerIndexRoute @@ -260,6 +268,7 @@ export interface FileRoutesById { '/app': typeof AppRouteRouteWithChildren '/login': typeof LoginRouteRouteWithChildren '/manager': typeof ManagerRouteRouteWithChildren + '/api/upload': typeof ApiUploadRoute '/app/': typeof AppIndexRoute '/login/': typeof LoginIndexRoute '/manager/': typeof ManagerIndexRoute @@ -294,6 +303,7 @@ export interface FileRouteTypes { | '/app' | '/login' | '/manager' + | '/api/upload' | '/app/' | '/login/' | '/manager/' @@ -323,6 +333,7 @@ export interface FileRouteTypes { fileRoutesByTo: FileRoutesByTo to: | '/' + | '/api/upload' | '/app' | '/login' | '/manager' @@ -355,6 +366,7 @@ export interface FileRouteTypes { | '/app' | '/login' | '/manager' + | '/api/upload' | '/app/' | '/login/' | '/manager/' @@ -388,6 +400,7 @@ export interface RootRouteChildren { AppRouteRoute: typeof AppRouteRouteWithChildren LoginRouteRoute: typeof LoginRouteRouteWithChildren ManagerRouteRoute: typeof ManagerRouteRouteWithChildren + ApiUploadRoute: typeof ApiUploadRoute ApiAuthSplatRoute: typeof ApiAuthSplatRoute ApiOpenapiAppRoute: typeof ApiOpenapiAppRouteWithChildren ApiOpenapiAuthRoute: typeof ApiOpenapiAuthRouteWithChildren @@ -447,6 +460,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppIndexRouteImport parentRoute: typeof AppRouteRoute } + '/api/upload': { + id: '/api/upload' + path: '/api/upload' + fullPath: '/api/upload' + preLoaderRoute: typeof ApiUploadRouteImport + parentRoute: typeof rootRouteImport + } '/manager/users/': { id: '/manager/users/' path: '/users' @@ -706,6 +726,7 @@ const rootRouteChildren: RootRouteChildren = { AppRouteRoute: AppRouteRouteWithChildren, LoginRouteRoute: LoginRouteRouteWithChildren, ManagerRouteRoute: ManagerRouteRouteWithChildren, + ApiUploadRoute: ApiUploadRoute, ApiAuthSplatRoute: ApiAuthSplatRoute, ApiOpenapiAppRoute: ApiOpenapiAppRouteWithChildren, ApiOpenapiAuthRoute: ApiOpenapiAuthRouteWithChildren, diff --git a/src/routes/api/upload.ts b/src/routes/api/upload.ts new file mode 100644 index 000000000..322748485 --- /dev/null +++ b/src/routes/api/upload.ts @@ -0,0 +1,27 @@ +import { handleRequest, type Router } from '@better-upload/server'; +import { createFileRoute } from '@tanstack/react-router'; + +import { uploadClient } from '@/lib/s3'; + +import { envServer } from '@/env/server'; +import { bookCover } from '@/server/upload/book-cover'; + +const router = { + client: uploadClient, + bucketName: envServer.S3_BUCKET_NAME, + routes: { + bookCover, + }, +} as const satisfies Router; + +// Used to type route param on UploadButton component +// This is to prevent typo issues when specifying the uploadRoute prop +export type UploadRoutes = keyof typeof router.routes; + +export const Route = createFileRoute('/api/upload')({ + server: { + handlers: { + POST: ({ request }) => handleRequest(request, router), + }, + }, +}); diff --git a/src/server/routers/book.ts b/src/server/routers/book.ts index a6bbd722f..b261ac259 100644 --- a/src/server/routers/book.ts +++ b/src/server/routers/book.ts @@ -135,6 +135,7 @@ export default { author: input.author, genreId: input.genreId ?? undefined, publisher: input.publisher, + coverId: input.coverId, }, }); } catch (error: unknown) { @@ -174,6 +175,7 @@ export default { author: input.author, genreId: input.genreId, publisher: input.publisher ?? null, + coverId: input.coverId ?? null, }, }); } catch (error: unknown) { diff --git a/src/server/upload/book-cover.ts b/src/server/upload/book-cover.ts new file mode 100644 index 000000000..6f8a7e8fe --- /dev/null +++ b/src/server/upload/book-cover.ts @@ -0,0 +1,45 @@ +import { RejectUpload, route } from '@better-upload/server'; + +import i18n from '@/lib/i18n'; + +import { bookCoverAcceptedFileTypes } from '@/features/book/schema'; +import { auth } from '@/server/auth'; + +export const bookCover = route({ + fileTypes: bookCoverAcceptedFileTypes, + maxFileSize: 1024 * 1024 * 100, // 100Mb + onBeforeUpload: async ({ req, file }) => { + const session = await auth.api.getSession(req); + if (!session?.user) { + throw new RejectUpload( + i18n.t('book:manager.uploadErrors.NOT_AUTHENTICATED') + ); + } + + // Only admins should be able to update book covers + const canUpdateBookCover = await auth.api.userHasPermission({ + body: { + userId: session.user.id, + permissions: { + book: ['create', 'update'], + }, + role: 'admin', + }, + }); + + if (!canUpdateBookCover.success) { + throw new RejectUpload(i18n.t('book:manager.uploadErrors.UNAUTHORIZED')); + } + + // normalize file extension from detected mimetype (file.type) + const fileExtension = file.type.split('/').at(-1) as string; + return { + // I think it is a good idea to create a random file id + // This allow us to invalidate cache (because the id will always be random) + // and it also prevent the user to upload a file with the same name (aka. objectKey), but different file content + objectInfo: { + key: `books/${crypto.randomUUID()}.${fileExtension}`, + }, + }; + }, +});