Resume + Projects done

This commit is contained in:
Ahmed Al-Taiar
2024-10-01 20:45:43 -04:00
parent 9c0dee7d54
commit 4a94b6807e
32 changed files with 1034 additions and 431 deletions

View File

@ -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")
);

View File

@ -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

View File

@ -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
}
`

View File

@ -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
}
`

View File

@ -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 },
})
}

View File

@ -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 },
})

BIN
web/public/no_resume.pdf Normal file

Binary file not shown.

View File

@ -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 = () => {
<Route path="/admin/portrait" page={PortraitPortraitPage} name="portrait" />
</Set>
<Set wrap={ScaffoldLayout} title="Resume" titleTo="adminResume">
<Route path="/admin/resume" page={ResumeAdminResumePage} name="adminResume" />
</Set>
<Set wrap={ScaffoldLayout} title="Projects" titleTo="projects" buttonLabel="New Project" buttonTo="newProject">
<Route path="/admin/projects/new" page={ProjectNewProjectPage} name="newProject" />
<Route path="/admin/projects/{id:Int}/edit" page={ProjectEditProjectPage} name="editProject" />
<Route path="/admin/projects/{id:Int}" page={ProjectProjectPage} name="adminProject" />
<Route path="/admin/projects" page={ProjectProjectsPage} name="adminProjects" />
<Route path="/admin/projects/{id:Int}" page={ProjectAdminProjectPage} name="adminProject" />
<Route path="/admin/projects" page={ProjectAdminProjectsPage} name="adminProjects" />
</Set>
</PrivateSet>
@ -49,9 +53,10 @@ const Routes = () => {
<Set wrap={NavbarLayout}>
<Route path="/" page={HomePage} name="home" />
<Route path="/projects" page={ProjectsPage} name="projects" />
<Route path="/project/{id:Int}" page={ProjectPage} name="project" />
<Route path="/projects" page={ProjectProjectsPage} name="projects" />
<Route path="/project/{id:Int}" page={ProjectProjectPage} name="project" />
<Route path="/contact" page={ContactPage} name="contact" />
<Route path="/resume" page={ResumeResumePage} name="resume" />
</Set>
<Route notfound page={NotFoundPage} />

View File

@ -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`
export const QUERY: TypedDocumentNode<FindPortrait, FindPortraitVariables> =
gql`
query ContactCardPortrait {
portrait: portrait {
fileId
}
}
`
`
export const Loading = () => <CellLoading />

View File

@ -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`
export const QUERY: TypedDocumentNode<FindPortrait, FindPortraitVariables> =
gql`
query FindPortrait {
portrait: portrait {
portrait {
id
fileId
}
}
`
`
export const Loading = () => <CellLoading />

View File

@ -55,8 +55,8 @@ const CREATE_PORTRAIT_MUTATION: TypedDocumentNode<
}
`
const PortraitForm = (props: PortraitFormProps) => {
const [fileId, _setFileId] = useState<string>(props.portrait?.fileId)
const PortraitForm = ({ portrait }: PortraitFormProps) => {
const [fileId, _setFileId] = useState<string>(portrait?.fileId)
const fileIdRef = useRef<string>(fileId)
const setFileId = (fileId: string) => {
@ -104,12 +104,12 @@ const PortraitForm = (props: PortraitFormProps) => {
)
}
if (props.portrait?.fileId)
if (portrait?.fileId)
return (
<div className="mx-auto w-fit space-y-2">
<img
className="aspect-portrait max-w-2xl rounded-xl object-cover"
src={props.portrait?.fileId}
src={portrait?.fileId}
alt={`${process.env.FIRST_NAME} Portrait`}
/>
<div className="flex justify-center">
@ -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)
}

View File

@ -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<AdminFindProjectById['project']>
}
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 (
<div className="flex w-full justify-center">
<div>
<div className="overflow-hidden overflow-x-auto rounded-xl bg-base-100">
<table className="table">
<thead className="bg-base-200 font-syne">
<tr>
<th className="w-0">
Project {project.id}: {project.title}
</th>
<th>&nbsp;</th>
</tr>
</thead>
<tbody>
<tr>
<th>ID</th>
<td>{project.id}</td>
</tr>
<tr>
<th>Title</th>
<td>{project.title}</td>
</tr>
<tr>
<th>Description</th>
<td>{project.description}</td>
</tr>
<tr>
<th>Date</th>
<td>{timeTag(project.date)}</td>
</tr>
<tr>
<th>Images</th>
<td>
<div className="flex flex-wrap gap-2">
{project.images.map((image, i) => (
<a
key={i}
href={image}
target="_blank"
className="btn btn-sm btn-square"
rel="noreferrer"
>
{i + 1}
</a>
))}
</div>
</td>
</tr>
<tr>
<th>Tags</th>
<td>
<div className="flex flex-wrap gap-2">
{project.tags.map((tag, i) => (
<div
key={i}
className="badge whitespace-nowrap"
style={{
backgroundColor: tag.color,
color:
calculateLuminance(tag.color) > 0.5
? 'black'
: 'white',
}}
>
{tag.tag}
</div>
))}
</div>
</td>
</tr>
<tr>
<th>Links</th>
<td>
<div className="flex flex-wrap gap-2">
{project.links.map((link, i) => (
<a
href={link}
target="_blank"
className="badge badge-ghost text-nowrap"
key={i}
rel="noreferrer"
>
{link}
</a>
))}
</div>
</td>
</tr>
</tbody>
</table>
</div>
<nav className="my-2 flex justify-center space-x-2">
<Link
to={routes.editProject({ id: project.id })}
title={'Edit project ' + project.id}
className="btn btn-primary btn-sm uppercase"
>
Edit
</Link>
<button
type="button"
title={'Delete project ' + project.id}
className="btn btn-error btn-sm uppercase"
onClick={() => onDeleteClick(project.id)}
>
Delete
</button>
</nav>
</div>
</div>
)
}
export default AdminProject

View File

@ -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 = () => <CellLoading />
export const Empty = () => <CellEmpty />
export const Failure = ({
error,
}: CellFailureProps<AdminFindProjectByIdVariables>) => (
<CellFailure error={error} />
)
export const Success = ({
project,
}: CellSuccessProps<AdminFindProjectById, AdminFindProjectByIdVariables>) => (
<Project project={project} />
)

View File

@ -1,100 +1,30 @@
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<FindProjectById['project']>
}
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 (
<div className="flex w-full justify-center">
<div>
<div className="overflow-hidden overflow-x-auto rounded-xl bg-base-100">
<table className="table">
<thead className="bg-base-200 font-syne">
<tr>
<th className="w-0">
Project {project.id}: {project.title}
</th>
<th>&nbsp;</th>
</tr>
</thead>
<tbody>
<tr>
<th>ID</th>
<td>{project.id}</td>
</tr>
<tr>
<th>Title</th>
<td>{project.title}</td>
</tr>
<tr>
<th>Description</th>
<td>{project.description}</td>
</tr>
<tr>
<th>Date</th>
<td>{timeTag(project.date)}</td>
</tr>
<tr>
<th>Images</th>
<td>
<div className="grid grid-rows-1 grid-cols-1 sm:grid-cols-2">
<div className="flex flex-col gap-8 p-8">
<h1 className="text-5xl font-bold font-syne">{project.title}</h1>
<div className="flex flex-wrap gap-2">
{project.images.map((image, i) => (
<a
key={i}
href={image}
target="_blank"
className="btn btn-sm btn-square"
rel="noreferrer"
>
{i + 1}
</a>
))}
{isAfter(new Date(project.date), startOfToday()) && (
<div className="badge badge-lg badge-info whitespace-nowrap">
planned
</div>
</td>
</tr>
<tr>
<th>Tags</th>
<td>
)}
<div className="badge badge-lg badge-ghost whitespace-nowrap">
{format(project.date, 'PPP')}
</div>
</div>
{project.tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{project.tags.map((tag, i) => (
<div
@ -103,55 +33,48 @@ const Project = ({ project }: Props) => {
style={{
backgroundColor: tag.color,
color:
calculateLuminance(tag.color) > 0.5
? 'black'
: 'white',
calculateLuminance(tag.color) > 0.5 ? 'black' : 'white',
}}
>
{tag.tag}
</div>
))}
</div>
</td>
</tr>
<tr>
<th>Links</th>
<td>
<div className="flex flex-wrap gap-2">
)}
{project.description && <p>{project.description}</p>}
{project.links.length > 0 && (
<>
<h2 className="font-bold text-3xl">Links</h2>
<div className="flex flex-col gap-2">
{project.links.map((link, i) => (
<a
key={i}
href={link}
target="_blank"
className="badge badge-ghost text-nowrap"
key={i}
rel="noreferrer"
className="btn btn-wide"
>
<Icon path={mdiLinkVariant} className="size-5" />
{link}
</a>
))}
</div>
</td>
</tr>
</tbody>
</table>
</>
)}
<h2 className="sm:hidden font-bold text-3xl text-center">Images</h2>
</div>
<nav className="my-2 flex justify-center space-x-2">
<Link
to={routes.editProject({ id: project.id })}
title={'Edit project ' + project.id}
className="btn btn-primary btn-sm uppercase"
<div className="flex flex-col gap-4 items-center">
{project.images.map((image, i) => (
<a
href={image}
target="_blank"
rel="noreferrer"
key={i}
className="rounded-xl"
>
Edit
</Link>
<button
type="button"
title={'Delete project ' + project.id}
className="btn btn-error btn-sm uppercase"
onClick={() => onDeleteClick(project.id)}
>
Delete
</button>
</nav>
<img src={image} alt="" className="rounded-xl" />
</a>
))}
</div>
</div>
)

View File

@ -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,

View File

@ -1,5 +1,6 @@
import { mdiDotsVertical } from '@mdi/js'
import Icon from '@mdi/react'
import { isAfter } from 'date-fns'
import type {
DeleteProjectMutation,
DeleteProjectMutationVariables,
@ -57,7 +58,12 @@ const ProjectsList = ({ projects }: FindProjects) => {
</tr>
</thead>
<tbody>
{projects.map((project) => {
{projects
.slice()
.sort((a, b) =>
isAfter(new Date(b.date), new Date(a.date)) ? 1 : -1
)
.map((project) => {
const actionButtons = (
<>
<Link

View File

@ -1,3 +1,5 @@
import { useLayoutEffect, useRef, useState } from 'react'
import { format, isAfter, startOfToday } from 'date-fns'
import { FindProjects } from 'types/graphql'
@ -6,14 +8,47 @@ import { Link, routes } from '@redwoodjs/router'
import AutoCarousel from 'src/components/AutoCarousel/AutoCarousel'
import { calculateLuminance } from 'src/lib/color'
const ProjectsShowcase = ({ projects }: FindProjects) => (
<div className="flex flex-wrap justify-center gap-2">
{projects
const CARD_WIDTH = 384
const ProjectsShowcase = ({ projects }: FindProjects) => {
const ref = useRef<HTMLDivElement>(null)
const [columns, setColumns] = useState<number>(
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 (
<div ref={ref} className="flex flex-wrap justify-center gap-2">
{split(
projects
.slice()
.sort((a, b) => (isAfter(new Date(b.date), new Date(a.date)) ? 1 : -1))
.map((project, i) => (
<Link key={i} to={routes.project({ id: project.id })}>
<div className="card card-compact bg-base-100 w-96 h-fit shadow-xl transition-all hover:-translate-y-2 hover:shadow-2xl">
.sort((a, b) =>
isAfter(new Date(b.date), new Date(a.date)) ? 1 : -1
),
columns
).map((projectChunk, i) => (
<div className="flex flex-col gap-2" key={i}>
{projectChunk.map((project, j) => (
<Link key={`${i}-${j}`} to={routes.project({ id: project.id })}>
<div className="card card-compact bg-base-100 w-96 h-fit shadow-xl transition-all hover:-translate-y-1 hover:shadow-2xl">
{project.images.length > 0 && (
<AutoCarousel images={project.images} />
)}
@ -54,6 +89,20 @@ const ProjectsShowcase = ({ projects }: FindProjects) => (
</Link>
))}
</div>
)
))}
</div>
)
}
export default ProjectsShowcase
function split<T>(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
}

View File

@ -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 = () => <CellLoading />
export const Empty = () => <CellEmpty />
export const Failure = ({
error,
}: CellFailureProps<AdminFindResumeVariables>) => <CellFailure error={error} />
export const Success = ({
resume,
}: CellSuccessProps<AdminFindResume, AdminFindResumeVariables>) =>
resume.id === -1 ? <ResumeForm /> : <ResumeForm resume={resume} />

View File

@ -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<string>(resume?.fileId)
return (
<object
data={fileId}
type="application/pdf"
aria-label="Resume PDF"
style={{
width: 'calc(100vw - 1rem)',
height: 'calc(100vh - 6rem)',
}}
className="rounded-xl"
/>
)
}
export default Resume

View File

@ -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<FindResume, FindResumeVariables> = gql`
query FindResume {
resume {
id
fileId
}
}
`
export const Loading = () => <CellLoading />
export const Empty = () => <CellEmpty />
export const Failure = ({ error }: CellFailureProps<FindResumeVariables>) => (
<CellFailure error={error} />
)
export const Success = ({
resume,
}: CellSuccessProps<FindResume, FindResumeVariables>) => (
<Resume resume={resume} />
)

View File

@ -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<FindResume, FindResumeVariables> = 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<string>(resume?.fileId)
const fileIdRef = useRef<string>(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<Meta, Record<string, never>>
) => {
setFileId(result.successful[0]?.uploadURL)
window.addEventListener(
'beforeunload',
(e) => handleBeforeUnload(e, [fileIdRef.current]),
{
once: true,
signal: unloadAbortController.signal,
}
)
}
if (resume?.fileId)
return (
<div className="mx-auto w-fit space-y-2">
<object
data={resume?.fileId}
type="application/pdf"
aria-label="Resume PDF"
style={{
width: 'calc(100vw - 1rem)',
height: 'calc(100vh - 10rem)',
}}
className="rounded-xl"
/>
<div className="flex justify-center">
<button
type="button"
title="Delete resume"
className="btn btn-error btn-sm uppercase"
onClick={() => {
if (confirm('Are you sure?')) {
deleteFile(resume?.fileId)
deleteResume()
setFileId(null)
}
}}
disabled={deleteLoading}
>
Delete
</button>
</div>
</div>
)
else
return (
<div className="mx-auto w-fit space-y-2">
{!fileId ? (
<>
<Uploader
onComplete={onUploadComplete}
width="22rem"
height="11.5rem"
className="flex justify-center"
type="pdf"
/>
</>
) : (
<object
data={fileId}
type="application/pdf"
aria-label="Resume PDF"
style={{
width: 'calc(100vw - 1rem)',
height: 'calc(100vh - 10rem)',
}}
className="rounded-xl"
/>
)}
{fileId && (
<div className="flex justify-center space-x-2">
<button
className={`btn btn-sm ${!fileId && 'btn-disabled'} uppercase`}
disabled={!fileId || deleteLoading}
onClick={() => {
deleteFile(fileId)
setFileId(null)
unloadAbortController.abort()
console.log('aborted')
}}
>
Change
</button>
<button
className={`btn btn-primary btn-sm ${
!fileId && 'btn-disabled'
} uppercase`}
disabled={!fileId || createLoading}
onClick={() => {
createResume({
variables: {
input: {
fileId,
},
},
})
unloadAbortController.abort()
console.log('aborted')
}}
>
Submit
</button>
</div>
)}
</div>
)
}
export default ResumeForm

View File

@ -137,55 +137,6 @@ const TagsList = ({ tags }: FindTags) => {
</table>
</div>
)
// return (
// <div className="rw-segment rw-table-wrapper-responsive">
// <table className="rw-table">
// <thead>
// <tr>
// <th>Id</th>
// <th>Tag</th>
// <th>Color</th>
// <th>&nbsp;</th>
// </tr>
// </thead>
// <tbody>
// {tags.map((tag) => (
// <tr key={tag.id}>
// <td>{truncate(tag.id)}</td>
// <td>{truncate(tag.tag)}</td>
// <td>{truncate(tag.color)}</td>
// <td>
// <nav className="rw-table-actions">
// <Link
// to={routes.tag({ id: tag.id })}
// title={'Show tag ' + tag.id + ' detail'}
// className="rw-button rw-button-small"
// >
// Show
// </Link>
// <Link
// to={routes.editTag({ id: tag.id })}
// title={'Edit tag ' + tag.id}
// className="rw-button rw-button-small rw-button-blue"
// >
// Edit
// </Link>
// <button
// type="button"
// title={'Delete tag ' + tag.id}
// className="rw-button rw-button-small rw-button-red"
// onClick={() => onDeleteClick(tag.id)}
// >
// Delete
// </button>
// </nav>
// </td>
// </tr>
// ))}
// </tbody>
// </table>
// </div>
// )
}
export default TagsList

View File

@ -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<Meta, Record<string, never>>): 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,
},

View File

@ -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 = () =>

View File

@ -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) => (
<>
<Metadata title={`Project ${id}`} />
<AdminProjectCell id={id} />
</>
)
export default ProjectPage

View File

@ -0,0 +1,12 @@
import { Metadata } from '@redwoodjs/web'
import ProjectsCell from 'src/components/Project/ProjectsCell'
const ProjectsPage = () => (
<>
<Metadata title="Projects" />
<ProjectsCell />
</>
)
export default ProjectsPage

View File

@ -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 (
<>
<Metadata title={`Project ${id}`} />
<Metadata title="Project" />
<ProjectCell id={id} />
</>
)
)
}
export default ProjectPage

View File

@ -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 (
<>
<Metadata title="Projects" />
<ProjectsCell />
<div className="hero min-h-64">
<div className="hero-content">
<div className="max-w-md text-center">
<h1 className="text-5xl font-bold">Projects</h1>
<p className="py-6">
{isBrowser && !isMobile ? 'Click' : 'Tap'} on a project for
details
</p>
</div>
</div>
</div>
<ProjectsShowcaseCell />
</>
)
)
}
export default ProjectsPage

View File

@ -1,27 +0,0 @@
import { Metadata } from '@redwoodjs/web'
interface ProjectPageProps {
id: number
}
// TODO: implement
const ProjectPage = ({ id }: ProjectPageProps) => {
return (
<>
<Metadata title="Project" />
<h1>ProjectPage</h1>
<p>
Find me in <code>./web/src/pages/ProjectPage/ProjectPage.tsx</code>
</p>
<p>My id is: {id}</p>
{/*
My default route is named `project`, link to me with:
`<Link to={routes.project()}>Project</Link>`
*/}
</>
)
}
export default ProjectPage

View File

@ -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 (
<>
<Metadata title="Projects" />
<div className="hero min-h-48">
<div className="hero-content">
<div className="max-w-md text-center">
<h1 className="text-5xl font-bold">Projects</h1>
<p className="py-6">
{isBrowser && !isMobile ? 'Click' : 'Tap'} on a project for
details
</p>
</div>
</div>
</div>
<ProjectsShowcaseCell />
</>
)
}
export default ProjectsPage

View File

@ -0,0 +1,14 @@
import { Metadata } from '@redwoodjs/web'
import AdminResumeCell from 'src/components/Resume/AdminResumeCell/AdminResumeCell'
const ResumePage = () => {
return (
<>
<Metadata title="Resume" />
<AdminResumeCell />
</>
)
}
export default ResumePage

View File

@ -0,0 +1,15 @@
import { Metadata } from '@redwoodjs/web'
import ResumeCell from 'src/components/Resume/ResumeCell'
const ResumePage = () => {
return (
<>
<Metadata title="Resume" />
<ResumeCell />
</>
)
}
export default ResumePage