This commit is contained in:
Matz Hilven 2024-06-10 09:23:38 +02:00
parent 38a7e09b68
commit 9644884de9
Signed by: MatzHilven
GPG key ID: ACEB669C2CB79EB7
9 changed files with 121 additions and 1215 deletions

View file

@ -19,6 +19,7 @@
"next": "14.1.4", "next": "14.1.4",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-intersection-observer": "^8.29.1",
"sharp": "^0.33.4", "sharp": "^0.33.4",
"tailwind-merge": "^2.3.0", "tailwind-merge": "^2.3.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",

View file

@ -35,6 +35,9 @@ dependencies:
react-dom: react-dom:
specifier: ^18.3.1 specifier: ^18.3.1
version: 18.3.1(react@18.3.1) version: 18.3.1(react@18.3.1)
react-intersection-observer:
specifier: ^8.29.1
version: 8.29.1(react@18.3.1)
sharp: sharp:
specifier: ^0.33.4 specifier: ^0.33.4
version: 0.33.4 version: 0.33.4
@ -2857,6 +2860,14 @@ packages:
scheduler: 0.23.2 scheduler: 0.23.2
dev: false dev: false
/react-intersection-observer@8.29.1(react@18.3.1):
resolution: {integrity: sha512-JLxJ4V0L73ailfvbYQ2/lfAyirtud1WsRsYnzHyVLMfQff1AIG1lWdC5XaGSK4yb9jZHVbbNsrVIO3PJm03koQ==}
peerDependencies:
react: ^15.0.0 || ^16.0.0 || ^17.0.0|| ^17.0.0
dependencies:
react: 18.3.1
dev: false
/react-is@16.13.1: /react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 MiB

File diff suppressed because it is too large Load diff

View file

@ -2,12 +2,9 @@ import './globals.css'
import '@mantine/core/styles.css' import '@mantine/core/styles.css'
import type { Metadata } from 'next' import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import React from 'react' import React from 'react'
import { ColorSchemeScript, MantineProvider } from '@mantine/core' import { ColorSchemeScript, MantineProvider } from '@mantine/core'
const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Matz Hilven', title: 'Matz Hilven',
description: 'todo', description: 'todo',
@ -23,7 +20,7 @@ export default function RootLayout({
<head> <head>
<ColorSchemeScript /> <ColorSchemeScript />
</head> </head>
<body className={inter.className}> <body className="dark">
<div className="bg-bg"> <div className="bg-bg">
<MantineProvider>{children}</MantineProvider> <MantineProvider>{children}</MantineProvider>
</div> </div>

View file

@ -1,9 +1,10 @@
'use client' 'use client'
import React, { useState, useEffect } from 'react' import React, { useState, useEffect, useMemo } from 'react'
import { Project, Tag, projectsSchema } from '@/typings/project'
import { ProjectCard } from '@/components/ProjectCard' import { ProjectCard } from '@/components/ProjectCard'
import projectsData from '../../public/projects.json' import projectsData from '../../public/projects.json'
import { projectsSchema, Project } from '@/typings/project'
import { useInView } from 'react-intersection-observer'
const Page = () => { const Page = () => {
const parsedProjects = projectsSchema.safeParse(projectsData) const parsedProjects = projectsSchema.safeParse(projectsData)
@ -16,63 +17,60 @@ const Page = () => {
const projects: Project[] = parsedProjects.data const projects: Project[] = parsedProjects.data
const [currentPage, setCurrentPage] = useState(1) const [currentPage, setCurrentPage] = useState(1)
const [itemsPerPage, setItemsPerPage] = useState<number>(4) const [itemsPerPage] = useState<number>(12)
const [filterTag, setFilterTag] = useState<Tag | null>(null) const [filterTag, setFilterTag] = useState<string | null>(null)
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc') const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc')
const { ref, inView } = useInView({
threshold: 1.0,
})
const handleFilterChange = (event: React.ChangeEvent<HTMLSelectElement>) => { const handleFilterChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
setFilterTag( setFilterTag(event.target.value === 'All' ? null : event.target.value)
event.target.value === 'All' ? null : (event.target.value as Tag) setCurrentPage(1)
)
setCurrentPage(1) // Reset to the first page when filter changes
} }
const handleSortChange = (event: React.ChangeEvent<HTMLSelectElement>) => { const handleSortChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
setSortOrder(event.target.value as 'asc' | 'desc') setSortOrder(event.target.value as 'asc' | 'desc')
setCurrentPage(1) // Reset to the first page when sort order changes setCurrentPage(1)
} }
const filteredAndSortedProjects = useMemo(() => {
let updatedProjects = filterTag
? projects.filter(project => project.tags.includes(filterTag))
: projects
updatedProjects = [...updatedProjects].sort((a, b) => {
if (sortOrder === 'asc') {
return a.name.localeCompare(b.name)
} else {
return b.name.localeCompare(a.name)
}
})
return updatedProjects
}, [projects, filterTag, sortOrder])
const visibleProjects = useMemo(() => {
return filteredAndSortedProjects.slice(0, currentPage * itemsPerPage)
}, [filteredAndSortedProjects, currentPage, itemsPerPage])
useEffect(() => { useEffect(() => {
const handleResize = () => { if (
const cardWidth = 240 // Width of a single card (adjust as needed) inView &&
const windowWidth = window.innerWidth currentPage * itemsPerPage < filteredAndSortedProjects.length
const newItemsPerPage = Math.floor(windowWidth / cardWidth) ) {
setItemsPerPage(newItemsPerPage > 0 ? newItemsPerPage : 1) setCurrentPage(prevPage => prevPage + 1)
} }
}, [inView, currentPage, itemsPerPage, filteredAndSortedProjects.length])
handleResize()
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}, [])
const filteredProjects = filterTag
? projects.filter(project => project.tags.includes(filterTag))
: projects
const sortedProjects = [...filteredProjects].sort((a, b) => {
if (sortOrder === 'asc') {
return a.name.localeCompare(b.name)
} else {
return b.name.localeCompare(a.name)
}
})
const indexOfLastItem = currentPage * itemsPerPage
const indexOfFirstItem = indexOfLastItem - itemsPerPage
const currentProjects = sortedProjects.slice(
indexOfFirstItem,
indexOfLastItem
)
const totalPages = Math.ceil(sortedProjects.length / itemsPerPage)
return ( return (
<div className="flex min-h-screen flex-col items-center p-4 md:p-8"> <div className="flex min-h-screen flex-col items-center bg-white p-4 text-gray-900 dark:bg-gray-900 dark:text-gray-100 md:p-8">
<div className="mb-8 text-2xl font-bold">Matz Hilven</div>
<div className="mb-4 flex gap-4"> <div className="mb-4 flex gap-4">
<select onChange={handleFilterChange} className="rounded border p-2"> <select
onChange={handleFilterChange}
className="rounded border border-gray-300 bg-white p-2 text-gray-900 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
>
<option value="All">All Tags</option> <option value="All">All Tags</option>
{[...new Set(projects.flatMap(project => project.tags))].map(tag => ( {[...new Set(projects.flatMap(project => project.tags))].map(tag => (
<option key={tag} value={tag}> <option key={tag} value={tag}>
@ -80,32 +78,20 @@ const Page = () => {
</option> </option>
))} ))}
</select> </select>
<select onChange={handleSortChange} className="rounded border p-2"> <select
onChange={handleSortChange}
className="rounded border border-gray-300 bg-white p-2 text-gray-900 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
>
<option value="asc">Sort by Name (A-Z)</option> <option value="asc">Sort by Name (A-Z)</option>
<option value="desc">Sort by Name (Z-A)</option> <option value="desc">Sort by Name (Z-A)</option>
</select> </select>
</div> </div>
<div className="mx-4 flex flex-wrap justify-center gap-4"> <div className="m-8 flex flex-wrap justify-center gap-6">
{currentProjects.map((project: Project, index: number) => ( {visibleProjects.map((project, index) => (
<ProjectCard project={project} key={index} /> <ProjectCard project={project} key={index} />
))} ))}
</div> </div>
<div className="mt-4 flex gap-4"> <div ref={ref}></div>
<button
onClick={() => setCurrentPage(currentPage - 1)}
disabled={currentPage === 1}
className="rounded border px-4 py-2 disabled:opacity-50"
>
Previous
</button>
<button
onClick={() => setCurrentPage(currentPage + 1)}
disabled={currentPage === totalPages}
className="rounded border px-4 py-2 disabled:opacity-50"
>
Next
</button>
</div>
</div> </div>
) )
} }

View file

@ -6,6 +6,9 @@ import {
IconBrandRust, IconBrandRust,
IconBrandGolang, IconBrandGolang,
IconBrandTypescript, IconBrandTypescript,
IconDiamond,
IconBrandKotlin,
IconBrandPython,
} from '@tabler/icons-react' } from '@tabler/icons-react'
type Props = { type Props = {
@ -13,20 +16,24 @@ type Props = {
} }
const socialIcons: Record<Social, React.JSX.Element> = { const socialIcons: Record<Social, React.JSX.Element> = {
Github: <IconBrandGithub className="h-5 w-5" />, Github: <IconBrandGithub />,
YouTube: <IconBrandYoutube className="h-5 w-5" />, YouTube: <IconBrandYoutube />,
} }
const languageIcons: Record<Language, React.JSX.Element> = { const languageIcons: Record<Language, React.JSX.Element> = {
Java: <IconCoffee className="h-5 w-5" />, Java: <IconCoffee color="#b07219" />,
Rust: <IconBrandRust className="h-5 w-5" />, Rust: <IconBrandRust color="#dea584" />,
Go: <IconBrandGolang className="h-5 w-5" />, Go: <IconBrandGolang color="#00ADD8" />,
TypeScript: <IconBrandTypescript className="h-5 w-5" />, Ruby: <IconDiamond color="#701516" />,
Kotlin: <IconBrandKotlin color="#7f52ff" />,
TypeScript: <IconBrandTypescript color="#3178C6" />,
Python: <IconBrandPython color="#306998" />,
} }
const tagStyles: Record<Tag, string> = { const tagStyles: Record<Tag, string> = {
'1.8': 'text-orange-500 border-orange-500', '1.8': 'text-orange-500 border-orange-500',
'1.17': 'text-orange-500 border-orange-500', '1.17': 'text-orange-500 border-orange-500',
'1.12': 'text-orange-500 border-orange-500',
Paper: 'text-blue-500 border-blue-500', Paper: 'text-blue-500 border-blue-500',
Bungee: 'text-blue-500 border-blue-500', Bungee: 'text-blue-500 border-blue-500',
Frontend: 'text-cyan-500 border-cyan-500', Frontend: 'text-cyan-500 border-cyan-500',
@ -37,48 +44,51 @@ const tagStyles: Record<Tag, string> = {
export const ProjectCard = ({ project }: Props) => { export const ProjectCard = ({ project }: Props) => {
return ( return (
<div className="h-80 w-80 transform overflow-hidden rounded-lg bg-white shadow-md transition-transform hover:scale-105"> <div className="relative flex h-80 w-64 flex-col overflow-hidden rounded-lg bg-white shadow-md transition-transform duration-300 hover:scale-105 dark:bg-gray-800">
<img <img
src={project.imageUrl} src={project.imageUrl}
alt={project.name} alt={project.name}
className="h-40 w-full object-cover" className="h-32 w-full rounded-t-lg object-cover"
/> />
<div className="flex flex-grow flex-col justify-between p-4"> <div className="flex flex-1 flex-col p-2 text-center">
<div> <div>
<div className="mb-2 text-xl font-bold">{project.name}</div> <div className="truncate text-xl font-bold text-gray-900 dark:text-gray-100">
<div className="mb-2 flex flex-wrap gap-2"> {project.name}
</div>
<div className="mt-2 flex flex-wrap justify-center gap-2">
{project.tags.map(tag => ( {project.tags.map(tag => (
<span <span
key={tag} key={tag}
className={`rounded-full border px-2 py-1 text-xs font-semibold ${tagStyles[tag] || 'border-gray-500 text-gray-500'}`} className={`rounded-full border px-2 py-1 text-xs ${tagStyles[tag]} dark:border-opacity-50`}
> >
{tag} {tag}
</span> </span>
))} ))}
</div> </div>
<div className="text-sm text-gray-700">{project.description}</div>
</div> </div>
<div className="mt-4 flex items-center justify-between"> <div className="mt-2 flex-1 overflow-hidden text-ellipsis text-gray-600 dark:text-gray-400">
<div className="flex space-x-4"> {project.description}
{Object.entries(project.socials).map(([social, url]) => ( </div>
</div>
<div className="absolute bottom-0 left-0 right-0 flex items-center justify-between bg-white p-2 dark:bg-gray-800">
<div className="flex gap-2">
{project.socials &&
Object.entries(project.socials).map(([social, url]) => (
<a <a
key={social} key={social}
href={url} href={url}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-gray-700" className="hover:animate-jump transition-colors"
> >
{socialIcons[social as Social]} {socialIcons[social as Social]}
</a> </a>
))} ))}
</div> </div>
<div className="flex space-x-2"> <div className="flex gap-2">
{project.languages.map(language => ( {project.languages.map(language => (
<span key={language} className="text-xl text-gray-700"> <div key={language}>{languageIcons[language]}</div>
{languageIcons[language]} ))}
</span>
))}
</div>
</div> </div>
</div> </div>
</div> </div>

View file

@ -7,12 +7,16 @@ export const LanguageSchema = z.union([
z.literal('Java'), z.literal('Java'),
z.literal('Rust'), z.literal('Rust'),
z.literal('Go'), z.literal('Go'),
z.literal('Ruby'),
z.literal('Kotlin'),
z.literal('TypeScript'), z.literal('TypeScript'),
z.literal('Python'),
]) ])
export type Language = z.infer<typeof LanguageSchema> export type Language = z.infer<typeof LanguageSchema>
export const TagSchema = z.union([ export const TagSchema = z.union([
z.literal('1.8'), z.literal('1.8'),
z.literal('1.12'),
z.literal('1.17'), z.literal('1.17'),
z.literal('Paper'), z.literal('Paper'),
z.literal('Bungee'), z.literal('Bungee'),
@ -36,7 +40,7 @@ export const ProjectSchema = z.object({
name: z.string(), name: z.string(),
description: z.string(), description: z.string(),
category: CategorySchema, category: CategorySchema,
imageUrl: z.string().url(), imageUrl: z.string(),
socials: z.record(SocialSchema, z.string().url()).optional(), socials: z.record(SocialSchema, z.string().url()).optional(),
languages: z.array(LanguageSchema), languages: z.array(LanguageSchema),
tags: z.array(TagSchema), tags: z.array(TagSchema),

View file

@ -18,26 +18,24 @@ const config = {
}, },
}, },
extend: { extend: {
dropShadow: {
glow: [
'0 0px 20px rgba(255,255, 255, 0.35)',
'0 0px 65px rgba(255, 255,255, 0.2)',
],
},
keyframes: { keyframes: {
'accordion-down': { jump: {
from: { height: '0' }, '0%, 100%': { transform: 'translateY(0)' },
to: { height: 'var(--radix-accordion-content-height)' }, '50%': { transform: 'translateY(-10px)' },
},
'accordion-up': {
from: { height: 'var(--radix-accordion-content-height)' },
to: { height: '0' },
}, },
}, },
animation: { animation: {
'accordion-down': 'accordion-down 0.2s ease-out', jump: 'jump 0.4s ease-in-out',
'accordion-up': 'accordion-up 0.2s ease-out',
},
colors: {
bg: '#F9FAFB',
}, },
}, },
}, },
plugins: [require('tailwindcss-animate')], plugins: [],
} satisfies Config } satisfies Config
export default config export default config