From 5c4158824929cc03b215a296f13ad859e4f6b832 Mon Sep 17 00:00:00 2001 From: Ahmed Al-Taiar Date: Fri, 27 Sep 2024 22:52:41 -0400 Subject: [PATCH] Update to RW 8.3.0 + Project CRUD complete --- .../migrations/20240927031102_/migration.sql | 14 + api/db/schema.prisma | 16 +- api/package.json | 8 +- api/src/graphql/projectImages.sdl.ts | 33 - api/src/graphql/projects.sdl.ts | 7 +- .../services/projectImages/projectImages.ts | 49 - api/src/services/projects/projects.ts | 27 +- package.json | 6 +- web/package.json | 12 +- .../Portrait/PortraitForm/PortraitForm.tsx | 42 +- .../EditProjectCell/EditProjectCell.tsx | 6 + .../components/Project/Project/Project.tsx | 72 +- .../Project/ProjectCell/ProjectCell.tsx | 6 + .../Project/ProjectForm/ProjectForm.tsx | 130 +- .../components/Project/Projects/Projects.tsx | 62 +- .../Project/ProjectsCell/ProjectsCell.tsx | 6 + .../Tag/TagsSelector/TagsSelector.tsx | 80 + .../Tag/TagsSelectorCell/TagsSelectorCell.tsx | 72 + web/src/index.css | 4 + web/src/lib/tus.ts | 30 + yarn.lock | 1883 ++++++++--------- 21 files changed, 1395 insertions(+), 1170 deletions(-) create mode 100644 api/db/migrations/20240927031102_/migration.sql delete mode 100644 api/src/graphql/projectImages.sdl.ts delete mode 100644 api/src/services/projectImages/projectImages.ts create mode 100644 web/src/components/Tag/TagsSelector/TagsSelector.tsx create mode 100644 web/src/components/Tag/TagsSelectorCell/TagsSelectorCell.tsx create mode 100644 web/src/lib/tus.ts diff --git a/api/db/migrations/20240927031102_/migration.sql b/api/db/migrations/20240927031102_/migration.sql new file mode 100644 index 0000000..954ccdd --- /dev/null +++ b/api/db/migrations/20240927031102_/migration.sql @@ -0,0 +1,14 @@ +/* + Warnings: + + - You are about to drop the `ProjectImage` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "ProjectImage" DROP CONSTRAINT "ProjectImage_projectId_fkey"; + +-- AlterTable +ALTER TABLE "Project" ADD COLUMN "images" TEXT[]; + +-- DropTable +DROP TABLE "ProjectImage"; diff --git a/api/db/schema.prisma b/api/db/schema.prisma index 78caa60..a5f8d29 100644 --- a/api/db/schema.prisma +++ b/api/db/schema.prisma @@ -65,20 +65,12 @@ model Tag { projects Project[] } -model ProjectImage { - id Int @id @default(autoincrement()) - fileId String - - Project Project? @relation(fields: [projectId], references: [id]) - projectId Int? -} - model Project { - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) title String - description String @default("No description provided") - images ProjectImage[] + description String @default("No description provided") + images String[] date DateTime - links String[] @default([]) + links String[] @default([]) tags Tag[] } diff --git a/api/package.json b/api/package.json index 1322711..4de39c9 100644 --- a/api/package.json +++ b/api/package.json @@ -5,10 +5,10 @@ "dependencies": { "@fastify/cors": "^9.0.1", "@fastify/rate-limit": "^9.1.0", - "@redwoodjs/api": "8.0.0", - "@redwoodjs/api-server": "8.0.0", - "@redwoodjs/auth-dbauth-api": "8.0.0", - "@redwoodjs/graphql-server": "8.0.0", + "@redwoodjs/api": "8.3.0", + "@redwoodjs/api-server": "8.3.0", + "@redwoodjs/auth-dbauth-api": "8.3.0", + "@redwoodjs/graphql-server": "8.3.0", "@tus/file-store": "^1.4.0", "@tus/server": "^1.7.0", "graphql-scalars": "^1.23.0", diff --git a/api/src/graphql/projectImages.sdl.ts b/api/src/graphql/projectImages.sdl.ts deleted file mode 100644 index da025a1..0000000 --- a/api/src/graphql/projectImages.sdl.ts +++ /dev/null @@ -1,33 +0,0 @@ -export const schema = gql` - type ProjectImage { - id: Int! - fileId: URL! - Project: Project - projectId: Int - } - - type Query { - projectImages: [ProjectImage!]! @requireAuth - projectImage(id: Int!): ProjectImage @requireAuth - } - - input CreateProjectImageInput { - fileId: URL! - projectId: Int - } - - input UpdateProjectImageInput { - fileId: URL - projectId: Int - } - - type Mutation { - createProjectImage(input: CreateProjectImageInput!): ProjectImage! - @requireAuth - updateProjectImage( - id: Int! - input: UpdateProjectImageInput! - ): ProjectImage! @requireAuth - deleteProjectImage(id: Int!): ProjectImage! @requireAuth - } -` diff --git a/api/src/graphql/projects.sdl.ts b/api/src/graphql/projects.sdl.ts index 054fe70..f211566 100644 --- a/api/src/graphql/projects.sdl.ts +++ b/api/src/graphql/projects.sdl.ts @@ -3,7 +3,7 @@ export const schema = gql` id: Int! title: String! description: String! - images: [ProjectImage]! + images: [String]! date: DateTime! links: [URL]! tags: [Tag]! @@ -19,6 +19,8 @@ export const schema = gql` description: String! date: DateTime! links: [URL]! + images: [URL]! + tags: [Int!] } input UpdateProjectInput { @@ -26,6 +28,9 @@ export const schema = gql` description: String date: DateTime links: [URL]! + images: [URL]! + tags: [Int!] + removeTags: [Int!] } type Mutation { diff --git a/api/src/services/projectImages/projectImages.ts b/api/src/services/projectImages/projectImages.ts deleted file mode 100644 index 1ccebe0..0000000 --- a/api/src/services/projectImages/projectImages.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { - QueryResolvers, - MutationResolvers, - ProjectImageRelationResolvers, -} from 'types/graphql' - -import { db } from 'src/lib/db' - -export const projectImages: QueryResolvers['projectImages'] = () => { - return db.projectImage.findMany() -} - -export const projectImage: QueryResolvers['projectImage'] = ({ id }) => { - return db.projectImage.findUnique({ - where: { id }, - }) -} - -export const createProjectImage: MutationResolvers['createProjectImage'] = ({ - input, -}) => { - return db.projectImage.create({ - data: input, - }) -} - -export const updateProjectImage: MutationResolvers['updateProjectImage'] = ({ - id, - input, -}) => { - return db.projectImage.update({ - data: input, - where: { id }, - }) -} - -export const deleteProjectImage: MutationResolvers['deleteProjectImage'] = ({ - id, -}) => { - return db.projectImage.delete({ - where: { id }, - }) -} - -export const ProjectImage: ProjectImageRelationResolvers = { - Project: (_obj, { root }) => { - return db.projectImage.findUnique({ where: { id: root?.id } }).Project() - }, -} diff --git a/api/src/services/projects/projects.ts b/api/src/services/projects/projects.ts index 3ea570b..7cc4f54 100644 --- a/api/src/services/projects/projects.ts +++ b/api/src/services/projects/projects.ts @@ -15,15 +15,34 @@ export const project: QueryResolvers['project'] = ({ id }) => export const createProject: MutationResolvers['createProject'] = ({ input }) => db.project.create({ - data: input, + data: { + title: input.title, + description: input.description, + date: input.date, + links: input.links, + images: input.images, + tags: { + connect: input.tags.map((tagId) => ({ id: tagId })), + }, + }, }) -export const updateProject: MutationResolvers['updateProject'] = ({ +export const updateProject: MutationResolvers['updateProject'] = async ({ id, input, }) => db.project.update({ - data: input, + data: { + title: input.title, + description: input.description, + date: input.date, + links: input.links, + images: input.images, + tags: { + disconnect: input.removeTags?.map((tagId) => ({ id: tagId })), + connect: input.tags?.map((tagId) => ({ id: tagId })), + }, + }, where: { id }, }) @@ -33,8 +52,6 @@ export const deleteProject: MutationResolvers['deleteProject'] = ({ id }) => }) export const Project: ProjectRelationResolvers = { - images: (_obj, { root }) => - db.project.findUnique({ where: { id: root?.id } }).images(), tags: (_obj, { root }) => db.project.findUnique({ where: { id: root?.id } }).tags(), } diff --git a/package.json b/package.json index 6659448..db5893e 100644 --- a/package.json +++ b/package.json @@ -7,9 +7,9 @@ ] }, "devDependencies": { - "@redwoodjs/auth-dbauth-setup": "8.0.0", - "@redwoodjs/core": "8.0.0", - "@redwoodjs/project-config": "8.0.0", + "@redwoodjs/auth-dbauth-setup": "8.3.0", + "@redwoodjs/core": "8.3.0", + "@redwoodjs/project-config": "8.3.0", "prettier-plugin-tailwindcss": "0.4.1" }, "eslintConfig": { diff --git a/web/package.json b/web/package.json index 3c00c14..19bfc0f 100644 --- a/web/package.json +++ b/web/package.json @@ -14,11 +14,11 @@ "@icons-pack/react-simple-icons": "^10.0.0", "@mdi/js": "^7.4.47", "@mdi/react": "^1.6.1", - "@redwoodjs/auth-dbauth-web": "8.0.0", - "@redwoodjs/forms": "8.0.0", - "@redwoodjs/router": "8.0.0", - "@redwoodjs/web": "8.0.0", - "@redwoodjs/web-server": "8.0.0", + "@redwoodjs/auth-dbauth-web": "8.3.0", + "@redwoodjs/forms": "8.3.0", + "@redwoodjs/router": "8.3.0", + "@redwoodjs/web": "8.3.0", + "@redwoodjs/web-server": "8.3.0", "@uppy/compressor": "^2.0.1", "@uppy/core": "^4.1.0", "@uppy/dashboard": "^4.0.2", @@ -34,7 +34,7 @@ "react-dom": "18.3.1" }, "devDependencies": { - "@redwoodjs/vite": "8.0.0", + "@redwoodjs/vite": "8.3.0", "@types/react": "^18.2.55", "@types/react-dom": "^18.2.19", "autoprefixer": "^10.4.20", diff --git a/web/src/components/Portrait/PortraitForm/PortraitForm.tsx b/web/src/components/Portrait/PortraitForm/PortraitForm.tsx index 6f07adb..8aacdaa 100644 --- a/web/src/components/Portrait/PortraitForm/PortraitForm.tsx +++ b/web/src/components/Portrait/PortraitForm/PortraitForm.tsx @@ -15,6 +15,7 @@ import { TypedDocumentNode, useMutation } from '@redwoodjs/web' import { toast } from '@redwoodjs/web/toast' import Uploader from 'src/components/Uploader/Uploader' +import { deleteFile, handleBeforeUnload } from 'src/lib/tus' interface PortraitFormProps { portrait?: Portrait @@ -56,7 +57,7 @@ const CREATE_PORTRAIT_MUTATION: TypedDocumentNode< const PortraitForm = (props: PortraitFormProps) => { const [fileId, _setFileId] = useState(props.portrait?.fileId) - const fileIdRef = useRef(fileId) + const fileIdRef = useRef(fileId) const setFileId = (fileId: string) => { _setFileId(fileId) @@ -89,31 +90,18 @@ const PortraitForm = (props: PortraitFormProps) => { } ) - const handleBeforeUnload = (_e: BeforeUnloadEvent) => { - deleteFile(fileIdRef.current) - - if (navigator.userAgent.match(/firefox|fxios/i)) { - const firefoxVer = Number(navigator.userAgent.match(/Firefox\/(\d+)/)[1]) - - // One day dom.fetchKeepalive.enabled becomes true by default... until then! - if (firefoxVer < 129) { - const time = Date.now() - - while (Date.now() - time < 500) { - /* empty */ - } - } - } - } - const onUploadComplete = ( result: UploadResult> ) => { setFileId(result.successful[0]?.uploadURL) - window.addEventListener('beforeunload', handleBeforeUnload, { - once: true, - signal: unloadAbortController.signal, - }) + window.addEventListener( + 'beforeunload', + (e) => handleBeforeUnload(e, [fileIdRef.current]), + { + once: true, + signal: unloadAbortController.signal, + } + ) } if (props.portrait?.fileId) @@ -202,14 +190,4 @@ const PortraitForm = (props: PortraitFormProps) => { ) } -const deleteFile = async (fileId: string) => { - await fetch(fileId, { - method: 'DELETE', - headers: { - 'Tus-Resumable': '1.0.0', - }, - keepalive: true, - }) -} - export default PortraitForm diff --git a/web/src/components/Project/EditProjectCell/EditProjectCell.tsx b/web/src/components/Project/EditProjectCell/EditProjectCell.tsx index e504fa2..0a88921 100644 --- a/web/src/components/Project/EditProjectCell/EditProjectCell.tsx +++ b/web/src/components/Project/EditProjectCell/EditProjectCell.tsx @@ -25,6 +25,12 @@ export const QUERY: TypedDocumentNode = gql` description date links + images + tags { + id + tag + color + } } } ` diff --git a/web/src/components/Project/Project/Project.tsx b/web/src/components/Project/Project/Project.tsx index 685648a..7290f64 100644 --- a/web/src/components/Project/Project/Project.tsx +++ b/web/src/components/Project/Project/Project.tsx @@ -9,7 +9,9 @@ import { useMutation } from '@redwoodjs/web' import type { TypedDocumentNode } from '@redwoodjs/web' import { toast } from '@redwoodjs/web/toast' +import { calculateLuminance } from 'src/lib/color' import { timeTag } from 'src/lib/formatters' +import { batchDelete } from 'src/lib/tus' const DELETE_PROJECT_MUTATION: TypedDocumentNode< DeleteProjectMutation, @@ -36,8 +38,10 @@ const Project = ({ project }: Props) => { }) const onDeleteClick = (id: DeleteProjectMutationVariables['id']) => { - if (confirm('Are you sure you want to delete project ' + id + '?')) + if (confirm('Are you sure you want to delete project ' + id + '?')) { + batchDelete(project.images) deleteProject({ variables: { id } }) + } } return ( @@ -70,20 +74,62 @@ const Project = ({ project }: Props) => { Date {timeTag(project.date)} + + Images + +
+ {project.images.map((image, i) => ( + + {i + 1} + + ))} +
+ + + + Tags + +
+ {project.tags.map((tag, i) => ( +
0.5 + ? 'black' + : 'white', + }} + > + {tag.tag} +
+ ))} +
+ + Links - - {project.links.map((link, i) => ( - - {link} - - ))} + +
+ {project.links.map((link, i) => ( + + {link} + + ))} +
diff --git a/web/src/components/Project/ProjectCell/ProjectCell.tsx b/web/src/components/Project/ProjectCell/ProjectCell.tsx index 0676c3d..3ad80db 100644 --- a/web/src/components/Project/ProjectCell/ProjectCell.tsx +++ b/web/src/components/Project/ProjectCell/ProjectCell.tsx @@ -22,6 +22,12 @@ export const QUERY: TypedDocumentNode< description date links + images + tags { + id + tag + color + } } } ` diff --git a/web/src/components/Project/ProjectForm/ProjectForm.tsx b/web/src/components/Project/ProjectForm/ProjectForm.tsx index dc11e01..fdd7071 100644 --- a/web/src/components/Project/ProjectForm/ProjectForm.tsx +++ b/web/src/components/Project/ProjectForm/ProjectForm.tsx @@ -1,9 +1,14 @@ import { useEffect, useMemo, useRef, useState } from 'react' -import { mdiCalendar, mdiFormatTitle, mdiLinkVariant } from '@mdi/js' +import { mdiCalendar, mdiDelete, mdiFormatTitle, mdiLinkVariant } from '@mdi/js' import Icon from '@mdi/react' +import { Meta, UploadResult } from '@uppy/core' import { format, isAfter, startOfToday } from 'date-fns' -import type { EditProjectById, UpdateProjectInput } from 'types/graphql' +import type { + EditProjectById, + FindTags, + UpdateProjectInput, +} from 'types/graphql' import type { RWGqlError } from '@redwoodjs/forms' import { @@ -16,13 +21,14 @@ import { } from '@redwoodjs/forms' import { toast } from '@redwoodjs/web/toast' -import DatePicker from 'src/components/DatePicker/DatePicker' -import FormTextList from 'src/components/FormTextList/FormTextList' +import DatePicker from 'src/components/DatePicker' +import FormTextList from 'src/components/FormTextList' +import TagsSelectorCell from 'src/components/Tag/TagsSelectorCell' +import Uploader from 'src/components/Uploader' +import { batchDelete } from 'src/lib/tus' type FormProject = NonNullable -// TODO: add project images - interface ProjectFormProps { project?: EditProjectById['project'] onSave: (data: UpdateProjectInput, id?: FormProject['id']) => void @@ -40,6 +46,23 @@ const ProjectForm = (props: ProjectFormProps) => { props.project?.date ? new Date(props.project.date) : today ) const [month, setMonth] = useState(format(today, 'MMMM yyyy')) + const [fileIds, setFileIds] = useState(props.project?.images || []) + const [selectedTags, setSelectedTags] = useState( + props.project?.tags || [] + ) + const [appendUploader, setAppendUploader] = useState(false) + const [toDelete, setToDelete] = useState([]) + + const onUploadComplete = ( + result: UploadResult> + ) => { + setFileIds( + appendUploader + ? [...fileIds, ...result.successful.map((file) => file.uploadURL)] + : result.successful.map((file) => file.uploadURL) + ) + setAppendUploader(false) + } const urlRegex = useMemo( () => @@ -59,9 +82,22 @@ const ProjectForm = (props: ProjectFormProps) => { if (errorsExist) return toast.error(`${errorCount} links invalid`) if (emptyCount > 0) return toast.error(`${emptyCount} links empty`) - data.links = links - data.date = date.toISOString() - props.onSave(data, props?.project?.id) + batchDelete(toDelete) + + props.onSave( + { + title: data.title, + description: data.description, + date: date.toISOString(), + links: links.filter((link) => link.trim().length > 0), + images: fileIds, + tags: selectedTags.map((tag) => tag.id), + removeTags: props.project?.tags + .filter((tag) => !selectedTags.some((st) => st.id === tag.id)) + .map((tag) => tag.id), + }, + props?.project?.id + ) } const titleRef = useRef(null) @@ -170,6 +206,82 @@ const ProjectForm = (props: ProjectFormProps) => { /> + + +
+
+

Images

+ {fileIds.length > 0 && + (appendUploader ? ( + + ) : ( + + ))} +
+ {fileIds.length > 0 ? ( + <> + {fileIds.map((fileId, i) => ( +
+
+ {i.toString()} +
+
+
+ +
+
+
+ ))} + {appendUploader && ( + + )} + + ) : ( + + )} +
+ {isAfter(date, today) && (

Project will be marked as

diff --git a/web/src/components/Project/Projects/Projects.tsx b/web/src/components/Project/Projects/Projects.tsx index 295a246..b630003 100644 --- a/web/src/components/Project/Projects/Projects.tsx +++ b/web/src/components/Project/Projects/Projects.tsx @@ -12,7 +12,9 @@ import type { TypedDocumentNode } from '@redwoodjs/web' import { toast } from '@redwoodjs/web/toast' import { QUERY } from 'src/components/Project/ProjectsCell' +import { calculateLuminance } from 'src/lib/color' import { timeTag, truncate } from 'src/lib/formatters' +import { batchDelete } from 'src/lib/tus' const DELETE_PROJECT_MUTATION: TypedDocumentNode< DeleteProjectMutation, @@ -34,8 +36,10 @@ const ProjectsList = ({ projects }: FindProjects) => { }) const onDeleteClick = (id: DeleteProjectMutationVariables['id']) => { - if (confirm('Are you sure you want to delete project ' + id + '?')) + if (confirm('Are you sure you want to delete project ' + id + '?')) { + batchDelete(projects.find((project) => project.id === id).images) deleteProject({ variables: { id } }) + } } return ( @@ -46,6 +50,8 @@ const ProjectsList = ({ projects }: FindProjects) => { Title Description Date + Images + Tags Links   @@ -82,26 +88,48 @@ const ProjectsList = ({ projects }: FindProjects) => { return ( {truncate(project.title)} - {truncate(project.description)} - {timeTag(project.date)} - - {project.links.map((link, i) => ( - - {link} - - ))} + {truncate(project.description)} + {timeTag(project.date)} + {`${project.images.length} ${project.images.length === 1 ? 'image' : 'images'}`} + +
+ {project.tags.map((tag, i) => ( +
0.5 + ? 'black' + : 'white', + }} + > + {tag.tag} +
+ ))} +
-