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 fileId String
} }
model Resume {
id Int @id @default(autoincrement())
fileId String
}
model Title {
id Int @id @default(autoincrement())
title String
}
model Tag { model Tag {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
tag String 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 { useAuth } from 'src/auth'
import AccountbarLayout from 'src/layouts/AccountbarLayout' import AccountbarLayout from 'src/layouts/AccountbarLayout'
import NavbarLayout from 'src/layouts/NavbarLayout/NavbarLayout' import NavbarLayout from 'src/layouts/NavbarLayout/NavbarLayout'
import ScaffoldLayout from 'src/layouts/ScaffoldLayout' import ScaffoldLayout from 'src/layouts/ScaffoldLayout/ScaffoldLayout'
const Routes = () => { const Routes = () => {
return ( return (
@ -27,11 +27,15 @@ const Routes = () => {
<Route path="/admin/portrait" page={PortraitPortraitPage} name="portrait" /> <Route path="/admin/portrait" page={PortraitPortraitPage} name="portrait" />
</Set> </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"> <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/new" page={ProjectNewProjectPage} name="newProject" />
<Route path="/admin/projects/{id:Int}/edit" page={ProjectEditProjectPage} name="editProject" /> <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/{id:Int}" page={ProjectAdminProjectPage} name="adminProject" />
<Route path="/admin/projects" page={ProjectProjectsPage} name="adminProjects" /> <Route path="/admin/projects" page={ProjectAdminProjectsPage} name="adminProjects" />
</Set> </Set>
</PrivateSet> </PrivateSet>
@ -49,9 +53,10 @@ const Routes = () => {
<Set wrap={NavbarLayout}> <Set wrap={NavbarLayout}>
<Route path="/" page={HomePage} name="home" /> <Route path="/" page={HomePage} name="home" />
<Route path="/projects" page={ProjectsPage} name="projects" /> <Route path="/projects" page={ProjectProjectsPage} name="projects" />
<Route path="/project/{id:Int}" page={ProjectPage} name="project" /> <Route path="/project/{id:Int}" page={ProjectProjectPage} name="project" />
<Route path="/contact" page={ContactPage} name="contact" /> <Route path="/contact" page={ContactPage} name="contact" />
<Route path="/resume" page={ResumeResumePage} name="resume" />
</Set> </Set>
<Route notfound page={NotFoundPage} /> <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 CellLoading from 'src/components/Cell/CellLoading/CellLoading'
import ContactCard from 'src/components/ContactCard/ContactCard/ContactCard' import ContactCard from 'src/components/ContactCard/ContactCard/ContactCard'
export const QUERY: TypedDocumentNode< export const QUERY: TypedDocumentNode<FindPortrait, FindPortraitVariables> =
FindPortrait, gql`
FindPortraitVariables query ContactCardPortrait {
> = gql` portrait: portrait {
query ContactCardPortrait { fileId
portrait: portrait { }
fileId
} }
} `
`
export const Loading = () => <CellLoading /> 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 CellLoading from 'src/components/Cell/CellLoading/CellLoading'
import PortraitForm from 'src/components/Portrait/PortraitForm/PortraitForm' import PortraitForm from 'src/components/Portrait/PortraitForm/PortraitForm'
export const QUERY: TypedDocumentNode< export const QUERY: TypedDocumentNode<FindPortrait, FindPortraitVariables> =
FindPortrait, gql`
FindPortraitVariables query FindPortrait {
> = gql` portrait {
query FindPortrait { id
portrait: portrait { fileId
id }
fileId
} }
} `
`
export const Loading = () => <CellLoading /> export const Loading = () => <CellLoading />

View File

@ -55,8 +55,8 @@ const CREATE_PORTRAIT_MUTATION: TypedDocumentNode<
} }
` `
const PortraitForm = (props: PortraitFormProps) => { const PortraitForm = ({ portrait }: PortraitFormProps) => {
const [fileId, _setFileId] = useState<string>(props.portrait?.fileId) const [fileId, _setFileId] = useState<string>(portrait?.fileId)
const fileIdRef = useRef<string>(fileId) const fileIdRef = useRef<string>(fileId)
const setFileId = (fileId: string) => { const setFileId = (fileId: string) => {
@ -104,12 +104,12 @@ const PortraitForm = (props: PortraitFormProps) => {
) )
} }
if (props.portrait?.fileId) if (portrait?.fileId)
return ( return (
<div className="mx-auto w-fit space-y-2"> <div className="mx-auto w-fit space-y-2">
<img <img
className="aspect-portrait max-w-2xl rounded-xl object-cover" className="aspect-portrait max-w-2xl rounded-xl object-cover"
src={props.portrait?.fileId} src={portrait?.fileId}
alt={`${process.env.FIRST_NAME} Portrait`} alt={`${process.env.FIRST_NAME} Portrait`}
/> />
<div className="flex justify-center"> <div className="flex justify-center">
@ -119,7 +119,7 @@ const PortraitForm = (props: PortraitFormProps) => {
className="btn btn-error btn-sm uppercase" className="btn btn-error btn-sm uppercase"
onClick={() => { onClick={() => {
if (confirm('Are you sure?')) { if (confirm('Are you sure?')) {
deleteFile(props.portrait?.fileId) deleteFile(portrait?.fileId)
deletePortrait() deletePortrait()
setFileId(null) 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,157 +1,80 @@
import type { import { mdiLinkVariant } from '@mdi/js'
DeleteProjectMutation, import Icon from '@mdi/react'
DeleteProjectMutationVariables, import { format, isAfter, startOfToday } from 'date-fns'
FindProjectById, import type { FindProjectById } from 'types/graphql'
} 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 { 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 { interface Props {
project: NonNullable<FindProjectById['project']> project: NonNullable<FindProjectById['project']>
} }
const Project = ({ project }: Props) => { 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 ( return (
<div className="flex w-full justify-center"> <div className="grid grid-rows-1 grid-cols-1 sm:grid-cols-2">
<div> <div className="flex flex-col gap-8 p-8">
<div className="overflow-hidden overflow-x-auto rounded-xl bg-base-100"> <h1 className="text-5xl font-bold font-syne">{project.title}</h1>
<table className="table"> <div className="flex flex-wrap gap-2">
<thead className="bg-base-200 font-syne"> {isAfter(new Date(project.date), startOfToday()) && (
<tr> <div className="badge badge-lg badge-info whitespace-nowrap">
<th className="w-0"> planned
Project {project.id}: {project.title} </div>
</th> )}
<th>&nbsp;</th> <div className="badge badge-lg badge-ghost whitespace-nowrap">
</tr> {format(project.date, 'PPP')}
</thead> </div>
<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> </div>
<nav className="my-2 flex justify-center space-x-2"> {project.tags.length > 0 && (
<Link <div className="flex flex-wrap gap-2">
to={routes.editProject({ id: project.id })} {project.tags.map((tag, i) => (
title={'Edit project ' + project.id} <div
className="btn btn-primary btn-sm uppercase" key={i}
className="badge whitespace-nowrap"
style={{
backgroundColor: tag.color,
color:
calculateLuminance(tag.color) > 0.5 ? 'black' : 'white',
}}
>
{tag.tag}
</div>
))}
</div>
)}
{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"
rel="noreferrer"
className="btn btn-wide"
>
<Icon path={mdiLinkVariant} className="size-5" />
{link}
</a>
))}
</div>
</>
)}
<h2 className="sm:hidden font-bold text-3xl text-center">Images</h2>
</div>
<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 <img src={image} alt="" className="rounded-xl" />
</Link> </a>
<button ))}
type="button"
title={'Delete project ' + project.id}
className="btn btn-error btn-sm uppercase"
onClick={() => onDeleteClick(project.id)}
>
Delete
</button>
</nav>
</div> </div>
</div> </div>
) )

View File

@ -9,7 +9,7 @@ import type {
import CellEmpty from 'src/components/Cell/CellEmpty/CellEmpty' import CellEmpty from 'src/components/Cell/CellEmpty/CellEmpty'
import CellFailure from 'src/components/Cell/CellFailure/CellFailure' import CellFailure from 'src/components/Cell/CellFailure/CellFailure'
import CellLoading from 'src/components/Cell/CellLoading/CellLoading' 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< export const QUERY: TypedDocumentNode<
FindProjectById, FindProjectById,

View File

@ -1,5 +1,6 @@
import { mdiDotsVertical } from '@mdi/js' import { mdiDotsVertical } from '@mdi/js'
import Icon from '@mdi/react' import Icon from '@mdi/react'
import { isAfter } from 'date-fns'
import type { import type {
DeleteProjectMutation, DeleteProjectMutation,
DeleteProjectMutationVariables, DeleteProjectMutationVariables,
@ -57,101 +58,106 @@ const ProjectsList = ({ projects }: FindProjects) => {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{projects.map((project) => { {projects
const actionButtons = ( .slice()
<> .sort((a, b) =>
<Link isAfter(new Date(b.date), new Date(a.date)) ? 1 : -1
to={routes.adminProject({ id: project.id })}
title={'Show project ' + project.id + ' detail'}
className="btn btn-xs uppercase"
>
Show
</Link>
<Link
to={routes.editProject({ id: project.id })}
title={'Edit project ' + project.id}
className="btn btn-primary btn-xs uppercase"
>
Edit
</Link>
<button
type="button"
title={'Delete projectt ' + project.id}
className="btn btn-error btn-xs uppercase"
onClick={() => onDeleteClick(project.id)}
>
Delete
</button>
</>
) )
.map((project) => {
const actionButtons = (
<>
<Link
to={routes.adminProject({ id: project.id })}
title={'Show project ' + project.id + ' detail'}
className="btn btn-xs uppercase"
>
Show
</Link>
<Link
to={routes.editProject({ id: project.id })}
title={'Edit project ' + project.id}
className="btn btn-primary btn-xs uppercase"
>
Edit
</Link>
<button
type="button"
title={'Delete projectt ' + project.id}
className="btn btn-error btn-xs uppercase"
onClick={() => onDeleteClick(project.id)}
>
Delete
</button>
</>
)
return ( return (
<tr key={project.id}> <tr key={project.id}>
<td>{truncate(project.title)}</td> <td>{truncate(project.title)}</td>
<td className="max-w-72">{truncate(project.description)}</td> <td className="max-w-72">{truncate(project.description)}</td>
<td className="max-w-36">{timeTag(project.date)}</td> <td className="max-w-36">{timeTag(project.date)}</td>
<td>{`${project.images.length} ${project.images.length === 1 ? 'image' : 'images'}`}</td> <td>{`${project.images.length} ${project.images.length === 1 ? 'image' : 'images'}`}</td>
<td> <td>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{project.tags.map((tag, i) => ( {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>
<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>
<td>
<nav className="hidden justify-end space-x-2 md:flex">
{actionButtons}
</nav>
<div className="dropdown dropdown-end flex justify-end md:hidden">
<div <div
key={i} tabIndex={0}
className="badge whitespace-nowrap" role="button"
style={{ className="btn btn-square btn-ghost btn-sm lg:hidden"
backgroundColor: tag.color,
color:
calculateLuminance(tag.color) > 0.5
? 'black'
: 'white',
}}
> >
{tag.tag} <Icon
path={mdiDotsVertical}
className="text-base-content-100 size-6"
/>
</div> </div>
))} <div
</div> // eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
</td> tabIndex={0}
<td> className="menu dropdown-content z-10 -mt-1 mr-10 inline rounded-box bg-base-100 shadow-xl"
<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} <nav className="w-46 space-x-2">{actionButtons}</nav>
</a> </div>
))}
</div>
</td>
<td>
<nav className="hidden justify-end space-x-2 md:flex">
{actionButtons}
</nav>
<div className="dropdown dropdown-end flex justify-end md:hidden">
<div
tabIndex={0}
role="button"
className="btn btn-square btn-ghost btn-sm lg:hidden"
>
<Icon
path={mdiDotsVertical}
className="text-base-content-100 size-6"
/>
</div> </div>
<div </td>
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex </tr>
tabIndex={0} )
className="menu dropdown-content z-10 -mt-1 mr-10 inline rounded-box bg-base-100 shadow-xl" })}
>
<nav className="w-46 space-x-2">{actionButtons}</nav>
</div>
</div>
</td>
</tr>
)
})}
</tbody> </tbody>
</table> </table>
</div> </div>

View File

@ -1,3 +1,5 @@
import { useLayoutEffect, useRef, useState } from 'react'
import { format, isAfter, startOfToday } from 'date-fns' import { format, isAfter, startOfToday } from 'date-fns'
import { FindProjects } from 'types/graphql' import { FindProjects } from 'types/graphql'
@ -6,54 +8,101 @@ import { Link, routes } from '@redwoodjs/router'
import AutoCarousel from 'src/components/AutoCarousel/AutoCarousel' import AutoCarousel from 'src/components/AutoCarousel/AutoCarousel'
import { calculateLuminance } from 'src/lib/color' import { calculateLuminance } from 'src/lib/color'
const ProjectsShowcase = ({ projects }: FindProjects) => ( const CARD_WIDTH = 384
<div className="flex flex-wrap justify-center gap-2">
{projects const ProjectsShowcase = ({ projects }: FindProjects) => {
.slice() const ref = useRef<HTMLDivElement>(null)
.sort((a, b) => (isAfter(new Date(b.date), new Date(a.date)) ? 1 : -1)) const [columns, setColumns] = useState<number>(
.map((project, i) => ( Math.max(
<Link key={i} to={routes.project({ id: project.id })}> Math.floor(ref.current?.getBoundingClientRect().width / CARD_WIDTH),
<div className="card card-compact bg-base-100 w-96 h-fit shadow-xl transition-all hover:-translate-y-2 hover:shadow-2xl"> 1
{project.images.length > 0 && ( )
<AutoCarousel images={project.images} /> )
)}
<div className="card-body"> useLayoutEffect(() => {
<div className="card-title overflow-auto"> const handleResize = () =>
<p className="whitespace-nowrap">{project.title}</p> setColumns(
</div> Math.max(
<div className="line-clamp-5">{project.description}</div> Math.floor(ref.current?.getBoundingClientRect().width / CARD_WIDTH),
<div className="card-actions justify-between"> 1
<div className="flex gap-2"> )
{isAfter(new Date(project.date), startOfToday()) && ( )
<div className="badge badge-info">planned</div>
)} handleResize()
<div className="badge badge-ghost">
{format(project.date, 'yyyy-MM-dd')} 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
),
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} />
)}
<div className="card-body">
<div className="card-title overflow-auto">
<p className="whitespace-nowrap">{project.title}</p>
</div>
<div className="line-clamp-5">{project.description}</div>
<div className="card-actions justify-between">
<div className="flex gap-2">
{isAfter(new Date(project.date), startOfToday()) && (
<div className="badge badge-info">planned</div>
)}
<div className="badge badge-ghost">
{format(project.date, 'yyyy-MM-dd')}
</div>
</div>
<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>
</div> </div>
</div> </div>
<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>
</div> </div>
</div> </Link>
</div> ))}
</Link> </div>
))} ))}
</div> </div>
) )
}
export default ProjectsShowcase 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> </table>
</div> </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 export default TagsList

View File

@ -11,6 +11,8 @@ import { isProduction } from '@redwoodjs/api/logger'
import '@uppy/core/dist/style.min.css' import '@uppy/core/dist/style.min.css'
import '@uppy/dashboard/dist/style.min.css' import '@uppy/dashboard/dist/style.min.css'
type FileType = 'image' | 'pdf'
interface Props { interface Props {
onComplete?(result: UploadResult<Meta, Record<string, never>>): void onComplete?(result: UploadResult<Meta, Record<string, never>>): void
width?: string | number width?: string | number
@ -19,6 +21,7 @@ interface Props {
maxFiles?: number maxFiles?: number
disabled?: boolean disabled?: boolean
hidden?: boolean hidden?: boolean
type?: FileType
} }
const apiDomain = isProduction const apiDomain = isProduction
@ -33,16 +36,15 @@ const Uploader = ({
disabled = false, disabled = false,
hidden = false, hidden = false,
maxFiles = 1, maxFiles = 1,
type = 'image',
}: Props) => { }: Props) => {
const [uppy] = useState(() => { const [uppy] = useState(() => {
const instance = new Uppy({ const instance = new Uppy({
restrictions: { restrictions: {
allowedFileTypes: [ allowedFileTypes:
'image/webp', type === 'image'
'image/png', ? ['image/webp', 'image/png', 'image/jpg', 'image/jpeg']
'image/jpg', : type === 'pdf' && ['application/pdf'],
'image/jpeg',
],
maxNumberOfFiles: maxFiles, maxNumberOfFiles: maxFiles,
maxFileSize: 25 * 1024 * 1024, maxFileSize: 25 * 1024 * 1024,
}, },

View File

@ -24,6 +24,10 @@ const NavbarLayout = ({ children }: NavbarLayoutProps) => {
name: 'Projects', name: 'Projects',
path: routes.projects(), path: routes.projects(),
}, },
{
name: 'Resume',
path: routes.resume(),
},
{ {
name: 'Contact', name: 'Contact',
path: routes.contact(), path: routes.contact(),
@ -47,6 +51,10 @@ const NavbarLayout = ({ children }: NavbarLayoutProps) => {
name: 'Portrait', name: 'Portrait',
path: routes.portrait(), path: routes.portrait(),
}, },
{
name: 'Resume',
path: routes.adminResume(),
},
] ]
const navbarButtons = () => 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' import ProjectCell from 'src/components/Project/ProjectCell'
type ProjectPageProps = { interface ProjectPageProps {
id: number id: number
} }
const ProjectPage = ({ id }: ProjectPageProps) => ( const ProjectPage = ({ id }: ProjectPageProps) => {
<> return (
<Metadata title={`Project ${id}`} /> <>
<ProjectCell id={id} /> <Metadata title="Project" />
</>
) <ProjectCell id={id} />
</>
)
}
export default ProjectPage export default ProjectPage

View File

@ -1,12 +1,29 @@
import { isMobile, isBrowser } from 'react-device-detect'
import { Metadata } from '@redwoodjs/web' 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 /> <Metadata title="Projects" />
</>
) <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 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