Public facing projects showcase, individual project details not done yet
This commit is contained in:
2
api/db/migrations/20240929164343_/migration.sql
Normal file
2
api/db/migrations/20240929164343_/migration.sql
Normal file
@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Project" ALTER COLUMN "images" SET DEFAULT ARRAY[]::TEXT[];
|
@ -69,7 +69,7 @@ model Project {
|
||||
id Int @id @default(autoincrement())
|
||||
title String
|
||||
description String @default("No description provided")
|
||||
images String[]
|
||||
images String[] @default([])
|
||||
date DateTime
|
||||
links String[] @default([])
|
||||
tags Tag[]
|
||||
|
@ -10,8 +10,8 @@ export const schema = gql`
|
||||
}
|
||||
|
||||
type Query {
|
||||
projects: [Project!]! @requireAuth
|
||||
project(id: Int!): Project @requireAuth
|
||||
projects: [Project!]! @skipAuth
|
||||
project(id: Int!): Project @skipAuth
|
||||
}
|
||||
|
||||
input CreateProjectInput {
|
||||
|
@ -7,8 +7,8 @@ export const schema = gql`
|
||||
}
|
||||
|
||||
type Query {
|
||||
tags: [Tag!]! @requireAuth
|
||||
tag(id: Int!): Tag @requireAuth
|
||||
tags: [Tag!]! @skipAuth
|
||||
tag(id: Int!): Tag @skipAuth
|
||||
}
|
||||
|
||||
input CreateTagInput {
|
||||
|
@ -31,6 +31,7 @@
|
||||
"humanize-string": "2.1.0",
|
||||
"react": "18.3.1",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-device-detect": "^2.2.3",
|
||||
"react-dom": "18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
@ -30,8 +30,8 @@ const Routes = () => {
|
||||
<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="project" />
|
||||
<Route path="/admin/projects" page={ProjectProjectsPage} name="projects" />
|
||||
<Route path="/admin/projects/{id:Int}" page={ProjectProjectPage} name="adminProject" />
|
||||
<Route path="/admin/projects" page={ProjectProjectsPage} name="adminProjects" />
|
||||
</Set>
|
||||
</PrivateSet>
|
||||
|
||||
@ -49,6 +49,8 @@ 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="/contact" page={ContactPage} name="contact" />
|
||||
</Set>
|
||||
|
||||
|
56
web/src/components/AutoCarousel/AutoCarousel.tsx
Normal file
56
web/src/components/AutoCarousel/AutoCarousel.tsx
Normal 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
|
@ -61,7 +61,7 @@ export const Success = ({ project }: CellSuccessProps<EditProjectById>) => {
|
||||
{
|
||||
onCompleted: () => {
|
||||
toast.success('Project updated')
|
||||
navigate(routes.projects())
|
||||
navigate(routes.adminProjects())
|
||||
},
|
||||
onError: (error) => toast.error(error.message),
|
||||
}
|
||||
|
@ -28,7 +28,7 @@ const NewProject = () => {
|
||||
{
|
||||
onCompleted: () => {
|
||||
toast.success('Project created')
|
||||
navigate(routes.projects())
|
||||
navigate(routes.adminProjects())
|
||||
},
|
||||
onError: (error) => toast.error(error.message),
|
||||
}
|
||||
|
@ -32,7 +32,7 @@ const Project = ({ project }: Props) => {
|
||||
const [deleteProject] = useMutation(DELETE_PROJECT_MUTATION, {
|
||||
onCompleted: () => {
|
||||
toast.success('Project deleted')
|
||||
navigate(routes.projects())
|
||||
navigate(routes.adminProjects())
|
||||
},
|
||||
onError: (error) => toast.error(error.message),
|
||||
})
|
||||
@ -99,7 +99,7 @@ const Project = ({ project }: Props) => {
|
||||
{project.tags.map((tag, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="badge"
|
||||
className="badge whitespace-nowrap"
|
||||
style={{
|
||||
backgroundColor: tag.color,
|
||||
color:
|
||||
|
@ -235,30 +235,33 @@ const ProjectForm = (props: ProjectFormProps) => {
|
||||
</div>
|
||||
{fileIds.length > 0 ? (
|
||||
<>
|
||||
{fileIds.map((fileId, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="card rounded-xl image-full image-full-no-overlay"
|
||||
>
|
||||
<figure>
|
||||
<img src={fileId} alt={i.toString()} />
|
||||
</figure>
|
||||
<div className="card-body p-2 rounded-xl">
|
||||
<div className="card-actions rounded-md justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-square btn-sm shadow-xl"
|
||||
onClick={() => {
|
||||
setToDelete([...toDelete, fileId])
|
||||
setFileIds(fileIds.filter((id) => id !== fileId))
|
||||
}}
|
||||
>
|
||||
<Icon path={mdiDelete} className="size-4 text-error" />
|
||||
</button>
|
||||
{!appendUploader &&
|
||||
fileIds.map((fileId, i) => (
|
||||
<div key={i} className="flex justify-center">
|
||||
<div className="card rounded-xl w-fit image-full image-full-no-overlay">
|
||||
<figure>
|
||||
<img src={fileId} alt={i.toString()} />
|
||||
</figure>
|
||||
<div className="card-body p-2 rounded-xl">
|
||||
<div className="card-actions rounded-md justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-square btn-sm shadow-xl"
|
||||
onClick={() => {
|
||||
setToDelete([...toDelete, fileId])
|
||||
setFileIds(fileIds.filter((id) => id !== fileId))
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
path={mdiDelete}
|
||||
className="size-4 text-error"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
{appendUploader && (
|
||||
<Uploader
|
||||
onComplete={onUploadComplete}
|
||||
|
@ -61,7 +61,7 @@ const ProjectsList = ({ projects }: FindProjects) => {
|
||||
const actionButtons = (
|
||||
<>
|
||||
<Link
|
||||
to={routes.project({ id: project.id })}
|
||||
to={routes.adminProject({ id: project.id })}
|
||||
title={'Show project ' + project.id + ' detail'}
|
||||
className="btn btn-xs uppercase"
|
||||
>
|
||||
@ -96,7 +96,7 @@ const ProjectsList = ({ projects }: FindProjects) => {
|
||||
{project.tags.map((tag, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="badge"
|
||||
className="badge whitespace-nowrap"
|
||||
style={{
|
||||
backgroundColor: tag.color,
|
||||
color:
|
||||
|
@ -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
|
@ -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} />
|
||||
)
|
@ -65,7 +65,7 @@ const Tag = ({ tag }: Props) => {
|
||||
<th>Color</th>
|
||||
<td>
|
||||
<div
|
||||
className="badge"
|
||||
className="badge whitespace-nowrap"
|
||||
style={{
|
||||
backgroundColor: tag.color,
|
||||
color:
|
||||
|
@ -96,7 +96,7 @@ const TagsList = ({ tags }: FindTags) => {
|
||||
<td>{truncate(tag.tag)}</td>
|
||||
<td>
|
||||
<div
|
||||
className="badge"
|
||||
className="badge whitespace-nowrap"
|
||||
style={{
|
||||
backgroundColor: tag.color,
|
||||
color:
|
||||
|
@ -20,6 +20,10 @@ const NavbarLayout = ({ children }: NavbarLayoutProps) => {
|
||||
const { isAuthenticated, logOut } = useAuth()
|
||||
|
||||
const navbarRoutes: NavbarRoute[] = [
|
||||
{
|
||||
name: 'Projects',
|
||||
path: routes.projects(),
|
||||
},
|
||||
{
|
||||
name: 'Contact',
|
||||
path: routes.contact(),
|
||||
@ -33,7 +37,7 @@ const NavbarLayout = ({ children }: NavbarLayoutProps) => {
|
||||
},
|
||||
{
|
||||
name: 'Projects',
|
||||
path: routes.projects(),
|
||||
path: routes.adminProjects(),
|
||||
},
|
||||
{
|
||||
name: 'Tags',
|
||||
|
27
web/src/pages/ProjectPage/ProjectPage.tsx
Normal file
27
web/src/pages/ProjectPage/ProjectPage.tsx
Normal 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
|
29
web/src/pages/ProjectsPage/ProjectsPage.tsx
Normal file
29
web/src/pages/ProjectsPage/ProjectsPage.tsx
Normal 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
|
22
yarn.lock
22
yarn.lock
@ -15476,6 +15476,18 @@ __metadata:
|
||||
languageName: node
|
||||
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":
|
||||
version: 18.3.1
|
||||
resolution: "react-dom@npm:18.3.1"
|
||||
@ -17667,6 +17679,15 @@ __metadata:
|
||||
languageName: node
|
||||
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":
|
||||
version: 1.0.38
|
||||
resolution: "ua-parser-js@npm:1.0.38"
|
||||
@ -18167,6 +18188,7 @@ __metadata:
|
||||
postcss-loader: "npm:^8.1.1"
|
||||
react: "npm:18.3.1"
|
||||
react-colorful: "npm:^5.6.1"
|
||||
react-device-detect: "npm:^2.2.3"
|
||||
react-dom: "npm:18.3.1"
|
||||
tailwindcss: "npm:^3.4.8"
|
||||
languageName: unknown
|
||||
|
Reference in New Issue
Block a user