improvements
This commit is contained in:
parent
9644884de9
commit
832be077f7
6 changed files with 179 additions and 113 deletions
|
@ -4,25 +4,34 @@ import '@mantine/core/styles.css'
|
||||||
import type { Metadata } from 'next'
|
import type { Metadata } from 'next'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { ColorSchemeScript, MantineProvider } from '@mantine/core'
|
import { ColorSchemeScript, MantineProvider } from '@mantine/core'
|
||||||
|
import { Montserrat } from 'next/font/google'
|
||||||
|
import Navbar from '@/components/Navbar'
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Matz Hilven',
|
title: 'Matz Hilven | Software Engineer',
|
||||||
description: 'todo',
|
description: 'todo',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const font = Montserrat({
|
||||||
|
subsets: ['latin'],
|
||||||
|
display: 'swap',
|
||||||
|
})
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en" className={font.className}>
|
||||||
<head>
|
<head>
|
||||||
<ColorSchemeScript />
|
<ColorSchemeScript />
|
||||||
</head>
|
</head>
|
||||||
<body className="dark">
|
<body className="dark">
|
||||||
<div className="bg-bg">
|
<div className="bg-white text-gray-900 dark:bg-gray-900 dark:text-gray-100">
|
||||||
<MantineProvider>{children}</MantineProvider>
|
<Navbar />
|
||||||
|
<div className="flex min-h-screen flex-col items-center p-4 md:p-8">
|
||||||
|
<MantineProvider>{children}</MantineProvider>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
@ -1,99 +1,5 @@
|
||||||
'use client'
|
|
||||||
|
|
||||||
import React, { useState, useEffect, useMemo } from 'react'
|
|
||||||
import { ProjectCard } from '@/components/ProjectCard'
|
|
||||||
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)
|
return <div>hey</div>
|
||||||
|
|
||||||
if (!parsedProjects.success) {
|
|
||||||
console.error('Invalid project data', parsedProjects.error)
|
|
||||||
return <div>Error loading projects</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
const projects: Project[] = parsedProjects.data
|
|
||||||
|
|
||||||
const [currentPage, setCurrentPage] = useState(1)
|
|
||||||
const [itemsPerPage] = useState<number>(12)
|
|
||||||
const [filterTag, setFilterTag] = useState<string | null>(null)
|
|
||||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc')
|
|
||||||
|
|
||||||
const { ref, inView } = useInView({
|
|
||||||
threshold: 1.0,
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleFilterChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
|
|
||||||
setFilterTag(event.target.value === 'All' ? null : event.target.value)
|
|
||||||
setCurrentPage(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSortChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
|
|
||||||
setSortOrder(event.target.value as 'asc' | 'desc')
|
|
||||||
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(() => {
|
|
||||||
if (
|
|
||||||
inView &&
|
|
||||||
currentPage * itemsPerPage < filteredAndSortedProjects.length
|
|
||||||
) {
|
|
||||||
setCurrentPage(prevPage => prevPage + 1)
|
|
||||||
}
|
|
||||||
}, [inView, currentPage, itemsPerPage, filteredAndSortedProjects.length])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<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-4 flex gap-4">
|
|
||||||
<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>
|
|
||||||
{[...new Set(projects.flatMap(project => project.tags))].map(tag => (
|
|
||||||
<option key={tag} value={tag}>
|
|
||||||
{tag}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<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="desc">Sort by Name (Z-A)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="m-8 flex flex-wrap justify-center gap-6">
|
|
||||||
{visibleProjects.map((project, index) => (
|
|
||||||
<ProjectCard project={project} key={index} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div ref={ref}></div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Page
|
export default Page
|
||||||
|
|
99
src/app/projects/page.tsx
Normal file
99
src/app/projects/page.tsx
Normal file
|
@ -0,0 +1,99 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useMemo } from 'react'
|
||||||
|
import { ProjectCard } from '@/components/ProjectCard'
|
||||||
|
import projectsData from '../../../public/projects.json'
|
||||||
|
import { projectsSchema, Project } from '@/typings/project'
|
||||||
|
import { useInView } from 'react-intersection-observer'
|
||||||
|
|
||||||
|
const Page = () => {
|
||||||
|
const parsedProjects = projectsSchema.safeParse(projectsData)
|
||||||
|
|
||||||
|
if (!parsedProjects.success) {
|
||||||
|
console.error('Invalid project data', parsedProjects.error)
|
||||||
|
return <div>Error loading projects</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
const projects: Project[] = parsedProjects.data
|
||||||
|
|
||||||
|
const [currentPage, setCurrentPage] = useState(1)
|
||||||
|
const [itemsPerPage] = useState<number>(12)
|
||||||
|
const [filterTag, setFilterTag] = useState<string | null>(null)
|
||||||
|
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc')
|
||||||
|
|
||||||
|
const { ref, inView } = useInView({
|
||||||
|
threshold: 1.0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleFilterChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
|
setFilterTag(event.target.value === 'All' ? null : event.target.value)
|
||||||
|
setCurrentPage(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSortChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
|
setSortOrder(event.target.value as 'asc' | 'desc')
|
||||||
|
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(() => {
|
||||||
|
if (
|
||||||
|
inView &&
|
||||||
|
currentPage * itemsPerPage < filteredAndSortedProjects.length
|
||||||
|
) {
|
||||||
|
setCurrentPage(prevPage => prevPage + 1)
|
||||||
|
}
|
||||||
|
}, [inView, currentPage, itemsPerPage, filteredAndSortedProjects.length])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-4 flex gap-4">
|
||||||
|
<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>
|
||||||
|
{[...new Set(projects.flatMap(project => project.tags))].map(tag => (
|
||||||
|
<option key={tag} value={tag}>
|
||||||
|
{tag}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<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="desc">Sort by Name (Z-A)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="m-8 flex flex-wrap justify-center gap-6">
|
||||||
|
{visibleProjects.map((project, index) => (
|
||||||
|
<ProjectCard project={project} key={index} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div ref={ref}></div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Page
|
18
src/components/NavLink.tsx
Normal file
18
src/components/NavLink.tsx
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
type NavLinkProps = {
|
||||||
|
href: string
|
||||||
|
isActive: boolean
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const NavLink = ({ href, isActive, label }: NavLinkProps) => {
|
||||||
|
return (
|
||||||
|
<div className="drop-shadow-glow group relative text-xl">
|
||||||
|
<Link href={href}>{label}</Link>
|
||||||
|
<span className="absolute bottom-[-4px] left-1/2 block h-0.5 w-0 bg-black transition-all duration-300 group-hover:left-0 group-hover:w-full dark:bg-white"></span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NavLink
|
39
src/components/Navbar.tsx
Normal file
39
src/components/Navbar.tsx
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
import { usePathname } from 'next/navigation'
|
||||||
|
import NavLink from './NavLink'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
{ path: '/', label: 'About' },
|
||||||
|
{ path: '/projects', label: 'Projects' },
|
||||||
|
{ path: '/homelab', label: 'Homelab' },
|
||||||
|
{ path: '/contact', label: 'Contact' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const Navbar = () => {
|
||||||
|
const pathname = usePathname()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="flex h-16 items-center justify-between shadow-md">
|
||||||
|
<Link href="/">
|
||||||
|
<div className="animate-gradient cursor-pointer select-none bg-gradient-to-r from-blue-400 via-pink-500 to-red-500 bg-[length:200%_200%] bg-clip-text pl-4 text-2xl font-semibold uppercase text-transparent">
|
||||||
|
Matz Hilven
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
<div className="mr-8 flex items-center space-x-8">
|
||||||
|
{routes.map(({ path, label }) => (
|
||||||
|
<NavLink
|
||||||
|
key={path}
|
||||||
|
href={path}
|
||||||
|
isActive={pathname === path}
|
||||||
|
label={label}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Navbar
|
|
@ -2,21 +2,9 @@ import type { Config } from 'tailwindcss'
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
darkMode: ['class'],
|
darkMode: ['class'],
|
||||||
content: [
|
content: ['./src/**/*.{ts,tsx}'],
|
||||||
'./pages/**/*.{ts,tsx}',
|
|
||||||
'./components/**/*.{ts,tsx}',
|
|
||||||
'./app/**/*.{ts,tsx}',
|
|
||||||
'./src/**/*.{ts,tsx}',
|
|
||||||
],
|
|
||||||
prefix: '',
|
prefix: '',
|
||||||
theme: {
|
theme: {
|
||||||
container: {
|
|
||||||
center: true,
|
|
||||||
padding: '2rem',
|
|
||||||
screens: {
|
|
||||||
'2xl': '1400px',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
extend: {
|
extend: {
|
||||||
dropShadow: {
|
dropShadow: {
|
||||||
glow: [
|
glow: [
|
||||||
|
@ -25,6 +13,12 @@ const config = {
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
keyframes: {
|
keyframes: {
|
||||||
|
gradient: {
|
||||||
|
'0%, 100%': { backgroundPosition: '0% 50%' },
|
||||||
|
'25%': { backgroundPosition: '100% 0%' },
|
||||||
|
'50%': { backgroundPosition: '100% 100%' },
|
||||||
|
'75%': { backgroundPosition: '0% 100%' },
|
||||||
|
},
|
||||||
jump: {
|
jump: {
|
||||||
'0%, 100%': { transform: 'translateY(0)' },
|
'0%, 100%': { transform: 'translateY(0)' },
|
||||||
'50%': { transform: 'translateY(-10px)' },
|
'50%': { transform: 'translateY(-10px)' },
|
||||||
|
@ -32,6 +26,7 @@ const config = {
|
||||||
},
|
},
|
||||||
animation: {
|
animation: {
|
||||||
jump: 'jump 0.4s ease-in-out',
|
jump: 'jump 0.4s ease-in-out',
|
||||||
|
gradient: 'gradient 10s ease infinite',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
Loading…
Reference in a new issue