diff --git a/api/db/migrations/20241001005227_title_and_resume/migration.sql b/api/db/migrations/20241001005227_title_and_resume/migration.sql new file mode 100644 index 0000000..746885d --- /dev/null +++ b/api/db/migrations/20241001005227_title_and_resume/migration.sql @@ -0,0 +1,15 @@ +-- CreateTable +CREATE TABLE "Resume" ( + "id" SERIAL NOT NULL, + "fileId" TEXT NOT NULL, + + CONSTRAINT "Resume_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Title" ( + "id" SERIAL NOT NULL, + "title" TEXT NOT NULL, + + CONSTRAINT "Title_pkey" PRIMARY KEY ("id") +); diff --git a/api/db/schema.prisma b/api/db/schema.prisma index 31a5f10..1cd8215 100644 --- a/api/db/schema.prisma +++ b/api/db/schema.prisma @@ -58,6 +58,16 @@ model Portrait { fileId String } +model Resume { + id Int @id @default(autoincrement()) + fileId String +} + +model Title { + id Int @id @default(autoincrement()) + title String +} + model Tag { id Int @id @default(autoincrement()) tag String diff --git a/api/src/graphql/resume.sdl.ts b/api/src/graphql/resume.sdl.ts new file mode 100644 index 0000000..84b0778 --- /dev/null +++ b/api/src/graphql/resume.sdl.ts @@ -0,0 +1,19 @@ +export const schema = gql` + type Resume { + id: Int! + fileId: URL! + } + + type Query { + resume: Resume @skipAuth + } + + input CreateResumeInput { + fileId: URL! + } + + type Mutation { + createResume(input: CreateResumeInput!): Resume! @requireAuth + deleteResume: Resume! @requireAuth + } +` diff --git a/api/src/graphql/titles.sdl.ts b/api/src/graphql/titles.sdl.ts new file mode 100644 index 0000000..f5e191d --- /dev/null +++ b/api/src/graphql/titles.sdl.ts @@ -0,0 +1,25 @@ +export const schema = gql` + type Title { + id: Int! + title: String! + } + + type Query { + titles: [Title!]! @skipAuth + title(id: Int!): Title @skipAuth + } + + input CreateTitleInput { + title: String! + } + + input UpdateTitleInput { + title: String + } + + type Mutation { + createTitle(input: CreateTitleInput!): Title! @requireAuth + updateTitle(id: Int!, input: UpdateTitleInput!): Title! @requireAuth + deleteTitle(id: Int!): Title! @requireAuth + } +` diff --git a/api/src/services/resume/resume.ts b/api/src/services/resume/resume.ts new file mode 100644 index 0000000..f7b099c --- /dev/null +++ b/api/src/services/resume/resume.ts @@ -0,0 +1,42 @@ +import type { QueryResolvers, MutationResolvers } from 'types/graphql' + +import { isProduction } from '@redwoodjs/api/logger' +import { ValidationError } from '@redwoodjs/graphql-server' + +import { db } from 'src/lib/db' + +const address = isProduction + ? process.env.ADDRESS_PROD + : process.env.ADDRESS_DEV + +export const resume: QueryResolvers['resume'] = async () => { + const resume = await db.resume.findFirst() + + if (resume) return resume + else + return { + id: -1, + fileId: `${address}/no_resume.pdf`, + } +} + +export const createResume: MutationResolvers['createResume'] = async ({ + input, +}) => { + if (await db.resume.findFirst()) + throw new ValidationError('Resume already exists') + else + return db.resume.create({ + data: input, + }) +} + +export const deleteResume: MutationResolvers['deleteResume'] = async () => { + const resume = await db.resume.findFirst() + + if (!resume) throw new ValidationError('Resume does not exist') + else + return db.resume.delete({ + where: { id: resume.id }, + }) +} diff --git a/api/src/services/titles/titles.ts b/api/src/services/titles/titles.ts new file mode 100644 index 0000000..a644711 --- /dev/null +++ b/api/src/services/titles/titles.ts @@ -0,0 +1,26 @@ +import type { QueryResolvers, MutationResolvers } from 'types/graphql' + +import { db } from 'src/lib/db' + +export const titles: QueryResolvers['titles'] = () => db.title.findMany() + +export const title: QueryResolvers['title'] = ({ id }) => + db.title.findUnique({ + where: { id }, + }) + +export const createTitle: MutationResolvers['createTitle'] = ({ input }) => + db.title.create({ + data: input, + }) + +export const updateTitle: MutationResolvers['updateTitle'] = ({ id, input }) => + db.title.update({ + data: input, + where: { id }, + }) + +export const deleteTitle: MutationResolvers['deleteTitle'] = ({ id }) => + db.title.delete({ + where: { id }, + }) diff --git a/web/public/no_resume.pdf b/web/public/no_resume.pdf new file mode 100644 index 0000000..e5631eb Binary files /dev/null and b/web/public/no_resume.pdf differ diff --git a/web/src/Routes.tsx b/web/src/Routes.tsx index 5ac5725..4595bcf 100644 --- a/web/src/Routes.tsx +++ b/web/src/Routes.tsx @@ -3,7 +3,7 @@ import { Router, Route, Set, PrivateSet } from '@redwoodjs/router' import { useAuth } from 'src/auth' import AccountbarLayout from 'src/layouts/AccountbarLayout' import NavbarLayout from 'src/layouts/NavbarLayout/NavbarLayout' -import ScaffoldLayout from 'src/layouts/ScaffoldLayout' +import ScaffoldLayout from 'src/layouts/ScaffoldLayout/ScaffoldLayout' const Routes = () => { return ( @@ -27,11 +27,15 @@ const Routes = () => { + + + + - - + + @@ -49,9 +53,10 @@ const Routes = () => { - - + + + diff --git a/web/src/components/ContactCard/ContactCardCell/ContactCardCell.tsx b/web/src/components/ContactCard/ContactCardCell/ContactCardCell.tsx index 27ae50f..ea7c588 100644 --- a/web/src/components/ContactCard/ContactCardCell/ContactCardCell.tsx +++ b/web/src/components/ContactCard/ContactCardCell/ContactCardCell.tsx @@ -11,16 +11,14 @@ import CellFailure from 'src/components/Cell/CellFailure/CellFailure' import CellLoading from 'src/components/Cell/CellLoading/CellLoading' import ContactCard from 'src/components/ContactCard/ContactCard/ContactCard' -export const QUERY: TypedDocumentNode< - FindPortrait, - FindPortraitVariables -> = gql` - query ContactCardPortrait { - portrait: portrait { - fileId +export const QUERY: TypedDocumentNode = + gql` + query ContactCardPortrait { + portrait: portrait { + fileId + } } - } -` + ` export const Loading = () => diff --git a/web/src/components/Portrait/PortraitCell/PortraitCell.tsx b/web/src/components/Portrait/PortraitCell/PortraitCell.tsx index b716852..ab35c60 100644 --- a/web/src/components/Portrait/PortraitCell/PortraitCell.tsx +++ b/web/src/components/Portrait/PortraitCell/PortraitCell.tsx @@ -11,17 +11,15 @@ import CellFailure from 'src/components/Cell/CellFailure/CellFailure' import CellLoading from 'src/components/Cell/CellLoading/CellLoading' import PortraitForm from 'src/components/Portrait/PortraitForm/PortraitForm' -export const QUERY: TypedDocumentNode< - FindPortrait, - FindPortraitVariables -> = gql` - query FindPortrait { - portrait: portrait { - id - fileId +export const QUERY: TypedDocumentNode = + gql` + query FindPortrait { + portrait { + id + fileId + } } - } -` + ` export const Loading = () => diff --git a/web/src/components/Portrait/PortraitForm/PortraitForm.tsx b/web/src/components/Portrait/PortraitForm/PortraitForm.tsx index 8e2210d..0b9387d 100644 --- a/web/src/components/Portrait/PortraitForm/PortraitForm.tsx +++ b/web/src/components/Portrait/PortraitForm/PortraitForm.tsx @@ -55,8 +55,8 @@ const CREATE_PORTRAIT_MUTATION: TypedDocumentNode< } ` -const PortraitForm = (props: PortraitFormProps) => { - const [fileId, _setFileId] = useState(props.portrait?.fileId) +const PortraitForm = ({ portrait }: PortraitFormProps) => { + const [fileId, _setFileId] = useState(portrait?.fileId) const fileIdRef = useRef(fileId) const setFileId = (fileId: string) => { @@ -104,12 +104,12 @@ const PortraitForm = (props: PortraitFormProps) => { ) } - if (props.portrait?.fileId) + if (portrait?.fileId) return (
{`${process.env.FIRST_NAME}
@@ -119,7 +119,7 @@ const PortraitForm = (props: PortraitFormProps) => { className="btn btn-error btn-sm uppercase" onClick={() => { if (confirm('Are you sure?')) { - deleteFile(props.portrait?.fileId) + deleteFile(portrait?.fileId) deletePortrait() setFileId(null) } diff --git a/web/src/components/Project/AdminProject/AdminProject.tsx b/web/src/components/Project/AdminProject/AdminProject.tsx new file mode 100644 index 0000000..8a2a372 --- /dev/null +++ b/web/src/components/Project/AdminProject/AdminProject.tsx @@ -0,0 +1,160 @@ +import type { + DeleteProjectMutation, + DeleteProjectMutationVariables, + AdminFindProjectById, +} from 'types/graphql' + +import { Link, routes, navigate } from '@redwoodjs/router' +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, + DeleteProjectMutationVariables +> = gql` + mutation DeleteProjectMutation($id: Int!) { + deleteProject(id: $id) { + id + } + } +` + +interface Props { + project: NonNullable +} + +const AdminProject = ({ project }: Props) => { + const [deleteProject] = useMutation(DELETE_PROJECT_MUTATION, { + onCompleted: () => { + toast.success('Project deleted') + navigate(routes.adminProjects()) + }, + onError: (error) => toast.error(error.message), + }) + + const onDeleteClick = (id: DeleteProjectMutationVariables['id']) => { + if (confirm('Are you sure you want to delete project ' + id + '?')) { + batchDelete(project.images) + deleteProject({ variables: { id } }) + } + } + + return ( +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Project {project.id}: {project.title} +  
ID{project.id}
Title{project.title}
Description{project.description}
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} + + ))} +
+
+
+ +
+
+ ) +} + +export default AdminProject diff --git a/web/src/components/Project/AdminProjectCell/AdminProjectCell.tsx b/web/src/components/Project/AdminProjectCell/AdminProjectCell.tsx new file mode 100644 index 0000000..76126cc --- /dev/null +++ b/web/src/components/Project/AdminProjectCell/AdminProjectCell.tsx @@ -0,0 +1,49 @@ +import type { + AdminFindProjectById, + AdminFindProjectByIdVariables, +} from 'types/graphql' + +import type { + CellSuccessProps, + CellFailureProps, + TypedDocumentNode, +} from '@redwoodjs/web' + +import CellEmpty from 'src/components/Cell/CellEmpty/CellEmpty' +import CellFailure from 'src/components/Cell/CellFailure/CellFailure' +import CellLoading from 'src/components/Cell/CellLoading/CellLoading' +import Project from 'src/components/Project/AdminProject/AdminProject' + +export const QUERY: TypedDocumentNode< + AdminFindProjectById, + AdminFindProjectByIdVariables +> = gql` + query AdminFindProjectById($id: Int!) { + project: project(id: $id) { + id + title + description + date + links + images + tags { + id + tag + color + } + } + } +` + +export const Loading = () => +export const Empty = () => +export const Failure = ({ + error, +}: CellFailureProps) => ( + +) +export const Success = ({ + project, +}: CellSuccessProps) => ( + +) diff --git a/web/src/components/Project/Project/Project.tsx b/web/src/components/Project/Project/Project.tsx index da96a4e..aba020e 100644 --- a/web/src/components/Project/Project/Project.tsx +++ b/web/src/components/Project/Project/Project.tsx @@ -1,157 +1,80 @@ -import type { - DeleteProjectMutation, - DeleteProjectMutationVariables, - FindProjectById, -} from 'types/graphql' - -import { Link, routes, navigate } from '@redwoodjs/router' -import { useMutation } from '@redwoodjs/web' -import type { TypedDocumentNode } from '@redwoodjs/web' -import { toast } from '@redwoodjs/web/toast' +import { mdiLinkVariant } from '@mdi/js' +import Icon from '@mdi/react' +import { format, isAfter, startOfToday } from 'date-fns' +import type { FindProjectById } from 'types/graphql' import { calculateLuminance } from 'src/lib/color' -import { timeTag } from 'src/lib/formatters' -import { batchDelete } from 'src/lib/tus' - -const DELETE_PROJECT_MUTATION: TypedDocumentNode< - DeleteProjectMutation, - DeleteProjectMutationVariables -> = gql` - mutation DeleteProjectMutation($id: Int!) { - deleteProject(id: $id) { - id - } - } -` interface Props { project: NonNullable } const Project = ({ project }: Props) => { - const [deleteProject] = useMutation(DELETE_PROJECT_MUTATION, { - onCompleted: () => { - toast.success('Project deleted') - navigate(routes.adminProjects()) - }, - onError: (error) => toast.error(error.message), - }) - - const onDeleteClick = (id: DeleteProjectMutationVariables['id']) => { - if (confirm('Are you sure you want to delete project ' + id + '?')) { - batchDelete(project.images) - deleteProject({ variables: { id } }) - } - } - return ( -
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- Project {project.id}: {project.title} -  
ID{project.id}
Title{project.title}
Description{project.description}
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.title}

+
+ {isAfter(new Date(project.date), startOfToday()) && ( +
+ planned +
+ )} +
+ {format(project.date, 'PPP')} +
-
+
+ {project.images.map((image, i) => ( + - Edit - - - + + + ))}
) diff --git a/web/src/components/Project/ProjectCell/ProjectCell.tsx b/web/src/components/Project/ProjectCell/ProjectCell.tsx index 3ad80db..1db99d7 100644 --- a/web/src/components/Project/ProjectCell/ProjectCell.tsx +++ b/web/src/components/Project/ProjectCell/ProjectCell.tsx @@ -9,7 +9,7 @@ import type { import CellEmpty from 'src/components/Cell/CellEmpty/CellEmpty' import CellFailure from 'src/components/Cell/CellFailure/CellFailure' import CellLoading from 'src/components/Cell/CellLoading/CellLoading' -import Project from 'src/components/Project/Project' +import Project from 'src/components/Project/Project/Project' export const QUERY: TypedDocumentNode< FindProjectById, diff --git a/web/src/components/Project/Projects/Projects.tsx b/web/src/components/Project/Projects/Projects.tsx index 190d727..1262a88 100644 --- a/web/src/components/Project/Projects/Projects.tsx +++ b/web/src/components/Project/Projects/Projects.tsx @@ -1,5 +1,6 @@ import { mdiDotsVertical } from '@mdi/js' import Icon from '@mdi/react' +import { isAfter } from 'date-fns' import type { DeleteProjectMutation, DeleteProjectMutationVariables, @@ -57,101 +58,106 @@ const ProjectsList = ({ projects }: FindProjects) => { - {projects.map((project) => { - const actionButtons = ( - <> - - Show - - - Edit - - - + {projects + .slice() + .sort((a, b) => + isAfter(new Date(b.date), new Date(a.date)) ? 1 : -1 ) + .map((project) => { + const actionButtons = ( + <> + + Show + + + Edit + + + + ) - return ( - - {truncate(project.title)} - {truncate(project.description)} - {timeTag(project.date)} - {`${project.images.length} ${project.images.length === 1 ? 'image' : 'images'}`} - -
- {project.tags.map((tag, i) => ( + return ( + + {truncate(project.title)} + {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} +
+ ))} +
+ + +
+ {project.links.map((link, i) => ( + + {link} + + ))} +
+ + + +
0.5 - ? 'black' - : 'white', - }} + tabIndex={0} + role="button" + className="btn btn-square btn-ghost btn-sm lg:hidden" > - {tag.tag} +
- ))} -
- - -
- {project.links.map((link, i) => ( - - {link} - - ))} -
- - - -
-
- + +
-
- -
-
- - - ) - })} + + + ) + })}
diff --git a/web/src/components/Project/ProjectsShowcase/ProjectsShowcase.tsx b/web/src/components/Project/ProjectsShowcase/ProjectsShowcase.tsx index 22396be..81a07eb 100644 --- a/web/src/components/Project/ProjectsShowcase/ProjectsShowcase.tsx +++ b/web/src/components/Project/ProjectsShowcase/ProjectsShowcase.tsx @@ -1,3 +1,5 @@ +import { useLayoutEffect, useRef, useState } from 'react' + import { format, isAfter, startOfToday } from 'date-fns' import { FindProjects } from 'types/graphql' @@ -6,54 +8,101 @@ import { Link, routes } from '@redwoodjs/router' import AutoCarousel from 'src/components/AutoCarousel/AutoCarousel' import { calculateLuminance } from 'src/lib/color' -const ProjectsShowcase = ({ projects }: FindProjects) => ( -
- {projects - .slice() - .sort((a, b) => (isAfter(new Date(b.date), new Date(a.date)) ? 1 : -1)) - .map((project, i) => ( - -
- {project.images.length > 0 && ( - - )} -
-
-

{project.title}

-
-
{project.description}
-
-
- {isAfter(new Date(project.date), startOfToday()) && ( -
planned
- )} -
- {format(project.date, 'yyyy-MM-dd')} +const CARD_WIDTH = 384 + +const ProjectsShowcase = ({ projects }: FindProjects) => { + const ref = useRef(null) + const [columns, setColumns] = useState( + Math.max( + Math.floor(ref.current?.getBoundingClientRect().width / CARD_WIDTH), + 1 + ) + ) + + useLayoutEffect(() => { + const handleResize = () => + setColumns( + Math.max( + Math.floor(ref.current?.getBoundingClientRect().width / CARD_WIDTH), + 1 + ) + ) + + handleResize() + + window.addEventListener('resize', handleResize) + + return () => window.removeEventListener('resize', handleResize) + }, []) + + return ( +
+ {split( + projects + .slice() + .sort((a, b) => + isAfter(new Date(b.date), new Date(a.date)) ? 1 : -1 + ), + columns + ).map((projectChunk, i) => ( +
+ {projectChunk.map((project, j) => ( + +
+ {project.images.length > 0 && ( + + )} +
+
+

{project.title}

+
+
{project.description}
+
+
+ {isAfter(new Date(project.date), startOfToday()) && ( +
planned
+ )} +
+ {format(project.date, 'yyyy-MM-dd')} +
+
+
+ {project.tags.map((tag, i) => ( +
0.5 + ? 'black' + : 'white', + }} + > + {tag.tag} +
+ ))} +
-
- {project.tags.map((tag, i) => ( -
0.5 - ? 'black' - : 'white', - }} - > - {tag.tag} -
- ))} -
-
-
- + + ))} +
))} -
-) +
+ ) +} export default ProjectsShowcase + +function split(arr: T[], chunks: number): T[][] { + const result: T[][] = [] + const chunkSize = Math.ceil(arr.length / chunks) + + for (let i = 0; i < arr.length; i += chunkSize) { + result.push(arr.slice(i, i + chunkSize)) + } + + return result +} diff --git a/web/src/components/Resume/AdminResumeCell/AdminResumeCell.tsx b/web/src/components/Resume/AdminResumeCell/AdminResumeCell.tsx new file mode 100644 index 0000000..140a4e2 --- /dev/null +++ b/web/src/components/Resume/AdminResumeCell/AdminResumeCell.tsx @@ -0,0 +1,35 @@ +import type { AdminFindResume, AdminFindResumeVariables } from 'types/graphql' + +import type { + CellSuccessProps, + CellFailureProps, + TypedDocumentNode, +} from '@redwoodjs/web' + +import CellEmpty from 'src/components/Cell/CellEmpty/CellEmpty' +import CellFailure from 'src/components/Cell/CellFailure/CellFailure' +import CellLoading from 'src/components/Cell/CellLoading/CellLoading' +import ResumeForm from 'src/components/Resume/ResumeForm/ResumeForm' + +export const QUERY: TypedDocumentNode< + AdminFindResume, + AdminFindResumeVariables +> = gql` + query AdminFindResume { + resume { + id + fileId + } + } +` + +export const Loading = () => +export const Empty = () => +export const Failure = ({ + error, +}: CellFailureProps) => + +export const Success = ({ + resume, +}: CellSuccessProps) => + resume.id === -1 ? : diff --git a/web/src/components/Resume/Resume/Resume.tsx b/web/src/components/Resume/Resume/Resume.tsx new file mode 100644 index 0000000..eb4f6a4 --- /dev/null +++ b/web/src/components/Resume/Resume/Resume.tsx @@ -0,0 +1,26 @@ +import { useState } from 'react' + +import { Resume as ResumeType } from 'types/graphql' + +interface ResumeProps { + resume?: ResumeType +} + +const Resume = ({ resume }: ResumeProps) => { + const [fileId] = useState(resume?.fileId) + + return ( + + ) +} + +export default Resume diff --git a/web/src/components/Resume/ResumeCell/ResumeCell.tsx b/web/src/components/Resume/ResumeCell/ResumeCell.tsx new file mode 100644 index 0000000..6c6fac5 --- /dev/null +++ b/web/src/components/Resume/ResumeCell/ResumeCell.tsx @@ -0,0 +1,33 @@ +import type { FindResume, FindResumeVariables } from 'types/graphql' + +import type { + CellSuccessProps, + CellFailureProps, + TypedDocumentNode, +} from '@redwoodjs/web' + +import CellEmpty from 'src/components/Cell/CellEmpty/CellEmpty' +import CellFailure from 'src/components/Cell/CellFailure/CellFailure' +import CellLoading from 'src/components/Cell/CellLoading/CellLoading' +import Resume from 'src/components/Resume/Resume/Resume' + +export const QUERY: TypedDocumentNode = gql` + query FindResume { + resume { + id + fileId + } + } +` + +export const Loading = () => +export const Empty = () => +export const Failure = ({ error }: CellFailureProps) => ( + +) + +export const Success = ({ + resume, +}: CellSuccessProps) => ( + +) diff --git a/web/src/components/Resume/ResumeForm/ResumeForm.tsx b/web/src/components/Resume/ResumeForm/ResumeForm.tsx new file mode 100644 index 0000000..42239f4 --- /dev/null +++ b/web/src/components/Resume/ResumeForm/ResumeForm.tsx @@ -0,0 +1,202 @@ +import { useRef, useState } from 'react' + +import { Meta, UploadResult } from '@uppy/core' +import type { + CreateResumeMutation, + CreateResumeMutationVariables, + DeleteResumeMutation, + DeleteResumeMutationVariables, + FindResume, + FindResumeVariables, + Resume, +} from 'types/graphql' + +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 ResumeFormProps { + resume?: Resume +} + +export const QUERY: TypedDocumentNode = gql` + query ResumeForm { + resume { + id + fileId + } + } +` + +const DELETE_RESUME_MUTATION: TypedDocumentNode< + DeleteResumeMutation, + DeleteResumeMutationVariables +> = gql` + mutation DeleteResumeMutation { + deleteResume { + id + fileId + } + } +` + +const CREATE_RESUME_MUTATION: TypedDocumentNode< + CreateResumeMutation, + CreateResumeMutationVariables +> = gql` + mutation CreateResumeMutation($input: CreateResumeInput!) { + createResume(input: $input) { + id + fileId + } + } +` + +const ResumeForm = ({ resume }: ResumeFormProps) => { + const [fileId, _setFileId] = useState(resume?.fileId) + const fileIdRef = useRef(fileId) + + const setFileId = (fileId: string) => { + _setFileId(fileId) + fileIdRef.current = fileId + } + + const unloadAbortController = new AbortController() + + const [deleteResume, { loading: deleteLoading }] = useMutation( + DELETE_RESUME_MUTATION, + { + onCompleted: () => { + toast.success('Resume deleted') + }, + onError: (error) => { + toast.error(error.message) + }, + refetchQueries: [{ query: QUERY }], + awaitRefetchQueries: true, + } + ) + + const [createResume, { loading: createLoading }] = useMutation( + CREATE_RESUME_MUTATION, + { + onCompleted: () => toast.success('Resume saved'), + onError: (error) => toast.error(error.message), + refetchQueries: [{ query: QUERY }], + awaitRefetchQueries: true, + } + ) + + const onUploadComplete = ( + result: UploadResult> + ) => { + setFileId(result.successful[0]?.uploadURL) + window.addEventListener( + 'beforeunload', + (e) => handleBeforeUnload(e, [fileIdRef.current]), + { + once: true, + signal: unloadAbortController.signal, + } + ) + } + + if (resume?.fileId) + return ( +
+ +
+ +
+ + ) + else + return ( +
+ {!fileId ? ( + <> + + + ) : ( + + )} + {fileId && ( +
+ + +
+ )} + + ) +} + +export default ResumeForm diff --git a/web/src/components/Tag/Tags/Tags.tsx b/web/src/components/Tag/Tags/Tags.tsx index 6361c60..695dd6a 100644 --- a/web/src/components/Tag/Tags/Tags.tsx +++ b/web/src/components/Tag/Tags/Tags.tsx @@ -137,55 +137,6 @@ const TagsList = ({ tags }: FindTags) => { ) - // return ( - //
- // - // - // - // - // - // - // - // - // - // - // {tags.map((tag) => ( - // - // - // - // - // - // - // ))} - // - //
IdTagColor 
{truncate(tag.id)}{truncate(tag.tag)}{truncate(tag.color)} - // - //
- //
- // ) } export default TagsList diff --git a/web/src/components/Uploader/Uploader.tsx b/web/src/components/Uploader/Uploader.tsx index f3a50e3..001651b 100644 --- a/web/src/components/Uploader/Uploader.tsx +++ b/web/src/components/Uploader/Uploader.tsx @@ -11,6 +11,8 @@ import { isProduction } from '@redwoodjs/api/logger' import '@uppy/core/dist/style.min.css' import '@uppy/dashboard/dist/style.min.css' +type FileType = 'image' | 'pdf' + interface Props { onComplete?(result: UploadResult>): void width?: string | number @@ -19,6 +21,7 @@ interface Props { maxFiles?: number disabled?: boolean hidden?: boolean + type?: FileType } const apiDomain = isProduction @@ -33,16 +36,15 @@ const Uploader = ({ disabled = false, hidden = false, maxFiles = 1, + type = 'image', }: Props) => { const [uppy] = useState(() => { const instance = new Uppy({ restrictions: { - allowedFileTypes: [ - 'image/webp', - 'image/png', - 'image/jpg', - 'image/jpeg', - ], + allowedFileTypes: + type === 'image' + ? ['image/webp', 'image/png', 'image/jpg', 'image/jpeg'] + : type === 'pdf' && ['application/pdf'], maxNumberOfFiles: maxFiles, maxFileSize: 25 * 1024 * 1024, }, diff --git a/web/src/layouts/NavbarLayout/NavbarLayout.tsx b/web/src/layouts/NavbarLayout/NavbarLayout.tsx index c71569a..18313a8 100644 --- a/web/src/layouts/NavbarLayout/NavbarLayout.tsx +++ b/web/src/layouts/NavbarLayout/NavbarLayout.tsx @@ -24,6 +24,10 @@ const NavbarLayout = ({ children }: NavbarLayoutProps) => { name: 'Projects', path: routes.projects(), }, + { + name: 'Resume', + path: routes.resume(), + }, { name: 'Contact', path: routes.contact(), @@ -47,6 +51,10 @@ const NavbarLayout = ({ children }: NavbarLayoutProps) => { name: 'Portrait', path: routes.portrait(), }, + { + name: 'Resume', + path: routes.adminResume(), + }, ] const navbarButtons = () => diff --git a/web/src/pages/Project/AdminProjectPage/AdminProjectPage.tsx b/web/src/pages/Project/AdminProjectPage/AdminProjectPage.tsx new file mode 100644 index 0000000..4db9441 --- /dev/null +++ b/web/src/pages/Project/AdminProjectPage/AdminProjectPage.tsx @@ -0,0 +1,16 @@ +import { Metadata } from '@redwoodjs/web' + +import AdminProjectCell from 'src/components/Project/AdminProjectCell/AdminProjectCell' + +type ProjectPageProps = { + id: number +} + +const ProjectPage = ({ id }: ProjectPageProps) => ( + <> + + + +) + +export default ProjectPage diff --git a/web/src/pages/Project/AdminProjectsPage/AdminProjectsPage.tsx b/web/src/pages/Project/AdminProjectsPage/AdminProjectsPage.tsx new file mode 100644 index 0000000..ddc861a --- /dev/null +++ b/web/src/pages/Project/AdminProjectsPage/AdminProjectsPage.tsx @@ -0,0 +1,12 @@ +import { Metadata } from '@redwoodjs/web' + +import ProjectsCell from 'src/components/Project/ProjectsCell' + +const ProjectsPage = () => ( + <> + + + +) + +export default ProjectsPage diff --git a/web/src/pages/Project/ProjectPage/ProjectPage.tsx b/web/src/pages/Project/ProjectPage/ProjectPage.tsx index e5ebe22..8039527 100644 --- a/web/src/pages/Project/ProjectPage/ProjectPage.tsx +++ b/web/src/pages/Project/ProjectPage/ProjectPage.tsx @@ -2,15 +2,18 @@ import { Metadata } from '@redwoodjs/web' import ProjectCell from 'src/components/Project/ProjectCell' -type ProjectPageProps = { +interface ProjectPageProps { id: number } -const ProjectPage = ({ id }: ProjectPageProps) => ( - <> - - - -) +const ProjectPage = ({ id }: ProjectPageProps) => { + return ( + <> + + + + + ) +} export default ProjectPage diff --git a/web/src/pages/Project/ProjectsPage/ProjectsPage.tsx b/web/src/pages/Project/ProjectsPage/ProjectsPage.tsx index ddc861a..e622f7a 100644 --- a/web/src/pages/Project/ProjectsPage/ProjectsPage.tsx +++ b/web/src/pages/Project/ProjectsPage/ProjectsPage.tsx @@ -1,12 +1,29 @@ +import { isMobile, isBrowser } from 'react-device-detect' + import { Metadata } from '@redwoodjs/web' -import ProjectsCell from 'src/components/Project/ProjectsCell' +import ProjectsShowcaseCell from 'src/components/Project/ProjectsShowcaseCell' -const ProjectsPage = () => ( - <> - - - -) +const ProjectsPage = () => { + return ( + <> + + +
+
+
+

Projects

+

+ {isBrowser && !isMobile ? 'Click' : 'Tap'} on a project for + details +

+
+
+
+ + + + ) +} export default ProjectsPage diff --git a/web/src/pages/ProjectPage/ProjectPage.tsx b/web/src/pages/ProjectPage/ProjectPage.tsx deleted file mode 100644 index dd3bfb7..0000000 --- a/web/src/pages/ProjectPage/ProjectPage.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { Metadata } from '@redwoodjs/web' - -interface ProjectPageProps { - id: number -} - -// TODO: implement - -const ProjectPage = ({ id }: ProjectPageProps) => { - return ( - <> - - -

ProjectPage

-

- Find me in ./web/src/pages/ProjectPage/ProjectPage.tsx -

-

My id is: {id}

- {/* - My default route is named `project`, link to me with: - `Project` - */} - - ) -} - -export default ProjectPage diff --git a/web/src/pages/ProjectsPage/ProjectsPage.tsx b/web/src/pages/ProjectsPage/ProjectsPage.tsx deleted file mode 100644 index 34306ce..0000000 --- a/web/src/pages/ProjectsPage/ProjectsPage.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { isMobile, isBrowser } from 'react-device-detect' - -import { Metadata } from '@redwoodjs/web' - -import ProjectsShowcaseCell from 'src/components/Project/ProjectsShowcaseCell' - -const ProjectsPage = () => { - return ( - <> - - -
-
-
-

Projects

-

- {isBrowser && !isMobile ? 'Click' : 'Tap'} on a project for - details -

-
-
-
- - - - ) -} - -export default ProjectsPage diff --git a/web/src/pages/Resume/AdminResumePage/AdminResumePage.tsx b/web/src/pages/Resume/AdminResumePage/AdminResumePage.tsx new file mode 100644 index 0000000..36c289b --- /dev/null +++ b/web/src/pages/Resume/AdminResumePage/AdminResumePage.tsx @@ -0,0 +1,14 @@ +import { Metadata } from '@redwoodjs/web' + +import AdminResumeCell from 'src/components/Resume/AdminResumeCell/AdminResumeCell' + +const ResumePage = () => { + return ( + <> + + + + ) +} + +export default ResumePage diff --git a/web/src/pages/Resume/ResumePage/ResumePage.tsx b/web/src/pages/Resume/ResumePage/ResumePage.tsx new file mode 100644 index 0000000..07cc6d6 --- /dev/null +++ b/web/src/pages/Resume/ResumePage/ResumePage.tsx @@ -0,0 +1,15 @@ +import { Metadata } from '@redwoodjs/web' + +import ResumeCell from 'src/components/Resume/ResumeCell' + +const ResumePage = () => { + return ( + <> + + + + + ) +} + +export default ResumePage