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
|
||||
}
|
||||
|
||||
model Resume {
|
||||
id Int @id @default(autoincrement())
|
||||
fileId String
|
||||
}
|
||||
|
||||
model Title {
|
||||
id Int @id @default(autoincrement())
|
||||
title String
|
||||
}
|
||||
|
||||
model Tag {
|
||||
id Int @id @default(autoincrement())
|
||||
tag String
|
||||
|
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 AccountbarLayout from 'src/layouts/AccountbarLayout'
|
||||
import NavbarLayout from 'src/layouts/NavbarLayout/NavbarLayout'
|
||||
import ScaffoldLayout from 'src/layouts/ScaffoldLayout'
|
||||
import ScaffoldLayout from 'src/layouts/ScaffoldLayout/ScaffoldLayout'
|
||||
|
||||
const Routes = () => {
|
||||
return (
|
||||
@ -27,11 +27,15 @@ const Routes = () => {
|
||||
<Route path="/admin/portrait" page={PortraitPortraitPage} name="portrait" />
|
||||
</Set>
|
||||
|
||||
<Set wrap={ScaffoldLayout} title="Resume" titleTo="adminResume">
|
||||
<Route path="/admin/resume" page={ResumeAdminResumePage} name="adminResume" />
|
||||
</Set>
|
||||
|
||||
<Set wrap={ScaffoldLayout} title="Projects" titleTo="projects" buttonLabel="New Project" buttonTo="newProject">
|
||||
<Route path="/admin/projects/new" page={ProjectNewProjectPage} name="newProject" />
|
||||
<Route path="/admin/projects/{id:Int}/edit" page={ProjectEditProjectPage} name="editProject" />
|
||||
<Route path="/admin/projects/{id:Int}" page={ProjectProjectPage} name="adminProject" />
|
||||
<Route path="/admin/projects" page={ProjectProjectsPage} name="adminProjects" />
|
||||
<Route path="/admin/projects/{id:Int}" page={ProjectAdminProjectPage} name="adminProject" />
|
||||
<Route path="/admin/projects" page={ProjectAdminProjectsPage} name="adminProjects" />
|
||||
</Set>
|
||||
</PrivateSet>
|
||||
|
||||
@ -49,9 +53,10 @@ const Routes = () => {
|
||||
|
||||
<Set wrap={NavbarLayout}>
|
||||
<Route path="/" page={HomePage} name="home" />
|
||||
<Route path="/projects" page={ProjectsPage} name="projects" />
|
||||
<Route path="/project/{id:Int}" page={ProjectPage} name="project" />
|
||||
<Route path="/projects" page={ProjectProjectsPage} name="projects" />
|
||||
<Route path="/project/{id:Int}" page={ProjectProjectPage} name="project" />
|
||||
<Route path="/contact" page={ContactPage} name="contact" />
|
||||
<Route path="/resume" page={ResumeResumePage} name="resume" />
|
||||
</Set>
|
||||
|
||||
<Route notfound page={NotFoundPage} />
|
||||
|
@ -11,16 +11,14 @@ import CellFailure from 'src/components/Cell/CellFailure/CellFailure'
|
||||
import CellLoading from 'src/components/Cell/CellLoading/CellLoading'
|
||||
import ContactCard from 'src/components/ContactCard/ContactCard/ContactCard'
|
||||
|
||||
export const QUERY: TypedDocumentNode<
|
||||
FindPortrait,
|
||||
FindPortraitVariables
|
||||
> = gql`
|
||||
query ContactCardPortrait {
|
||||
portrait: portrait {
|
||||
fileId
|
||||
export const QUERY: TypedDocumentNode<FindPortrait, FindPortraitVariables> =
|
||||
gql`
|
||||
query ContactCardPortrait {
|
||||
portrait: portrait {
|
||||
fileId
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
`
|
||||
|
||||
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 PortraitForm from 'src/components/Portrait/PortraitForm/PortraitForm'
|
||||
|
||||
export const QUERY: TypedDocumentNode<
|
||||
FindPortrait,
|
||||
FindPortraitVariables
|
||||
> = gql`
|
||||
query FindPortrait {
|
||||
portrait: portrait {
|
||||
id
|
||||
fileId
|
||||
export const QUERY: TypedDocumentNode<FindPortrait, FindPortraitVariables> =
|
||||
gql`
|
||||
query FindPortrait {
|
||||
portrait {
|
||||
id
|
||||
fileId
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
`
|
||||
|
||||
export const Loading = () => <CellLoading />
|
||||
|
||||
|
@ -55,8 +55,8 @@ const CREATE_PORTRAIT_MUTATION: TypedDocumentNode<
|
||||
}
|
||||
`
|
||||
|
||||
const PortraitForm = (props: PortraitFormProps) => {
|
||||
const [fileId, _setFileId] = useState<string>(props.portrait?.fileId)
|
||||
const PortraitForm = ({ portrait }: PortraitFormProps) => {
|
||||
const [fileId, _setFileId] = useState<string>(portrait?.fileId)
|
||||
const fileIdRef = useRef<string>(fileId)
|
||||
|
||||
const setFileId = (fileId: string) => {
|
||||
@ -104,12 +104,12 @@ const PortraitForm = (props: PortraitFormProps) => {
|
||||
)
|
||||
}
|
||||
|
||||
if (props.portrait?.fileId)
|
||||
if (portrait?.fileId)
|
||||
return (
|
||||
<div className="mx-auto w-fit space-y-2">
|
||||
<img
|
||||
className="aspect-portrait max-w-2xl rounded-xl object-cover"
|
||||
src={props.portrait?.fileId}
|
||||
src={portrait?.fileId}
|
||||
alt={`${process.env.FIRST_NAME} Portrait`}
|
||||
/>
|
||||
<div className="flex justify-center">
|
||||
@ -119,7 +119,7 @@ const PortraitForm = (props: PortraitFormProps) => {
|
||||
className="btn btn-error btn-sm uppercase"
|
||||
onClick={() => {
|
||||
if (confirm('Are you sure?')) {
|
||||
deleteFile(props.portrait?.fileId)
|
||||
deleteFile(portrait?.fileId)
|
||||
deletePortrait()
|
||||
setFileId(null)
|
||||
}
|
||||
|
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 {
|
||||
DeleteProjectMutation,
|
||||
DeleteProjectMutationVariables,
|
||||
FindProjectById,
|
||||
} from 'types/graphql'
|
||||
|
||||
import { Link, routes, navigate } from '@redwoodjs/router'
|
||||
import { useMutation } from '@redwoodjs/web'
|
||||
import type { TypedDocumentNode } from '@redwoodjs/web'
|
||||
import { toast } from '@redwoodjs/web/toast'
|
||||
import { mdiLinkVariant } from '@mdi/js'
|
||||
import Icon from '@mdi/react'
|
||||
import { format, isAfter, startOfToday } from 'date-fns'
|
||||
import type { FindProjectById } from 'types/graphql'
|
||||
|
||||
import { calculateLuminance } from 'src/lib/color'
|
||||
import { timeTag } from 'src/lib/formatters'
|
||||
import { batchDelete } from 'src/lib/tus'
|
||||
|
||||
const DELETE_PROJECT_MUTATION: TypedDocumentNode<
|
||||
DeleteProjectMutation,
|
||||
DeleteProjectMutationVariables
|
||||
> = gql`
|
||||
mutation DeleteProjectMutation($id: Int!) {
|
||||
deleteProject(id: $id) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
interface Props {
|
||||
project: NonNullable<FindProjectById['project']>
|
||||
}
|
||||
|
||||
const Project = ({ project }: Props) => {
|
||||
const [deleteProject] = useMutation(DELETE_PROJECT_MUTATION, {
|
||||
onCompleted: () => {
|
||||
toast.success('Project deleted')
|
||||
navigate(routes.adminProjects())
|
||||
},
|
||||
onError: (error) => toast.error(error.message),
|
||||
})
|
||||
|
||||
const onDeleteClick = (id: DeleteProjectMutationVariables['id']) => {
|
||||
if (confirm('Are you sure you want to delete project ' + id + '?')) {
|
||||
batchDelete(project.images)
|
||||
deleteProject({ variables: { id } })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full justify-center">
|
||||
<div>
|
||||
<div className="overflow-hidden overflow-x-auto rounded-xl bg-base-100">
|
||||
<table className="table">
|
||||
<thead className="bg-base-200 font-syne">
|
||||
<tr>
|
||||
<th className="w-0">
|
||||
Project {project.id}: {project.title}
|
||||
</th>
|
||||
<th> </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 className="grid grid-rows-1 grid-cols-1 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-8 p-8">
|
||||
<h1 className="text-5xl font-bold font-syne">{project.title}</h1>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{isAfter(new Date(project.date), startOfToday()) && (
|
||||
<div className="badge badge-lg badge-info whitespace-nowrap">
|
||||
planned
|
||||
</div>
|
||||
)}
|
||||
<div className="badge badge-lg badge-ghost whitespace-nowrap">
|
||||
{format(project.date, 'PPP')}
|
||||
</div>
|
||||
</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"
|
||||
{project.tags.length > 0 && (
|
||||
<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>
|
||||
)}
|
||||
{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
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
title={'Delete project ' + project.id}
|
||||
className="btn btn-error btn-sm uppercase"
|
||||
onClick={() => onDeleteClick(project.id)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</nav>
|
||||
<img src={image} alt="" className="rounded-xl" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
@ -9,7 +9,7 @@ import type {
|
||||
import CellEmpty from 'src/components/Cell/CellEmpty/CellEmpty'
|
||||
import CellFailure from 'src/components/Cell/CellFailure/CellFailure'
|
||||
import CellLoading from 'src/components/Cell/CellLoading/CellLoading'
|
||||
import Project from 'src/components/Project/Project'
|
||||
import Project from 'src/components/Project/Project/Project'
|
||||
|
||||
export const QUERY: TypedDocumentNode<
|
||||
FindProjectById,
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { mdiDotsVertical } from '@mdi/js'
|
||||
import Icon from '@mdi/react'
|
||||
import { isAfter } from 'date-fns'
|
||||
import type {
|
||||
DeleteProjectMutation,
|
||||
DeleteProjectMutationVariables,
|
||||
@ -57,101 +58,106 @@ const ProjectsList = ({ projects }: FindProjects) => {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{projects.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>
|
||||
</>
|
||||
{projects
|
||||
.slice()
|
||||
.sort((a, b) =>
|
||||
isAfter(new Date(b.date), new Date(a.date)) ? 1 : -1
|
||||
)
|
||||
.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 (
|
||||
<tr key={project.id}>
|
||||
<td>{truncate(project.title)}</td>
|
||||
<td className="max-w-72">{truncate(project.description)}</td>
|
||||
<td className="max-w-36">{timeTag(project.date)}</td>
|
||||
<td>{`${project.images.length} ${project.images.length === 1 ? 'image' : 'images'}`}</td>
|
||||
<td>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{project.tags.map((tag, i) => (
|
||||
return (
|
||||
<tr key={project.id}>
|
||||
<td>{truncate(project.title)}</td>
|
||||
<td className="max-w-72">{truncate(project.description)}</td>
|
||||
<td className="max-w-36">{timeTag(project.date)}</td>
|
||||
<td>{`${project.images.length} ${project.images.length === 1 ? 'image' : 'images'}`}</td>
|
||||
<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>
|
||||
<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
|
||||
key={i}
|
||||
className="badge whitespace-nowrap"
|
||||
style={{
|
||||
backgroundColor: tag.color,
|
||||
color:
|
||||
calculateLuminance(tag.color) > 0.5
|
||||
? 'black'
|
||||
: 'white',
|
||||
}}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
className="btn btn-square btn-ghost btn-sm lg:hidden"
|
||||
>
|
||||
{tag.tag}
|
||||
<Icon
|
||||
path={mdiDotsVertical}
|
||||
className="text-base-content-100 size-6"
|
||||
/>
|
||||
</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"
|
||||
<div
|
||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
|
||||
tabIndex={0}
|
||||
className="menu dropdown-content z-10 -mt-1 mr-10 inline rounded-box bg-base-100 shadow-xl"
|
||||
>
|
||||
{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
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
className="btn btn-square btn-ghost btn-sm lg:hidden"
|
||||
>
|
||||
<Icon
|
||||
path={mdiDotsVertical}
|
||||
className="text-base-content-100 size-6"
|
||||
/>
|
||||
<nav className="w-46 space-x-2">{actionButtons}</nav>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
|
||||
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>
|
||||
)
|
||||
})}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
@ -1,3 +1,5 @@
|
||||
import { useLayoutEffect, useRef, useState } from 'react'
|
||||
|
||||
import { format, isAfter, startOfToday } from 'date-fns'
|
||||
import { FindProjects } from 'types/graphql'
|
||||
|
||||
@ -6,54 +8,101 @@ import { Link, routes } from '@redwoodjs/router'
|
||||
import AutoCarousel from 'src/components/AutoCarousel/AutoCarousel'
|
||||
import { calculateLuminance } from 'src/lib/color'
|
||||
|
||||
const ProjectsShowcase = ({ projects }: FindProjects) => (
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
{projects
|
||||
.slice()
|
||||
.sort((a, b) => (isAfter(new Date(b.date), new Date(a.date)) ? 1 : -1))
|
||||
.map((project, i) => (
|
||||
<Link key={i} to={routes.project({ id: project.id })}>
|
||||
<div className="card card-compact bg-base-100 w-96 h-fit shadow-xl transition-all hover:-translate-y-2 hover:shadow-2xl">
|
||||
{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')}
|
||||
const CARD_WIDTH = 384
|
||||
|
||||
const ProjectsShowcase = ({ projects }: FindProjects) => {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const [columns, setColumns] = useState<number>(
|
||||
Math.max(
|
||||
Math.floor(ref.current?.getBoundingClientRect().width / CARD_WIDTH),
|
||||
1
|
||||
)
|
||||
)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const handleResize = () =>
|
||||
setColumns(
|
||||
Math.max(
|
||||
Math.floor(ref.current?.getBoundingClientRect().width / CARD_WIDTH),
|
||||
1
|
||||
)
|
||||
)
|
||||
|
||||
handleResize()
|
||||
|
||||
window.addEventListener('resize', handleResize)
|
||||
|
||||
return () => window.removeEventListener('resize', handleResize)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div ref={ref} className="flex flex-wrap justify-center gap-2">
|
||||
{split(
|
||||
projects
|
||||
.slice()
|
||||
.sort((a, b) =>
|
||||
isAfter(new Date(b.date), new Date(a.date)) ? 1 : -1
|
||||
),
|
||||
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 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>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProjectsShowcase
|
||||
|
||||
function split<T>(arr: T[], chunks: number): T[][] {
|
||||
const result: T[][] = []
|
||||
const chunkSize = Math.ceil(arr.length / chunks)
|
||||
|
||||
for (let i = 0; i < arr.length; i += chunkSize) {
|
||||
result.push(arr.slice(i, i + chunkSize))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
@ -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>
|
||||
</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
|
||||
|
@ -11,6 +11,8 @@ import { isProduction } from '@redwoodjs/api/logger'
|
||||
import '@uppy/core/dist/style.min.css'
|
||||
import '@uppy/dashboard/dist/style.min.css'
|
||||
|
||||
type FileType = 'image' | 'pdf'
|
||||
|
||||
interface Props {
|
||||
onComplete?(result: UploadResult<Meta, Record<string, never>>): void
|
||||
width?: string | number
|
||||
@ -19,6 +21,7 @@ interface Props {
|
||||
maxFiles?: number
|
||||
disabled?: boolean
|
||||
hidden?: boolean
|
||||
type?: FileType
|
||||
}
|
||||
|
||||
const apiDomain = isProduction
|
||||
@ -33,16 +36,15 @@ const Uploader = ({
|
||||
disabled = false,
|
||||
hidden = false,
|
||||
maxFiles = 1,
|
||||
type = 'image',
|
||||
}: Props) => {
|
||||
const [uppy] = useState(() => {
|
||||
const instance = new Uppy({
|
||||
restrictions: {
|
||||
allowedFileTypes: [
|
||||
'image/webp',
|
||||
'image/png',
|
||||
'image/jpg',
|
||||
'image/jpeg',
|
||||
],
|
||||
allowedFileTypes:
|
||||
type === 'image'
|
||||
? ['image/webp', 'image/png', 'image/jpg', 'image/jpeg']
|
||||
: type === 'pdf' && ['application/pdf'],
|
||||
maxNumberOfFiles: maxFiles,
|
||||
maxFileSize: 25 * 1024 * 1024,
|
||||
},
|
||||
|
@ -24,6 +24,10 @@ const NavbarLayout = ({ children }: NavbarLayoutProps) => {
|
||||
name: 'Projects',
|
||||
path: routes.projects(),
|
||||
},
|
||||
{
|
||||
name: 'Resume',
|
||||
path: routes.resume(),
|
||||
},
|
||||
{
|
||||
name: 'Contact',
|
||||
path: routes.contact(),
|
||||
@ -47,6 +51,10 @@ const NavbarLayout = ({ children }: NavbarLayoutProps) => {
|
||||
name: 'Portrait',
|
||||
path: routes.portrait(),
|
||||
},
|
||||
{
|
||||
name: 'Resume',
|
||||
path: routes.adminResume(),
|
||||
},
|
||||
]
|
||||
|
||||
const navbarButtons = () =>
|
||||
|
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'
|
||||
|
||||
type ProjectPageProps = {
|
||||
interface ProjectPageProps {
|
||||
id: number
|
||||
}
|
||||
|
||||
const ProjectPage = ({ id }: ProjectPageProps) => (
|
||||
<>
|
||||
<Metadata title={`Project ${id}`} />
|
||||
<ProjectCell id={id} />
|
||||
</>
|
||||
)
|
||||
const ProjectPage = ({ id }: ProjectPageProps) => {
|
||||
return (
|
||||
<>
|
||||
<Metadata title="Project" />
|
||||
|
||||
<ProjectCell id={id} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProjectPage
|
||||
|
@ -1,12 +1,29 @@
|
||||
import { isMobile, isBrowser } from 'react-device-detect'
|
||||
|
||||
import { Metadata } from '@redwoodjs/web'
|
||||
|
||||
import ProjectsCell from 'src/components/Project/ProjectsCell'
|
||||
import ProjectsShowcaseCell from 'src/components/Project/ProjectsShowcaseCell'
|
||||
|
||||
const ProjectsPage = () => (
|
||||
<>
|
||||
<Metadata title="Projects" />
|
||||
<ProjectsCell />
|
||||
</>
|
||||
)
|
||||
const ProjectsPage = () => {
|
||||
return (
|
||||
<>
|
||||
<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
|
||||
|
@ -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