From aa4a78127e8c01cb384b1765daec80ab70bb6f68 Mon Sep 17 00:00:00 2001 From: hiroro-work Date: Mon, 21 Oct 2024 14:34:53 +0900 Subject: [PATCH 01/13] firebase init storage --- firebase.json | 3 +++ storage.rules | 12 ++++++++++++ 2 files changed, 15 insertions(+) create mode 100644 storage.rules diff --git a/firebase.json b/firebase.json index ce9e43f..aabe38d 100644 --- a/firebase.json +++ b/firebase.json @@ -44,5 +44,8 @@ "firestore": { "rules": "firestore.rules", "indexes": "firestore.indexes.json" + }, + "storage": { + "rules": "storage.rules" } } diff --git a/storage.rules b/storage.rules new file mode 100644 index 0000000..f08744f --- /dev/null +++ b/storage.rules @@ -0,0 +1,12 @@ +rules_version = '2'; + +// Craft rules based on data in your Firestore database +// allow write: if firestore.get( +// /databases/(default)/documents/users/$(request.auth.uid)).data.isAdmin; +service firebase.storage { + match /b/{bucket}/o { + match /{allPaths=**} { + allow read, write: if false; + } + } +} From 314511465cfcb96c39352b087159eadac6d20a36 Mon Sep 17 00:00:00 2001 From: hiroro-work Date: Mon, 21 Oct 2024 14:35:46 +0900 Subject: [PATCH 02/13] =?UTF-8?q?Storage=E3=81=AE=E3=82=BB=E3=82=AD?= =?UTF-8?q?=E3=83=A5=E3=83=AA=E3=83=86=E3=82=A3=E3=83=AB=E3=83=BC=E3=83=AB?= =?UTF-8?q?=E3=82=82=E3=83=87=E3=83=97=E3=83=AD=E3=82=A4=E3=81=A7=E3=81=8D?= =?UTF-8?q?=E3=82=8B=E3=82=88=E3=81=86=E3=81=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 157b4b1..9fe9278 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "typecheck": "tsc", "deploy:hosting": "pnpm build && firebase deploy --except functions --project default", "deploy:functions": "pnpm functions deploy:default", - "deploy:rules": "firebase deploy --only firestore:rules --project default", + "deploy:rules": "firebase deploy --only firestore:rules,storage --project default", "tsx": "tsx --env-file=scripts/.env" }, "dependencies": { From f9e09ad7c7eaeee4b1725b400b4633c4e6ac7028 Mon Sep 17 00:00:00 2001 From: hiroro-work Date: Mon, 21 Oct 2024 14:40:33 +0900 Subject: [PATCH 03/13] =?UTF-8?q?=E6=9B=B8=E7=B1=8D=E3=83=87=E3=83=BC?= =?UTF-8?q?=E3=82=BF=E3=81=AE=E5=9E=8B=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/shared/src/types/book.ts | 14 ++++++++++++++ packages/shared/src/types/index.ts | 1 + 2 files changed, 15 insertions(+) create mode 100644 packages/shared/src/types/book.ts diff --git a/packages/shared/src/types/book.ts b/packages/shared/src/types/book.ts new file mode 100644 index 0000000..b3b5466 --- /dev/null +++ b/packages/shared/src/types/book.ts @@ -0,0 +1,14 @@ +import type { Timestamp, WithId } from './firebase.js'; + +export type BookDocumentData = { + createdAt: Timestamp; + updatedAt: Timestamp; + title: string; + description: string; + image: { + path: string; + url: string; + }; +}; + +export type Book = WithId; diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 96e0147..9b8a555 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -1,2 +1,3 @@ export * from './firebase.js'; export * from './user.js'; +export * from './book.js'; From 74ee291ae407ac53fafd0c6ab8e01f772280df87 Mon Sep 17 00:00:00 2001 From: hiroro-work Date: Mon, 21 Oct 2024 14:40:51 +0900 Subject: [PATCH 04/13] =?UTF-8?q?books=E3=82=B3=E3=83=AC=E3=82=AF=E3=82=B7?= =?UTF-8?q?=E3=83=A7=E3=83=B3=E3=81=AE=E3=82=BB=E3=82=AD=E3=83=A5=E3=83=AA?= =?UTF-8?q?=E3=83=86=E3=82=A3=E3=83=AB=E3=83=BC=E3=83=AB=E3=82=92=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- firestore.rules | 10 ++++++++++ storage.rules | 14 +++++++++----- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/firestore.rules b/firestore.rules index 173105f..018c338 100644 --- a/firestore.rules +++ b/firestore.rules @@ -8,9 +8,19 @@ service cloud.firestore { function isOwn(uid) { return isSignedIn() && request.auth.uid == uid; } + function isAdmin() { + return isSignedIn() && request.auth.token.role == 'admin'; + } match /users/{uid} { allow get: if isOwn(uid); } + + match /books/{bookId} { + allow read: if true; + allow create: if isAdmin() && request.resource.data.createdAt == request.time; + allow update: if isAdmin() && request.resource.data.createdAt == resource.data.createdAt; + allow delete: if isAdmin(); + } } } diff --git a/storage.rules b/storage.rules index f08744f..210fca7 100644 --- a/storage.rules +++ b/storage.rules @@ -1,12 +1,16 @@ rules_version = '2'; -// Craft rules based on data in your Firestore database -// allow write: if firestore.get( -// /databases/(default)/documents/users/$(request.auth.uid)).data.isAdmin; service firebase.storage { + function isSignedIn() { + return request.auth != null; + } + function isAdmin() { + return isSignedIn() && request.auth.token.role == 'admin'; + } + match /b/{bucket}/o { - match /{allPaths=**} { - allow read, write: if false; + match /books/{bookId}/{allPaths=**} { + allow read, write: if isAdmin(); } } } From a23517adef5986e79afa03127f74ed9f5b6a3cc1 Mon Sep 17 00:00:00 2001 From: hiroro-work Date: Mon, 21 Oct 2024 14:43:51 +0900 Subject: [PATCH 05/13] =?UTF-8?q?=E6=9B=B8=E7=B1=8D=E3=83=87=E3=83=BC?= =?UTF-8?q?=E3=82=BF=E7=94=A8=E3=81=AE=E3=83=A6=E3=83=BC=E3=83=86=E3=82=A3?= =?UTF-8?q?=E3=83=AA=E3=83=86=E3=82=A3=E9=96=A2=E6=95=B0=E3=82=92=E4=BD=9C?= =?UTF-8?q?=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/models/book.ts | 101 ++++++++++++++++++++++++++++++++ app/utils/firebase/firestore.ts | 10 ++++ app/utils/firebase/storage.ts | 9 +++ 3 files changed, 120 insertions(+) create mode 100644 app/models/book.ts create mode 100644 app/utils/firebase/storage.ts diff --git a/app/models/book.ts b/app/models/book.ts new file mode 100644 index 0000000..8360301 --- /dev/null +++ b/app/models/book.ts @@ -0,0 +1,101 @@ +import { + collection, + deleteDoc, + doc, + getConverter, + getFirestore, + orderBy, + query, + serverTimestamp, + setDoc, + updateDoc, +} from '~/utils/firebase/firestore'; +import { + deleteObject, + getDownloadURL, + getStorage, + ref, + uploadBytes, +} from '~/utils/firebase/storage'; +import type { Book, BookDocumentData } from '@local/shared'; +import type { + DocumentReference, + QueryConstraint, + UpdateData, +} from 'firebase/firestore'; +import type { StorageReference } from 'firebase/storage'; + +export const bookConverter = getConverter(); + +export const booksRef = () => + collection(getFirestore(), 'books').withConverter(bookConverter); + +type RefOrNull = Id extends string + ? DocumentReference + : null; +export const bookRef = (id: Id) => + (id ? doc(booksRef(), id) : null) as RefOrNull; + +export const newBookRef = () => doc(booksRef()); + +export const booksQuery = (...queryConstraints: QueryConstraint[]) => + query( + booksRef(), + ...(queryConstraints.length === 0 + ? [orderBy('createdAt', 'asc')] + : queryConstraints), + ); + +export const bookStorageRef = (id: string) => + ref(getStorage(), `books/${id}`); + +export const bookImageStorageRef = (id: string) => + ref(bookStorageRef(id), 'image'); + +export const uploadImage = async (ref: StorageReference, image: File) => { + const metadata = { contentType: image.type }; + const snapshot = await uploadBytes(ref, image, metadata); + const url = await getDownloadURL(snapshot.ref); + return { snapshot, url }; +}; + +export const createBook = async ( + ref: DocumentReference, + data: Omit & { + image: File; + }, +) => { + const { id } = ref; + const { image, ...rest } = data; + const { snapshot, url } = await uploadImage( + bookImageStorageRef(id), + image, + ); + return setDoc(ref, { + ...rest, + image: { path: snapshot.ref.fullPath, url }, + createdAt: serverTimestamp(), + updatedAt: serverTimestamp(), + } as BookDocumentData); +}; + +export const updateBook = async ( + ref: DocumentReference, + data: UpdateData> & { image?: File | null }, +) => { + const { id } = ref; + const { image, ...rest } = data; + const { snapshot, url } = image + ? await uploadImage(bookImageStorageRef(id), image) + : { snapshot: null, url: null }; + return updateDoc(ref, { + ...rest, + ...(snapshot && { image: { path: snapshot.ref.fullPath, url } }), + updatedAt: serverTimestamp(), + }); +}; + +export const deleteBook = async (book: Book) => { + await deleteObject(ref(getStorage(), book.image.path)); + return deleteDoc(bookRef(book.id)); +}; diff --git a/app/utils/firebase/firestore.ts b/app/utils/firebase/firestore.ts index fd0c3cc..89862c8 100644 --- a/app/utils/firebase/firestore.ts +++ b/app/utils/firebase/firestore.ts @@ -1,8 +1,13 @@ import { collection, + deleteDoc, doc, getFirestore, + orderBy, + query, serverTimestamp as _serverTimestamp, + setDoc, + updateDoc, Timestamp, } from 'firebase/firestore'; import type { WithId } from '@local/shared'; @@ -30,9 +35,14 @@ const serverTimestamp = _serverTimestamp as unknown as () => Timestamp; export { collection, + deleteDoc, doc, getConverter, getFirestore, + orderBy, + query, serverTimestamp, + setDoc, + updateDoc, Timestamp, }; diff --git a/app/utils/firebase/storage.ts b/app/utils/firebase/storage.ts new file mode 100644 index 0000000..34ecdbd --- /dev/null +++ b/app/utils/firebase/storage.ts @@ -0,0 +1,9 @@ +import { + deleteObject, + getDownloadURL, + getStorage, + ref, + uploadBytes, +} from 'firebase/storage'; + +export { deleteObject, getDownloadURL, getStorage, ref, uploadBytes }; From 8f1b1a84eacec538b6c5914e56d11df943f97d64 Mon Sep 17 00:00:00 2001 From: hiroro-work Date: Mon, 21 Oct 2024 14:44:32 +0900 Subject: [PATCH 06/13] =?UTF-8?q?=E3=82=B3=E3=83=AC=E3=82=AF=E3=82=B7?= =?UTF-8?q?=E3=83=A7=E3=83=B3=E3=83=87=E3=83=BC=E3=82=BF=E3=82=92=E5=8F=96?= =?UTF-8?q?=E5=BE=97=E3=81=99=E3=82=8B=E3=82=AB=E3=82=B9=E3=82=BF=E3=83=A0?= =?UTF-8?q?=E3=83=95=E3=83=83=E3=82=AF=E3=82=92=E4=BD=9C=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/hooks/useCollectionData.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 app/hooks/useCollectionData.ts diff --git a/app/hooks/useCollectionData.ts b/app/hooks/useCollectionData.ts new file mode 100644 index 0000000..f5d5d84 --- /dev/null +++ b/app/hooks/useCollectionData.ts @@ -0,0 +1,12 @@ +import { useCollectionData as _useCollectionData } from '@sonicgarden/react-fire-hooks'; + +export const useCollectionData = ( + ...[query, options]: Parameters> +) => + _useCollectionData(query, { + ...options, + snapshotOptions: { + serverTimestamps: 'estimate', + ...options?.snapshotOptions, + }, + }); From d55df535b6257ac1bd443ead355ab4d2d2fece38 Mon Sep 17 00:00:00 2001 From: hiroro-work Date: Mon, 21 Oct 2024 14:49:11 +0900 Subject: [PATCH 07/13] =?UTF-8?q?=E6=9B=B8=E7=B1=8D=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E7=94=BB=E9=9D=A2=E3=82=92=E4=BD=9C=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../elements/UnstyledModalButton.tsx | 44 ++++++++++++++ app/components/pages/admin/AdminBooks.tsx | 59 ++++++++++++++++++ .../pages/admin/_components/Book.tsx | 60 +++++++++++++++++++ app/hooks/usePermissions.ts | 1 + app/routes/admin.books._index.tsx | 5 ++ 5 files changed, 169 insertions(+) create mode 100644 app/components/elements/UnstyledModalButton.tsx create mode 100644 app/components/pages/admin/AdminBooks.tsx create mode 100644 app/components/pages/admin/_components/Book.tsx create mode 100644 app/routes/admin.books._index.tsx diff --git a/app/components/elements/UnstyledModalButton.tsx b/app/components/elements/UnstyledModalButton.tsx new file mode 100644 index 0000000..f294821 --- /dev/null +++ b/app/components/elements/UnstyledModalButton.tsx @@ -0,0 +1,44 @@ +import { + Modal, + UnstyledButton, + createPolymorphicComponent, +} from '@mantine/core'; +import { useDisclosure } from '@mantine/hooks'; +import { forwardRef } from 'react'; +import type { ModalProps, UnstyledButtonProps } from '@mantine/core'; +import type { ReactNode } from 'react'; + +type Props = UnstyledButtonProps & { + modalContent: ({ + opened, + close, + }: { + opened: boolean; + close: () => void; + }) => ReactNode; + modalProps?: Omit; + children: ReactNode; +}; + +const _UnstyledModalButton = forwardRef( + ({ modalContent, modalProps, children, ...props }: Props, ref) => { + const [opened, { open, close }] = useDisclosure(false); + return ( + <> + + {children} + + + {modalContent({ opened, close })} + + + ); + }, +); +_UnstyledModalButton.displayName = 'UnstyledModalButton'; + +export const UnstyledModalButton = createPolymorphicComponent< + 'button', + Props, + typeof _UnstyledModalButton +>(_UnstyledModalButton); diff --git a/app/components/pages/admin/AdminBooks.tsx b/app/components/pages/admin/AdminBooks.tsx new file mode 100644 index 0000000..a84ff9e --- /dev/null +++ b/app/components/pages/admin/AdminBooks.tsx @@ -0,0 +1,59 @@ +import { + Box, + Stack, + Title, + Group, + Table, + ActionIcon, + LoadingOverlay, +} from '@mantine/core'; +import { IconSquareRoundedPlus } from '@tabler/icons-react'; +import { UnstyledModalButton } from '~/components/elements/UnstyledModalButton'; +import { useCollectionData } from '~/hooks/useCollectionData'; +import { booksQuery } from '~/models/book'; +import { Book } from './_components/Book'; + +export const AdminBooks = () => { + const { data: books, loading } = useCollectionData(booksQuery()); + + return ( + + + + + 書籍管理 + + + + '新規作成フォーム'} + modalProps={{ title: '書籍' }} + > + + + + + + + + + + + ID + 表紙 + タイトル + 説明 + + + + {books.map((book) => ( + + ))} + +
+
+
+ ); +}; diff --git a/app/components/pages/admin/_components/Book.tsx b/app/components/pages/admin/_components/Book.tsx new file mode 100644 index 0000000..54b72ed --- /dev/null +++ b/app/components/pages/admin/_components/Book.tsx @@ -0,0 +1,60 @@ +import { Table, ActionIcon, Image } from '@mantine/core'; +import { IconEdit, IconTrash } from '@tabler/icons-react'; +import { useState, useCallback } from 'react'; +import { UnstyledConfirmButton } from '~/components/elements/UnstyledConfirmButton'; +import { UnstyledModalButton } from '~/components/elements/UnstyledModalButton'; +import { deleteBook } from '~/models/book'; +import { notify } from '~/utils/mantine/notifications'; +import type { Book as BookType } from '@local/shared'; + +export const Book = ({ book }: { book: BookType }) => { + const [loading, setLoading] = useState(false); + const handleConfirm = useCallback(async () => { + setLoading(true); + try { + await deleteBook(book); + notify.info({ message: '書籍を削除しました' }); + } catch (e) { + console.error(e); + notify.error({ message: '削除に失敗しました' }); + } finally { + setLoading(false); + } + }, [book]); + + return ( + + + + '更新フォーム'} + modalProps={{ title: '書籍' }} + > + + + + + + + + {book.id} + + 表紙 + + {book.title} + + {book.description} + + + ); +}; diff --git a/app/hooks/usePermissions.ts b/app/hooks/usePermissions.ts index a87220d..ae9496e 100644 --- a/app/hooks/usePermissions.ts +++ b/app/hooks/usePermissions.ts @@ -6,6 +6,7 @@ export const usePermissions = () => { const validatePathPermission = useCallback( (path: string) => { if (/^\/admin\/?$/.test(path) && role === 'admin') return true; + if (/^\/admin\/books\/?$/.test(path) && role === 'admin') return true; return false; }, [role], diff --git a/app/routes/admin.books._index.tsx b/app/routes/admin.books._index.tsx new file mode 100644 index 0000000..e9c641c --- /dev/null +++ b/app/routes/admin.books._index.tsx @@ -0,0 +1,5 @@ +import { AdminBooks } from '~/components/pages/admin/AdminBooks'; + +export default function AdminBooksPage() { + return ; +} From 05f3fbedfb36d4c6a6274297e87862a59a26abf3 Mon Sep 17 00:00:00 2001 From: hiroro-work Date: Mon, 21 Oct 2024 14:49:41 +0900 Subject: [PATCH 08/13] pnpm -w add @mantine/dropzone --- package.json | 1 + pnpm-lock.yaml | 30 ++++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/package.json b/package.json index 9fe9278..03daeb7 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "dependencies": { "@local/shared": "workspace:^", "@mantine/core": "^7.13.3", + "@mantine/dropzone": "^7.13.3", "@mantine/form": "^7.13.3", "@mantine/hooks": "^7.13.3", "@mantine/modals": "^7.13.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5ca10cc..bae240d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@mantine/core': specifier: ^7.13.3 version: 7.13.3(@mantine/hooks@7.13.3(react@18.3.1))(@types/react@18.3.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@mantine/dropzone': + specifier: ^7.13.3 + version: 7.13.3(@mantine/core@7.13.3(@mantine/hooks@7.13.3(react@18.3.1))(@types/react@18.3.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mantine/hooks@7.13.3(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@mantine/form': specifier: ^7.13.3 version: 7.13.3(react@18.3.1) @@ -1278,6 +1281,14 @@ packages: react: ^18.2.0 react-dom: ^18.2.0 + '@mantine/dropzone@7.13.3': + resolution: {integrity: sha512-N3MxrDiMlshqbVQjA52QZqPYhWFs8HPnB48zfHF9Vo+PEPeKT+Kf2q4SR/PSIubFKOyzJkW78kKzAjXmBdqmCw==} + peerDependencies: + '@mantine/core': 7.13.3 + '@mantine/hooks': 7.13.3 + react: ^18.2.0 + react-dom: ^18.2.0 + '@mantine/form@7.13.3': resolution: {integrity: sha512-9OsXlrKD8R2QadHt6ueIXxmot9xf9I9HBO0rynmuZlOj76N7l9PH1KYWLG8TQ9UU32lNnuYecyilF4Ce9fp0Fw==} peerDependencies: @@ -4494,6 +4505,12 @@ packages: peerDependencies: react: ^18.3.1 + react-dropzone-esm@15.0.1: + resolution: {integrity: sha512-RdeGpqwHnoV/IlDFpQji7t7pTtlC2O1i/Br0LWkRZ9hYtLyce814S71h5NolnCZXsIN5wrZId6+8eQj2EBnEzg==} + engines: {node: '>= 10.13'} + peerDependencies: + react: '>= 16.8 || 18.0.0' + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -6661,6 +6678,14 @@ snapshots: transitivePeerDependencies: - '@types/react' + '@mantine/dropzone@7.13.3(@mantine/core@7.13.3(@mantine/hooks@7.13.3(react@18.3.1))(@types/react@18.3.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mantine/hooks@7.13.3(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@mantine/core': 7.13.3(@mantine/hooks@7.13.3(react@18.3.1))(@types/react@18.3.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@mantine/hooks': 7.13.3(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-dropzone-esm: 15.0.1(react@18.3.1) + '@mantine/form@7.13.3(react@18.3.1)': dependencies: fast-deep-equal: 3.1.3 @@ -10914,6 +10939,11 @@ snapshots: react: 18.3.1 scheduler: 0.23.2 + react-dropzone-esm@15.0.1(react@18.3.1): + dependencies: + prop-types: 15.8.1 + react: 18.3.1 + react-is@16.13.1: {} react-is@18.3.1: {} From 6e17abed45f9b495dc3c1032ae264c22a207b7a6 Mon Sep 17 00:00:00 2001 From: hiroro-work Date: Mon, 21 Oct 2024 14:56:13 +0900 Subject: [PATCH 09/13] =?UTF-8?q?=E6=9B=B8=E7=B1=8D=E3=81=AE=E6=96=B0?= =?UTF-8?q?=E8=A6=8F=E4=BD=9C=E6=88=90=E6=A9=9F=E8=83=BD=E3=82=92=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/components/forms/book/CreateBookForm.tsx | 85 +++++++++++++++++++ .../book/_components/DescriptionInput.tsx | 29 +++++++ .../forms/book/_components/ImageDropzone.tsx | 52 ++++++++++++ .../forms/book/_components/TitleInput.tsx | 27 ++++++ app/components/pages/admin/AdminBooks.tsx | 5 +- app/root.tsx | 1 + 6 files changed, 198 insertions(+), 1 deletion(-) create mode 100644 app/components/forms/book/CreateBookForm.tsx create mode 100644 app/components/forms/book/_components/DescriptionInput.tsx create mode 100644 app/components/forms/book/_components/ImageDropzone.tsx create mode 100644 app/components/forms/book/_components/TitleInput.tsx diff --git a/app/components/forms/book/CreateBookForm.tsx b/app/components/forms/book/CreateBookForm.tsx new file mode 100644 index 0000000..ea7ef47 --- /dev/null +++ b/app/components/forms/book/CreateBookForm.tsx @@ -0,0 +1,85 @@ +import { Stack, Group, Button } from '@mantine/core'; +import { useForm, zodResolver } from '@mantine/form'; +import { useCallback, useState } from 'react'; +import { z } from 'zod'; +import { LoadingOverlayButton } from '~/components/elements/LoadingOverlayButton'; +import { createBook, newBookRef } from '~/models/book'; +import { notify } from '~/utils/mantine/notifications'; +import { + DescriptionInput, + descriptionValidation, +} from './_components/DescriptionInput'; +import { + ImageDropzone, + imageValidation, +} from './_components/ImageDropzone'; +import { TitleInput, titleValidation } from './_components/TitleInput'; +import type { BookDocumentData } from '@local/shared'; + +type FormValues = { + title: BookDocumentData['title']; + description: BookDocumentData['description']; + image: File | null; +}; + +const schema = z.object({ + title: titleValidation, + description: descriptionValidation, + image: imageValidation, +}); + +export const CreateBookForm = ({ + onSubmit, + onCancel, +}: { + onSubmit?: () => void | Promise; + onCancel?: () => void | Promise; +}) => { + const form = useForm({ + validate: zodResolver(schema), + initialValues: { + title: '', + description: '', + image: null, + }, + }); + const [loading, setLoading] = useState(false); + const handleSubmit = useCallback( + async (values: FormValues) => { + try { + setLoading(true); + await createBook(newBookRef(), { ...values, image: values.image! }); + await onSubmit?.(); + notify.info({ message: '書籍を作成しました' }); + } catch (error) { + console.error(error); + notify.error({ message: '作成に失敗しました' }); + } finally { + setLoading(false); + } + }, + [onSubmit], + ); + + return ( +
+ + + + + + + + {onCancel && ( + + )} + + 作成 + + + +
+ ); +}; diff --git a/app/components/forms/book/_components/DescriptionInput.tsx b/app/components/forms/book/_components/DescriptionInput.tsx new file mode 100644 index 0000000..05ca68d --- /dev/null +++ b/app/components/forms/book/_components/DescriptionInput.tsx @@ -0,0 +1,29 @@ +import { Textarea } from '@mantine/core'; +import { z } from 'zod'; +import type { TextareaProps } from '@mantine/core'; +import type { UseFormReturnType } from '@mantine/form'; + +export const descriptionValidation = z + .string() + .min(1, { message: '必須項目です' }); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const DescriptionInput =
>({ + form, + ...props +}: { form: Form } & Omit) => { + const name = 'description'; + const label = '説明'; + + return ( +