Public facing projects showcase, individual project details not done yet

This commit is contained in:
Ahmed Al-Taiar
2024-09-29 15:38:26 -04:00
parent 38168db452
commit 9c0dee7d54
20 changed files with 287 additions and 38 deletions

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Project" ALTER COLUMN "images" SET DEFAULT ARRAY[]::TEXT[];

View File

@ -69,7 +69,7 @@ model Project {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
title String title String
description String @default("No description provided") description String @default("No description provided")
images String[] images String[] @default([])
date DateTime date DateTime
links String[] @default([]) links String[] @default([])
tags Tag[] tags Tag[]

View File

@ -10,8 +10,8 @@ export const schema = gql`
} }
type Query { type Query {
projects: [Project!]! @requireAuth projects: [Project!]! @skipAuth
project(id: Int!): Project @requireAuth project(id: Int!): Project @skipAuth
} }
input CreateProjectInput { input CreateProjectInput {

View File

@ -7,8 +7,8 @@ export const schema = gql`
} }
type Query { type Query {
tags: [Tag!]! @requireAuth tags: [Tag!]! @skipAuth
tag(id: Int!): Tag @requireAuth tag(id: Int!): Tag @skipAuth
} }
input CreateTagInput { input CreateTagInput {

View File

@ -31,6 +31,7 @@
"humanize-string": "2.1.0", "humanize-string": "2.1.0",
"react": "18.3.1", "react": "18.3.1",
"react-colorful": "^5.6.1", "react-colorful": "^5.6.1",
"react-device-detect": "^2.2.3",
"react-dom": "18.3.1" "react-dom": "18.3.1"
}, },
"devDependencies": { "devDependencies": {

View File

@ -30,8 +30,8 @@ const Routes = () => {
<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="project" /> <Route path="/admin/projects/{id:Int}" page={ProjectProjectPage} name="adminProject" />
<Route path="/admin/projects" page={ProjectProjectsPage} name="projects" /> <Route path="/admin/projects" page={ProjectProjectsPage} name="adminProjects" />
</Set> </Set>
</PrivateSet> </PrivateSet>
@ -49,6 +49,8 @@ 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="/project/{id:Int}" page={ProjectPage} name="project" />
<Route path="/contact" page={ContactPage} name="contact" /> <Route path="/contact" page={ContactPage} name="contact" />
</Set> </Set>

View File

@ -0,0 +1,56 @@
import { useState, useRef, useCallback, useEffect } from 'react'
const SCROLL_INTERVAL_SECONDS = 3
interface AutoCarouselProps {
images: string[]
}
const AutoCarousel = ({ images }: AutoCarouselProps) => {
const [activeItem, setActiveItem] = useState<number>(0)
const ref = useRef<HTMLDivElement>(null)
const scroll = useCallback(() => {
setActiveItem((prev) => {
if (images.length - 1 > prev) return prev + 1
else return 0
})
}, [images.length])
const autoScroll = useCallback(
() => setInterval(scroll, SCROLL_INTERVAL_SECONDS * 1000),
[scroll]
)
useEffect(() => {
const play = autoScroll()
return () => clearInterval(play)
}, [autoScroll])
useEffect(() => {
const width = ref.current?.getBoundingClientRect().width
ref.current?.scroll({ left: activeItem * (width || 0) })
}, [activeItem])
return (
<div
ref={ref}
className="carousel carousel-center p-2 space-x-2 rounded-box"
>
{images.map((image, i) => (
<div
key={i}
className="carousel-item w-full h-fit my-auto justify-center"
>
<img
src={image}
alt={`${i}`}
className="object-contain rounded-xl size-fit"
/>
</div>
))}
</div>
)
}
export default AutoCarousel

View File

@ -61,7 +61,7 @@ export const Success = ({ project }: CellSuccessProps<EditProjectById>) => {
{ {
onCompleted: () => { onCompleted: () => {
toast.success('Project updated') toast.success('Project updated')
navigate(routes.projects()) navigate(routes.adminProjects())
}, },
onError: (error) => toast.error(error.message), onError: (error) => toast.error(error.message),
} }

View File

@ -28,7 +28,7 @@ const NewProject = () => {
{ {
onCompleted: () => { onCompleted: () => {
toast.success('Project created') toast.success('Project created')
navigate(routes.projects()) navigate(routes.adminProjects())
}, },
onError: (error) => toast.error(error.message), onError: (error) => toast.error(error.message),
} }

View File

@ -32,7 +32,7 @@ const Project = ({ project }: Props) => {
const [deleteProject] = useMutation(DELETE_PROJECT_MUTATION, { const [deleteProject] = useMutation(DELETE_PROJECT_MUTATION, {
onCompleted: () => { onCompleted: () => {
toast.success('Project deleted') toast.success('Project deleted')
navigate(routes.projects()) navigate(routes.adminProjects())
}, },
onError: (error) => toast.error(error.message), onError: (error) => toast.error(error.message),
}) })
@ -99,7 +99,7 @@ const Project = ({ project }: Props) => {
{project.tags.map((tag, i) => ( {project.tags.map((tag, i) => (
<div <div
key={i} key={i}
className="badge" className="badge whitespace-nowrap"
style={{ style={{
backgroundColor: tag.color, backgroundColor: tag.color,
color: color:

View File

@ -235,11 +235,10 @@ const ProjectForm = (props: ProjectFormProps) => {
</div> </div>
{fileIds.length > 0 ? ( {fileIds.length > 0 ? (
<> <>
{fileIds.map((fileId, i) => ( {!appendUploader &&
<div fileIds.map((fileId, i) => (
key={i} <div key={i} className="flex justify-center">
className="card rounded-xl image-full image-full-no-overlay" <div className="card rounded-xl w-fit image-full image-full-no-overlay">
>
<figure> <figure>
<img src={fileId} alt={i.toString()} /> <img src={fileId} alt={i.toString()} />
</figure> </figure>
@ -253,11 +252,15 @@ const ProjectForm = (props: ProjectFormProps) => {
setFileIds(fileIds.filter((id) => id !== fileId)) setFileIds(fileIds.filter((id) => id !== fileId))
}} }}
> >
<Icon path={mdiDelete} className="size-4 text-error" /> <Icon
path={mdiDelete}
className="size-4 text-error"
/>
</button> </button>
</div> </div>
</div> </div>
</div> </div>
</div>
))} ))}
{appendUploader && ( {appendUploader && (
<Uploader <Uploader

View File

@ -61,7 +61,7 @@ const ProjectsList = ({ projects }: FindProjects) => {
const actionButtons = ( const actionButtons = (
<> <>
<Link <Link
to={routes.project({ id: project.id })} to={routes.adminProject({ id: project.id })}
title={'Show project ' + project.id + ' detail'} title={'Show project ' + project.id + ' detail'}
className="btn btn-xs uppercase" className="btn btn-xs uppercase"
> >
@ -96,7 +96,7 @@ const ProjectsList = ({ projects }: FindProjects) => {
{project.tags.map((tag, i) => ( {project.tags.map((tag, i) => (
<div <div
key={i} key={i}
className="badge" className="badge whitespace-nowrap"
style={{ style={{
backgroundColor: tag.color, backgroundColor: tag.color,
color: color:

View File

@ -0,0 +1,59 @@
import { format, isAfter, startOfToday } from 'date-fns'
import { FindProjects } from 'types/graphql'
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')}
</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>
)
export default ProjectsShowcase

View File

@ -0,0 +1,44 @@
import type { FindProjects, FindProjectsVariables } 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 ProjectsShowcase from '../ProjectsShowcase/ProjectsShowcase'
export const QUERY: TypedDocumentNode<FindProjects, FindProjectsVariables> =
gql`
query FindProjects {
projects {
id
title
description
images
date
links
tags {
id
tag
color
}
}
}
`
export const Loading = () => <CellLoading />
export const Empty = () => <CellEmpty />
export const Failure = ({ error }: CellFailureProps<FindProjectsVariables>) => (
<CellFailure error={error} />
)
export const Success = ({
projects,
}: CellSuccessProps<FindProjects, FindProjectsVariables>) => (
<ProjectsShowcase projects={projects} />
)

View File

@ -65,7 +65,7 @@ const Tag = ({ tag }: Props) => {
<th>Color</th> <th>Color</th>
<td> <td>
<div <div
className="badge" className="badge whitespace-nowrap"
style={{ style={{
backgroundColor: tag.color, backgroundColor: tag.color,
color: color:

View File

@ -96,7 +96,7 @@ const TagsList = ({ tags }: FindTags) => {
<td>{truncate(tag.tag)}</td> <td>{truncate(tag.tag)}</td>
<td> <td>
<div <div
className="badge" className="badge whitespace-nowrap"
style={{ style={{
backgroundColor: tag.color, backgroundColor: tag.color,
color: color:

View File

@ -20,6 +20,10 @@ const NavbarLayout = ({ children }: NavbarLayoutProps) => {
const { isAuthenticated, logOut } = useAuth() const { isAuthenticated, logOut } = useAuth()
const navbarRoutes: NavbarRoute[] = [ const navbarRoutes: NavbarRoute[] = [
{
name: 'Projects',
path: routes.projects(),
},
{ {
name: 'Contact', name: 'Contact',
path: routes.contact(), path: routes.contact(),
@ -33,7 +37,7 @@ const NavbarLayout = ({ children }: NavbarLayoutProps) => {
}, },
{ {
name: 'Projects', name: 'Projects',
path: routes.projects(), path: routes.adminProjects(),
}, },
{ {
name: 'Tags', name: 'Tags',

View File

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

View File

@ -0,0 +1,29 @@
import { isMobile, isBrowser } from 'react-device-detect'
import { Metadata } from '@redwoodjs/web'
import ProjectsShowcaseCell from 'src/components/Project/ProjectsShowcaseCell'
const ProjectsPage = () => {
return (
<>
<Metadata title="Projects" />
<div className="hero min-h-48">
<div className="hero-content">
<div className="max-w-md text-center">
<h1 className="text-5xl font-bold">Projects</h1>
<p className="py-6">
{isBrowser && !isMobile ? 'Click' : 'Tap'} on a project for
details
</p>
</div>
</div>
</div>
<ProjectsShowcaseCell />
</>
)
}
export default ProjectsPage

View File

@ -15476,6 +15476,18 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"react-device-detect@npm:^2.2.3":
version: 2.2.3
resolution: "react-device-detect@npm:2.2.3"
dependencies:
ua-parser-js: "npm:^1.0.33"
peerDependencies:
react: ">= 0.14.0"
react-dom: ">= 0.14.0"
checksum: 10c0/396bbeeab0cb21da084c67434d204c9cf502fad6c683903313084d3f6487950a36a34f9bf67ccf5c1772a1bb5b79a2a4403fcfe6b51d93877db4c2d9f3a3a925
languageName: node
linkType: hard
"react-dom@npm:18.3.1": "react-dom@npm:18.3.1":
version: 18.3.1 version: 18.3.1
resolution: "react-dom@npm:18.3.1" resolution: "react-dom@npm:18.3.1"
@ -17667,6 +17679,15 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"ua-parser-js@npm:^1.0.33":
version: 1.0.39
resolution: "ua-parser-js@npm:1.0.39"
bin:
ua-parser-js: script/cli.js
checksum: 10c0/c6452b0c683000f10975cb0a7e74cb1119ea95d4522ae85f396fa53b0b17884358a24ffdd86a66030c6b2981bdc502109a618c79fdaa217ee9032c9e46fcc78a
languageName: node
linkType: hard
"ua-parser-js@npm:^1.0.35": "ua-parser-js@npm:^1.0.35":
version: 1.0.38 version: 1.0.38
resolution: "ua-parser-js@npm:1.0.38" resolution: "ua-parser-js@npm:1.0.38"
@ -18167,6 +18188,7 @@ __metadata:
postcss-loader: "npm:^8.1.1" postcss-loader: "npm:^8.1.1"
react: "npm:18.3.1" react: "npm:18.3.1"
react-colorful: "npm:^5.6.1" react-colorful: "npm:^5.6.1"
react-device-detect: "npm:^2.2.3"
react-dom: "npm:18.3.1" react-dom: "npm:18.3.1"
tailwindcss: "npm:^3.4.8" tailwindcss: "npm:^3.4.8"
languageName: unknown languageName: unknown