progress
This commit is contained in:
parent
38a7e09b68
commit
9644884de9
9 changed files with 121 additions and 1215 deletions
|
@ -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",
|
||||||
|
|
|
@ -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==}
|
||||||
|
|
||||||
|
|
BIN
public/images/projects/war.gif
Normal file
BIN
public/images/projects/war.gif
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.4 MiB |
1115
public/projects.json
1115
public/projects.json
File diff suppressed because it is too large
Load diff
|
@ -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>
|
||||||
|
|
|
@ -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,42 +17,30 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
const filteredAndSortedProjects = useMemo(() => {
|
||||||
const handleResize = () => {
|
let updatedProjects = filterTag
|
||||||
const cardWidth = 240 // Width of a single card (adjust as needed)
|
|
||||||
const windowWidth = window.innerWidth
|
|
||||||
const newItemsPerPage = Math.floor(windowWidth / cardWidth)
|
|
||||||
setItemsPerPage(newItemsPerPage > 0 ? newItemsPerPage : 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
handleResize()
|
|
||||||
|
|
||||||
window.addEventListener('resize', handleResize)
|
|
||||||
|
|
||||||
return () => window.removeEventListener('resize', handleResize)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const filteredProjects = filterTag
|
|
||||||
? projects.filter(project => project.tags.includes(filterTag))
|
? projects.filter(project => project.tags.includes(filterTag))
|
||||||
: projects
|
: projects
|
||||||
|
|
||||||
const sortedProjects = [...filteredProjects].sort((a, b) => {
|
updatedProjects = [...updatedProjects].sort((a, b) => {
|
||||||
if (sortOrder === 'asc') {
|
if (sortOrder === 'asc') {
|
||||||
return a.name.localeCompare(b.name)
|
return a.name.localeCompare(b.name)
|
||||||
} else {
|
} else {
|
||||||
|
@ -59,20 +48,29 @@ const Page = () => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const indexOfLastItem = currentPage * itemsPerPage
|
return updatedProjects
|
||||||
const indexOfFirstItem = indexOfLastItem - itemsPerPage
|
}, [projects, filterTag, sortOrder])
|
||||||
const currentProjects = sortedProjects.slice(
|
|
||||||
indexOfFirstItem,
|
|
||||||
indexOfLastItem
|
|
||||||
)
|
|
||||||
|
|
||||||
const totalPages = Math.ceil(sortedProjects.length / itemsPerPage)
|
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 (
|
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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,50 +44,53 @@ 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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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),
|
||||||
|
|
|
@ -18,26 +18,24 @@ const config = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
extend: {
|
extend: {
|
||||||
keyframes: {
|
dropShadow: {
|
||||||
'accordion-down': {
|
glow: [
|
||||||
from: { height: '0' },
|
'0 0px 20px rgba(255,255, 255, 0.35)',
|
||||||
to: { height: 'var(--radix-accordion-content-height)' },
|
'0 0px 65px rgba(255, 255,255, 0.2)',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
'accordion-up': {
|
keyframes: {
|
||||||
from: { height: 'var(--radix-accordion-content-height)' },
|
jump: {
|
||||||
to: { height: '0' },
|
'0%, 100%': { transform: 'translateY(0)' },
|
||||||
|
'50%': { transform: 'translateY(-10px)' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
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
|
||||||
|
|
Loading…
Reference in a new issue