Resume + Projects done
This commit is contained in:
@ -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")
|
||||||
|
);
|
@ -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
|
||||||
|
19
api/src/graphql/resume.sdl.ts
Normal file
19
api/src/graphql/resume.sdl.ts
Normal 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
|
||||||
|
}
|
||||||
|
`
|
25
api/src/graphql/titles.sdl.ts
Normal file
25
api/src/graphql/titles.sdl.ts
Normal 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
|
||||||
|
}
|
||||||
|
`
|
42
api/src/services/resume/resume.ts
Normal file
42
api/src/services/resume/resume.ts
Normal 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 },
|
||||||
|
})
|
||||||
|
}
|
26
api/src/services/titles/titles.ts
Normal file
26
api/src/services/titles/titles.ts
Normal 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
BIN
web/public/no_resume.pdf
Normal file
Binary file not shown.
@ -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} />
|
||||||
|
@ -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 />
|
||||||
|
|
||||||
|
@ -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 />
|
||||||
|
|
||||||
|
@ -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)
|
||||||
}
|
}
|
||||||
|
160
web/src/components/Project/AdminProject/AdminProject.tsx
Normal file
160
web/src/components/Project/AdminProject/AdminProject.tsx
Normal 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> </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
|
@ -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} />
|
||||||
|
)
|
@ -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> </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>
|
||||||
)
|
)
|
||||||
|
@ -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,
|
||||||
|
@ -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>
|
||||||
|
@ -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
|
||||||
|
}
|
||||||
|
@ -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} />
|
26
web/src/components/Resume/Resume/Resume.tsx
Normal file
26
web/src/components/Resume/Resume/Resume.tsx
Normal 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
|
33
web/src/components/Resume/ResumeCell/ResumeCell.tsx
Normal file
33
web/src/components/Resume/ResumeCell/ResumeCell.tsx
Normal 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} />
|
||||||
|
)
|
202
web/src/components/Resume/ResumeForm/ResumeForm.tsx
Normal file
202
web/src/components/Resume/ResumeForm/ResumeForm.tsx
Normal 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
|
@ -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> </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
|
||||||
|
@ -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,
|
||||||
},
|
},
|
||||||
|
@ -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 = () =>
|
||||||
|
16
web/src/pages/Project/AdminProjectPage/AdminProjectPage.tsx
Normal file
16
web/src/pages/Project/AdminProjectPage/AdminProjectPage.tsx
Normal 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
|
@ -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
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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
|
|
@ -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
|
|
14
web/src/pages/Resume/AdminResumePage/AdminResumePage.tsx
Normal file
14
web/src/pages/Resume/AdminResumePage/AdminResumePage.tsx
Normal 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
|
15
web/src/pages/Resume/ResumePage/ResumePage.tsx
Normal file
15
web/src/pages/Resume/ResumePage/ResumePage.tsx
Normal 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
|
Reference in New Issue
Block a user