Portfolio

This commit is contained in:
2025-04-30 20:25:26 -04:00
parent d2230d0a7c
commit 2752aa7022
35 changed files with 4744 additions and 166 deletions

2
.gitignore vendored
View File

@@ -1,4 +1,6 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
/public/config/*
/public/images/*
# dependencies
/node_modules

1
.yarnrc.yml Normal file
View File

@@ -0,0 +1 @@
nodeLinker: node-modules

View File

@@ -9,9 +9,21 @@
"lint": "next lint"
},
"dependencies": {
"@icons-pack/react-simple-icons": "10.0.0",
"@mdi/js": "^7.4.47",
"@mdi/react": "^1.6.1",
"@react-spring/three": "^9.7.5",
"@react-spring/web": "^9.7.5",
"@react-three/drei": "^10.0.7",
"@react-three/fiber": "^9.1.2",
"ajv": "^8.17.1",
"ajv-formats": "^3.0.1",
"awesome-ajv-errors": "^5.1.0",
"next": "15.3.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-pdf": "^9.2.1",
"three": "^0.175.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
@@ -19,6 +31,7 @@
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/three": "^0",
"eslint": "^9",
"eslint-config-next": "15.3.0",
"tailwindcss": "^4",

View File

@@ -1 +0,0 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 391 B

View File

@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

BIN
public/scene.glb Normal file

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

BIN
public/static/inter.ttf Normal file

Binary file not shown.

BIN
public/static/pc-bg.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

View File

@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 128 B

View File

@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

Before

Width:  |  Height:  |  Size: 385 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

@@ -1,26 +1 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}

View File

@@ -1,20 +1,17 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Inter } from "next/font/google";
import "./globals.css";
import config from "../../public/config/config.json";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
const inter = Inter({
variable: "--font-inter",
subsets: ["latin"],
preload: true,
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: config.name.join(" "),
description: "Portfolio",
};
export default function RootLayout({
@@ -24,9 +21,7 @@ export default function RootLayout({
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<body className={`${inter.variable} antialiased bg-[#1b1b1b]`}>
{children}
</body>
</html>

View File

@@ -1,103 +1,19 @@
import Image from "next/image";
"use client";
export default function Home() {
return (
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={180}
height={38}
priority
/>
<ol className="list-inside list-decimal text-sm/6 text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
<li className="mb-2 tracking-[-.01em]">
Get started by editing{" "}
<code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-[family-name:var(--font-geist-mono)] font-semibold">
src/app/page.tsx
</code>
.
</li>
<li className="tracking-[-.01em]">
Save and see your changes instantly.
</li>
</ol>
import dynamic from "next/dynamic";
<div className="flex gap-4 items-center flex-col sm:flex-row">
<a
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={20}
height={20}
/>
Deploy now
</a>
<a
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Read our docs
</a>
</div>
</main>
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/file.svg"
alt="File icon"
width={16}
height={16}
/>
Learn
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/window.svg"
alt="Window icon"
width={16}
height={16}
/>
Examples
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/globe.svg"
alt="Globe icon"
width={16}
height={16}
/>
Go to nextjs.org
</a>
</footer>
</div>
);
}
const Scene = dynamic(
() => import("@/components/scene/Scene").then((mod) => mod.Scene),
{
ssr: false,
loading: () => (
<div className="w-screen h-screen text-center flex justify-center items-center text-white text-4xl">
Loading...
</div>
),
}
);
const Page = () => <Scene />;
export default Page;

View File

@@ -0,0 +1,15 @@
import { type Config } from "@/types/config";
import { useState, useEffect } from "react";
export const useConfig = (): Config => {
const [config, setConfig] = useState<Config>({ name: ["", ""] });
useEffect(() => {
fetch("/config/config.json")
.then((res) => res.json())
.then(setConfig)
.catch(console.error);
}, []);
return config;
};

View File

@@ -0,0 +1,11 @@
import { useEffect, useRef } from "react";
import { Mesh } from "three";
export const useDisableRaycast = (disable: boolean) => {
const ref = useRef<Mesh>(null);
useEffect(() => {
if (!ref.current) return;
ref.current.raycast = disable ? () => null : Mesh.prototype.raycast;
}, [disable]);
return ref;
};

View File

@@ -0,0 +1,21 @@
import { useEffect, useState } from "react";
type Size = { width: number; height: number };
export function useWindowSize(): Size {
const [size, setSize] = useState<Size>({
width: typeof window === "undefined" ? 1920 : window.innerWidth,
height: typeof window === "undefined" ? 1080 : window.innerHeight,
});
useEffect(() => {
function handle() {
setSize({ width: window.innerWidth, height: window.innerHeight });
}
window.addEventListener("resize", handle);
handle();
return () => window.removeEventListener("resize", handle);
}, []);
return size;
}

View File

@@ -0,0 +1,554 @@
import { animated, useSpring as useThreeSpring } from "@react-spring/three";
import { useDisableRaycast } from "../hooks/useDisableRaycast";
import { View, viewToHash } from "@/components/scene/consts";
import Stack from "@/util/stack";
import {
type Dispatch,
type SetStateAction,
useCallback,
useEffect,
useState,
} from "react";
import {
type MeshStandardMaterial,
type BufferGeometry,
BackSide,
} from "three";
import { Billboard, Text } from "@react-three/drei";
import { useConfig } from "../hooks/useConfig";
interface ButtonsProps {
menuTraversal: Stack<View>;
setMenuTraversal: Dispatch<SetStateAction<Stack<View>>>;
pendingView: View | null;
setPendingView: Dispatch<SetStateAction<View | null>>;
goPreviousView(): void;
phoneHovered: boolean;
currentView: View;
}
const AnimatedText = animated(Text);
export const Buttons = ({
menuTraversal,
setMenuTraversal,
pendingView,
setPendingView,
goPreviousView,
phoneHovered,
currentView,
}: ButtonsProps) => {
const config = useConfig();
const [hovered, setHovered] = useState<View | null>(null);
const desktopRef = useDisableRaycast(currentView !== View.MainView);
const printerRef = useDisableRaycast(currentView !== View.MainView);
const sideRef = useDisableRaycast(currentView !== View.MainView);
const cellphoneRef = useDisableRaycast(currentView !== View.DesktopView);
const pcRef = useDisableRaycast(currentView !== View.DesktopView);
const resumeRef = useDisableRaycast(currentView !== View.SideView);
const phoneRef = useDisableRaycast(currentView !== View.SideView);
const goToView = useCallback(
(view: View) => {
setMenuTraversal((prev) => {
const next = prev.clone();
next.push(view);
return next;
});
setPendingView(view);
},
[setMenuTraversal, setPendingView]
);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") goPreviousView();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [goPreviousView]);
useEffect(() => {
const onPop = () => {
goPreviousView();
};
window.addEventListener("popstate", onPop);
return () => window.removeEventListener("popstate", onPop);
}, [goPreviousView]);
useEffect(() => {
if (pendingView === null) return;
window.history.pushState(
{ depth: menuTraversal.size() },
"",
viewToHash[pendingView] ? `#${viewToHash[pendingView]}` : "/"
);
setPendingView(null);
}, [menuTraversal, pendingView, setPendingView]);
useEffect(() => {
if (hovered || phoneHovered) {
document.body.style.cursor = "pointer";
} else {
document.body.style.cursor = "auto";
}
}, [hovered, phoneHovered]);
const mainMenuSpring = useThreeSpring<MeshStandardMaterial>({
opacity: currentView === View.MainView ? 1 : 0,
});
const desktopMenuSpring = useThreeSpring<MeshStandardMaterial>({
opacity: currentView === View.DesktopView ? 1 : 0,
});
const creditsButtonSpring = useThreeSpring<MeshStandardMaterial>({
opacity:
currentView === View.DesktopView
? hovered === View.CreditsView
? 1
: 0.25
: 0,
});
const printerMenuSpring = useThreeSpring<MeshStandardMaterial>({
opacity: currentView === View.PrinterView ? 1 : 0,
});
const sideMenuSpring = useThreeSpring<MeshStandardMaterial>({
opacity: currentView === View.SideView ? 1 : 0,
});
const desktopViewSpring = useThreeSpring<BufferGeometry>({
scale: hovered === View.DesktopView ? 1.25 : 1,
});
const sideViewSpring = useThreeSpring<BufferGeometry>({
scale: hovered === View.SideView ? 1.25 : 1,
});
const cellphoneViewSpring = useThreeSpring<BufferGeometry>({
scale: hovered === View.CellphoneView ? 1.25 : 1,
});
const pcViewSpring = useThreeSpring<BufferGeometry>({
scale: hovered === View.PCView ? 1.25 : 1,
});
const creditsViewSpring = useThreeSpring<BufferGeometry>({
scale: hovered === View.CreditsView ? 1.25 : 1,
});
const resumeViewSpring = useThreeSpring<BufferGeometry>({
scale: hovered === View.ResumeView ? 1.25 : 1,
});
const printerViewSpring = useThreeSpring<BufferGeometry>({
scale: hovered === View.PrinterView ? 1.25 : 1,
});
const phoneViewSpring = useThreeSpring<BufferGeometry>({
scale: hovered === View.PhoneView ? 1.25 : 1,
});
return (
<>
{/* Main Menu */}
<group>
<animated.mesh
ref={desktopRef}
position={[0.9, 7, 1.65]}
onClick={
currentView === View.MainView
? () => goToView(View.DesktopView)
: undefined
}
onPointerOver={
currentView === View.MainView
? () => setHovered(View.DesktopView)
: undefined
}
onPointerOut={() => setHovered(null)}
scale={desktopViewSpring.scale}
>
<sphereGeometry args={[0.6, 32, 32]} />
<animated.meshStandardMaterial
transparent
color="white"
opacity={mainMenuSpring.opacity}
/>
</animated.mesh>
<animated.mesh
position={[0.9, 7, 1.65]}
scale={desktopViewSpring.scale}
>
<sphereGeometry args={[0.65, 32, 32]} />
<animated.meshStandardMaterial
transparent
color="black"
opacity={mainMenuSpring.opacity}
side={BackSide}
/>
</animated.mesh>
<Billboard position={[0.9, 8.5, 1.65]}>
<AnimatedText
font="/static/inter.ttf"
fontSize={0.75}
outlineWidth={0.05}
fillOpacity={mainMenuSpring.opacity}
outlineOpacity={mainMenuSpring.opacity}
>
Projects & Socials
</AnimatedText>
</Billboard>
<animated.mesh
ref={printerRef}
position={[-5.5, 6, -2]}
onClick={
currentView === View.MainView
? () => goToView(View.PrinterView)
: undefined
}
onPointerOver={
currentView === View.MainView
? () => setHovered(View.PrinterView)
: undefined
}
onPointerOut={() => setHovered(null)}
scale={printerViewSpring.scale}
>
<sphereGeometry args={[0.6, 32, 32]} />
<animated.meshStandardMaterial
transparent
color="white"
opacity={mainMenuSpring.opacity}
/>
</animated.mesh>
<animated.mesh position={[-5.5, 6, -2]} scale={printerViewSpring.scale}>
<sphereGeometry args={[0.65, 32, 32]} />
<animated.meshStandardMaterial
transparent
color="black"
opacity={mainMenuSpring.opacity}
side={BackSide}
/>
</animated.mesh>
<Billboard position={[-5.5, 7.5, -2]}>
<AnimatedText
font="/static/inter.ttf"
fontSize={0.75}
outlineWidth={0.05}
fillOpacity={mainMenuSpring.opacity}
outlineOpacity={mainMenuSpring.opacity}
>
Hobby Corner
</AnimatedText>
</Billboard>
<animated.mesh
ref={sideRef}
position={[2.525, 6, 6.75]}
onClick={
currentView === View.MainView
? () => goToView(View.SideView)
: undefined
}
onPointerOver={
currentView === View.MainView
? () => setHovered(View.SideView)
: undefined
}
onPointerOut={() => setHovered(null)}
scale={sideViewSpring.scale}
>
<sphereGeometry args={[0.6, 32, 32]} />
<animated.meshStandardMaterial
transparent
color="white"
opacity={mainMenuSpring.opacity}
/>
</animated.mesh>
<animated.mesh position={[2.525, 6, 6.75]} scale={sideViewSpring.scale}>
<sphereGeometry args={[0.65, 32, 32]} />
<animated.meshStandardMaterial
transparent
color="black"
opacity={mainMenuSpring.opacity}
side={BackSide}
/>
</animated.mesh>
<Billboard position={[2.525, 7.5, 6.75]}>
<AnimatedText
font="/static/inter.ttf"
fontSize={0.75}
outlineWidth={0.05}
fillOpacity={mainMenuSpring.opacity}
outlineOpacity={mainMenuSpring.opacity}
>
Resume & Contact
</AnimatedText>
</Billboard>
</group>
{/* Desktop Menu */}
<group>
<animated.mesh
ref={cellphoneRef}
position={[2.358, 5.125, -2.055]}
onClick={
currentView === View.DesktopView
? () => goToView(View.CellphoneView)
: undefined
}
onPointerOver={
currentView === View.DesktopView
? () => setHovered(View.CellphoneView)
: undefined
}
onPointerOut={() => setHovered(null)}
scale={cellphoneViewSpring.scale}
>
<sphereGeometry args={[0.25, 32, 32]} />
<animated.meshStandardMaterial
transparent
color="white"
opacity={desktopMenuSpring.opacity}
/>
</animated.mesh>
<animated.mesh
position={[2.358, 5.125, -2.055]}
scale={cellphoneViewSpring.scale}
>
<sphereGeometry args={[0.25 + 1 / 48, 32, 32]} />
<animated.meshStandardMaterial
transparent
color="black"
opacity={desktopMenuSpring.opacity}
side={BackSide}
/>
</animated.mesh>
<Billboard position={[2.358, 5.75, -2.055]}>
<AnimatedText
font="/static/inter.ttf"
fontSize={0.3125}
outlineWidth={1 / 48}
fillOpacity={desktopMenuSpring.opacity}
outlineOpacity={desktopMenuSpring.opacity}
>
Socials
</AnimatedText>
</Billboard>
<animated.mesh
ref={pcRef}
position={[3.25, 5.982, 1.668]}
onClick={
currentView === View.DesktopView
? () => goToView(View.PCView)
: undefined
}
onPointerOver={
currentView === View.DesktopView
? () => setHovered(View.PCView)
: undefined
}
onPointerOut={() => setHovered(null)}
scale={pcViewSpring.scale}
>
<sphereGeometry args={[0.25, 32, 32]} />
<animated.meshStandardMaterial
transparent
color="white"
opacity={desktopMenuSpring.opacity}
/>
</animated.mesh>
<animated.mesh
position={[3.25, 5.982, 1.668]}
scale={pcViewSpring.scale}
>
<sphereGeometry args={[0.25 + 1 / 48, 32, 32]} />
<animated.meshStandardMaterial
transparent
color="black"
opacity={desktopMenuSpring.opacity}
side={BackSide}
/>
</animated.mesh>
<Billboard position={[3.25, 6.607, 1.668]}>
<AnimatedText
font="/static/inter.ttf"
fontSize={0.3125}
outlineWidth={1 / 48}
fillOpacity={desktopMenuSpring.opacity}
outlineOpacity={desktopMenuSpring.opacity}
>
Projects
</AnimatedText>
</Billboard>
<animated.mesh
ref={pcRef}
position={[3.25, 5.982, -2.15]}
onClick={
currentView === View.DesktopView
? () => goToView(View.CreditsView)
: undefined
}
onPointerOver={
currentView === View.DesktopView
? () => setHovered(View.CreditsView)
: undefined
}
onPointerOut={() => setHovered(null)}
scale={creditsViewSpring.scale}
>
<sphereGeometry args={[0.25, 32, 32]} />
<animated.meshStandardMaterial
transparent
color="white"
opacity={creditsButtonSpring.opacity}
/>
</animated.mesh>
<animated.mesh
position={[3.25, 5.982, -2.15]}
scale={creditsViewSpring.scale}
>
<sphereGeometry args={[0.25 + 1 / 48, 32, 32]} />
<animated.meshStandardMaterial
transparent
color="black"
opacity={creditsButtonSpring.opacity}
side={BackSide}
/>
</animated.mesh>
</group>
{/* Printer Menu */}
<group>
<Billboard position={[-3.25, 6.65, -2.75]}>
<AnimatedText
maxWidth={4.5}
font="/static/inter.ttf"
fontSize={0.25}
outlineWidth={1 / 60}
fillOpacity={printerMenuSpring.opacity}
outlineOpacity={printerMenuSpring.opacity}
>
CAD and 3D printing
</AnimatedText>
</Billboard>
<Billboard position={[-2.75, 5.25, -2.25]}>
<AnimatedText
maxWidth={3}
textAlign="center"
font="/static/inter.ttf"
fontSize={0.25}
outlineWidth={1 / 60}
fillOpacity={printerMenuSpring.opacity}
outlineOpacity={printerMenuSpring.opacity}
>
Building with electronics
</AnimatedText>
</Billboard>
<Billboard position={[-5.5, 3.175, -3]}>
<AnimatedText
font="/static/inter.ttf"
fontSize={0.25}
outlineWidth={1 / 60}
fillOpacity={printerMenuSpring.opacity}
outlineOpacity={printerMenuSpring.opacity}
>
Gaming
</AnimatedText>
</Billboard>
</group>
{/* Side Menu */}
<group>
<animated.mesh
ref={resumeRef}
position={[2.845, 4.75, 6.454]}
onClick={
currentView === View.SideView
? () => goToView(View.ResumeView)
: undefined
}
onPointerOver={
currentView === View.SideView
? () => setHovered(View.ResumeView)
: undefined
}
onPointerOut={() => setHovered(null)}
scale={resumeViewSpring.scale}
>
<sphereGeometry args={[0.175, 32, 32]} />
<animated.meshStandardMaterial
transparent
color="white"
opacity={sideMenuSpring.opacity}
/>
</animated.mesh>
<animated.mesh
position={[2.845, 4.75, 6.454]}
scale={resumeViewSpring.scale}
>
<sphereGeometry args={[0.175 + 7 / 480, 32, 32]} />
<animated.meshStandardMaterial
transparent
color="black"
opacity={sideMenuSpring.opacity}
side={BackSide}
/>
</animated.mesh>
<Billboard position={[2.845, 5.1875, 6.454]}>
<AnimatedText
font="/static/inter.ttf"
fontSize={0.21875}
outlineWidth={7 / 480}
fillOpacity={sideMenuSpring.opacity}
outlineOpacity={sideMenuSpring.opacity}
>
Resume
</AnimatedText>
</Billboard>
{config.phoneNumber && (
<>
<animated.mesh
ref={phoneRef}
position={[3.75, 5.125, 7]}
onClick={
currentView === View.SideView
? () => goToView(View.PhoneView)
: undefined
}
onPointerOver={
currentView === View.SideView
? () => setHovered(View.PhoneView)
: undefined
}
onPointerOut={() => setHovered(null)}
scale={phoneViewSpring.scale}
>
<sphereGeometry args={[0.175, 32, 32]} />
<animated.meshStandardMaterial
transparent
color="white"
opacity={sideMenuSpring.opacity}
/>
</animated.mesh>
<animated.mesh
position={[3.75, 5.125, 7]}
scale={phoneViewSpring.scale}
>
<sphereGeometry args={[0.175 + 7 / 480, 32, 32]} />
<animated.meshStandardMaterial
transparent
color="black"
opacity={sideMenuSpring.opacity}
side={BackSide}
/>
</animated.mesh>
<Billboard position={[3.75, 5.515, 7]}>
<AnimatedText
font="/static/inter.ttf"
fontSize={0.21875}
outlineWidth={7 / 480}
fillOpacity={sideMenuSpring.opacity}
outlineOpacity={sideMenuSpring.opacity}
>
Contact
</AnimatedText>
</Billboard>
</>
)}
</group>
</>
);
};

View File

@@ -0,0 +1,484 @@
"use client";
import { SpotLight } from "three";
import {
useGLTF,
PerspectiveCamera,
Environment,
Text,
Html,
type PerspectiveCameraProps,
useProgress,
} from "@react-three/drei";
import {
animated as threeAnimated,
useSpring as useThreeSpring,
} from "@react-spring/three";
import {
animated as webAnimated,
useSpring as useWebSpring,
} from "@react-spring/web";
import {
useEffect,
useMemo,
useState,
useRef,
Suspense,
useCallback,
type JSX,
} from "react";
import Stack from "@/util/stack";
import { Canvas } from "@react-three/fiber";
import { mdiArrowLeftBold } from "@mdi/js";
import { Icon } from "@mdi/react";
import { PDF } from "../util/PDF";
import { CellphoneUI } from "../ui/CellphoneUI";
import { PCUI } from "../ui/PCUI";
import { useConfig } from "../hooks/useConfig";
import { GLTFResult } from "@/types/scene";
import { hashtoView, views, View } from "./consts";
import { StaticMeshes } from "./StaticMeshes";
import { Buttons } from "./Buttons";
import { Credits } from "../ui/Credits";
import { isWebGL2Available } from "@react-three/drei";
import { useWindowSize } from "../hooks/useWindowSize";
const AnimatedCam = threeAnimated(PerspectiveCamera);
const AnimatedButton = webAnimated.button as React.ComponentType<
React.ButtonHTMLAttributes<HTMLButtonElement> & { children?: React.ReactNode }
>;
export function Scene(props: JSX.IntrinsicElements["group"]) {
const { nodes, materials } = useGLTF("/scene.glb") as unknown as GLTFResult;
const { progress } = useProgress();
const { width, height } = useWindowSize();
const config = useConfig();
// States
const [pendingView, setPendingView] = useState<View | null>(null);
const [phoneHovered, setPhoneHovered] = useState(false);
const [backHovered, setBackHovered] = useState(false);
const [backClicked, setBackClicked] = useState(false);
const [menuTraversal, setMenuTraversal] = useState<Stack<View>>(() => {
const s = new Stack<View>();
s.push(View.MainView);
const hash = window.location.hash.slice(1);
if (hash in hashtoView && hashtoView[hash] !== View.MainView) {
s.push(hashtoView[hash]);
}
return s;
});
// Variables
const currentView = menuTraversal.peek() ?? View.MainView;
const view = views[currentView];
// Refs
const portalRef = useRef<HTMLElement | null>(null);
// Callbacks
const goPreviousView = useCallback(() => {
setMenuTraversal((prev) => {
if (prev.size() <= 1) return prev;
const next = prev.clone();
next.pop();
setPendingView(next.peek()!);
return next;
});
}, []);
// Memos
const spotlight = useMemo(() => {
const light = new SpotLight("#ffffff");
light.intensity = 175;
light.penumbra = 0.25;
light.castShadow = true;
light.shadow.mapSize.set(2048, 2048);
light.shadow.bias = -0.000025;
return light;
}, []);
// Effects
useEffect(() => {
portalRef.current = document.body!;
}, []);
// Springs
const cameraSpring = useThreeSpring<PerspectiveCameraProps>({
...view,
config: {
precision: 0.0001,
friction: 80,
mass: 10,
clamp: true,
},
});
const { backScale, backOpacity } = useWebSpring({
backScale: backHovered ? 1.25 : 1,
backOpacity: menuTraversal.size() > 1 ? 1 : 0,
onRest: () => backClicked && setBackClicked(false),
});
// Scene
return (
<main className="h-screen w-screen overscroll-none touch-none bg-[#1b1b1b]">
{isWebGL2Available() ? (
width >= 1.5 * height ? (
<>
<div className="pointer-events-none fixed z-[999999999]">
<AnimatedButton
style={{
transform: backScale.to(
(s) => `scale(${s})`
) as unknown as string,
opacity: backOpacity as unknown as number,
pointerEvents: menuTraversal.size() > 1 ? "auto" : "none",
cursor: backHovered ? "pointer" : "auto",
}}
className="m-4"
onMouseEnter={() => setBackHovered(true)}
onMouseLeave={() => setBackHovered(false)}
onClick={() => {
setBackClicked(true);
goPreviousView();
}}
>
<Icon
path={mdiArrowLeftBold}
size={2}
color="white"
className="stroke-[0.75] stroke-black"
/>
</AnimatedButton>
</div>
<Canvas
shadows
gl={{ localClippingEnabled: true, antialias: true, alpha: true }}
>
<Suspense
fallback={
<Html fullscreen>
<div className="pt-10 w-screen h-screen flex flex-col space-y-6 justify-center items-center text-white text-4xl pointer-events-none">
<p>Loading...</p>
<div className="w-48 bg-neutral-700 h-4 rounded-lg">
<div
className="h-full rounded-lg bg-neutral-100"
style={{
width: `${progress.toFixed(0)}%`,
}}
/>
</div>
</div>
</Html>
}
>
<group {...props} dispose={null}>
<Environment preset="apartment" />
<AnimatedCam makeDefault {...cameraSpring} />
<Buttons
menuTraversal={menuTraversal}
setMenuTraversal={setMenuTraversal}
pendingView={pendingView}
setPendingView={setPendingView}
goPreviousView={goPreviousView}
phoneHovered={phoneHovered}
currentView={currentView}
/>
<group>
<primitive
object={spotlight}
position={[-1.915, 20, 1.0925]}
/>
<primitive
object={spotlight.target}
position={[-1.915, 0, 1.0925]}
/>
</group>
<group
position={[2.50415, 0.12973, 3.47808]}
rotation={[0, -Math.PI / 2, 0]}
scale={0.01}
>
<mesh
castShadow
receiveShadow
geometry={nodes.Monitor.geometry}
material={materials.PlasticMaterial}
position={[-175, 415.25, -160]}
>
<Html
transform
receiveShadow
castShadow
pointerEvents={
currentView === View.PCView ? "auto" : "none"
}
occlude="blending"
className="w-[1452px] h-[810px] bg-neutral-400"
scale={10}
position={[0, 170, 0.25]}
raycast={
currentView === View.DesktopView
? () => null
: undefined
}
distanceFactor={10}
style={{
backgroundImage: "url(/static/pc-bg.webp)",
backgroundColor: "#111535",
}}
>
<div className="w-full h-full backdrop-blur-md">
<PCUI />
</div>
</Html>
</mesh>
</group>
<group
position={[4.105, 4.2825, -2.085]}
rotation={[0, -Math.PI / 2, 0]}
>
<mesh
castShadow
receiveShadow
geometry={nodes.SecondaryMonitorMesh.geometry}
material={materials.SecondaryMonitorMaterial}
scale={0.01}
>
<Credits currentView={currentView} />
</mesh>
</group>
<group
position={[3.81024, 3.85444, 6.13972]}
rotation={[-Math.PI, -0.44331, -Math.PI]}
scale={0.035}
>
<mesh
castShadow
receiveShadow
geometry={nodes.Paper3.geometry}
material={materials.PaperMaterial}
>
<Html
transform
receiveShadow
castShadow
pointerEvents={
currentView === View.ResumeView ? "auto" : "none"
}
occlude="blending"
className="w-[800px] h-[1074px] bg-white"
scale={15}
position={[28.7765, 12.431875, 3.725]}
rotation={[-Math.PI / 2, 0, (1 * Math.PI) / 9]}
raycast={
currentView === View.ResumeView
? () => null
: undefined
}
distanceFactor={1}
>
<PDF url="/config/resume.pdf" />
</Html>
</mesh>
</group>
<group
position={[2.58559, 4.69855, 3.29056]}
rotation={[-Math.PI, -0.43633, -Math.PI]}
scale={0.02}
>
<mesh
castShadow
receiveShadow
geometry={nodes.CellphoneMesh.geometry}
material={materials.CellphoneMaterial}
position={[-102.64982, -21.45192, 247.01788]}
>
<Html
transform
receiveShadow
castShadow
pointerEvents={
currentView === View.CellphoneView ? "auto" : "none"
}
occlude="blending"
className="w-[1008px] h-[1614px] bg-blue-100"
position={[0, 2.8, 0]}
rotation={[-Math.PI / 2, 0, Math.PI / 2]}
raycast={
currentView === View.CellphoneView
? () => null
: undefined
}
style={{
backgroundImage: "url('/static/cellphone-bg.webp')",
backgroundColor: "black",
}}
distanceFactor={10}
>
<CellphoneUI />
</Html>
</mesh>
</group>
<group
position={[4, 4.74056, 7.25]}
rotation={[-Math.PI, -Math.PI / 4, -Math.PI]}
scale={0.3}
>
<group
position={[-0.45957, -1.57735, -0.97302]}
rotation={[-Math.PI / 2, 0, 0]}
scale={100}
onClick={
config.phoneNumber && currentView === View.PhoneView
? () => {
window.open(
`tel:${config.phoneNumber}`,
"_blank",
"noopener,noreferrer"
);
}
: undefined
}
onPointerOver={
config.phoneNumber && currentView === View.PhoneView
? () => setPhoneHovered(true)
: undefined
}
onPointerOut={() => setPhoneHovered(false)}
>
<mesh
castShadow
receiveShadow
geometry={nodes.Top.geometry}
material={materials.TopMaterial}
/>
<mesh
castShadow
receiveShadow
geometry={nodes.OfficePhoneBody.geometry}
material={materials.OfficePhoneBodyMaterial}
/>
<mesh
castShadow
receiveShadow
geometry={nodes.Screen.geometry}
material={materials.ScreenMaterial}
/>
<mesh
castShadow
receiveShadow
geometry={nodes.Buttons.geometry}
material={materials.HandleAndButtonsMaterial}
/>
<mesh
castShadow
receiveShadow
geometry={nodes.Handle.geometry}
material={materials.HandleAndButtonsMaterial}
position={[-0.01639, -0.0078, 0.02006]}
rotation={[-0.37437, 0, Math.PI / 2]}
/>
</group>
</group>
<Text
position={[-2.415, 12.5, -6]}
font="/static/inter-bold.ttf"
color="black"
fontSize={3}
>
{config.name[0]}
</Text>
<Text
position={[5.185, 12.5, 1.592]}
font="/static/inter-bold.ttf"
rotation={[0, -Math.PI / 2, 0]}
color="black"
fontSize={3}
>
{config.name[1]}
</Text>
{config.status && (
<Text
position={[5.185, 10, 1.592]}
font="/static/inter.ttf"
rotation={[0, -Math.PI / 2, 0]}
color="black"
fontSize={0.75}
>
{config.status}
</Text>
)}
{config.location && (
<Text
position={[5.185, 8.5, 1.592]}
font="/static/inter.ttf"
rotation={[0, -Math.PI / 2, 0]}
color="black"
fontSize={0.75}
>
📍&#8201;{config.location}
</Text>
)}
<StaticMeshes nodes={nodes} materials={materials} />
</group>
</Suspense>
</Canvas>
</>
) : (
<div
className={`${
config.fallbackUrl ? "pt-12" : ""
} flex flex-col space-y-6 w-screen h-screen text-center justify-center items-center text-white text-4xl`}
>
<p>Screen too small, please rotate</p>
{config.fallbackUrl && (
<p className="text-base">
or visit the{" "}
<a
href={config.fallbackUrl}
target="_blank"
className="text-blue-500 hover:underline"
>
fallback
</a>
</p>
)}
</div>
)
) : (
<div
className={`${
config.fallbackUrl ? "pt-12" : ""
} flex flex-col space-y-6 w-screen h-screen text-center justify-center items-center text-white text-4xl`}
>
<p>
Please enable{" "}
<a
href="https://get.webgl.org"
target="_blank"
className="text-blue-500 hover:underline"
>
WebGL
</a>
</p>
{config.fallbackUrl && (
<p className="text-base">
or visit the{" "}
<a
href={config.fallbackUrl}
target="_blank"
className="text-blue-500 hover:underline"
>
fallback
</a>
</p>
)}
</div>
)}
</main>
);
}
useGLTF.preload("/scene.glb");

View File

@@ -0,0 +1,383 @@
import { GLTFResult } from "@/types/scene";
interface StaticMeshesProps {
nodes: GLTFResult["nodes"];
materials: GLTFResult["materials"];
}
export const StaticMeshes = ({ nodes, materials }: StaticMeshesProps) => (
<>
<group
position={[0.25, 0, 1.25]}
rotation={[0, Math.PI / 2, 0]}
scale={0.15}
>
<group position={[0, 9.35, 0]}>
<mesh
castShadow
receiveShadow
geometry={nodes.Frame.geometry}
material={materials.FrameMaterial}
/>
<mesh
castShadow
receiveShadow
geometry={nodes.OfficeChairChasis.geometry}
material={materials.OfficeChairChasisMaterial}
/>
<mesh
castShadow
receiveShadow
geometry={nodes.Seat.geometry}
material={materials.SeatMaterial}
/>
</group>
</group>
<group
position={[2.50415, 0.12973, 3.47808]}
rotation={[0, -Math.PI / 2, 0]}
scale={0.01}
>
<mesh
castShadow
receiveShadow
geometry={nodes.Mouse.geometry}
material={materials.PlasticMaterial}
position={[-250, 415.5, -232.5]}
/>
<mesh
castShadow
receiveShadow
geometry={nodes.CDSlot.geometry}
material={materials.PlasticMaterial}
position={[0, 0, 8.5]}
/>
<group position={[8, 0, 8.5]}>
<mesh
castShadow
receiveShadow
geometry={nodes.PCMesh.geometry}
material={materials.PlasticMaterial}
/>
<mesh
castShadow
receiveShadow
geometry={nodes.CDSlotLining.geometry}
material={materials.CDSlotLiningMaterial}
/>
</group>
<mesh
castShadow
receiveShadow
geometry={nodes.Keyboard.geometry}
material={materials.PlasticMaterial}
position={[-275, 413.49, -190]}
/>
</group>
<group position={[2.4, 4.275, 4.25]}>
<mesh
castShadow
receiveShadow
geometry={nodes.MousepadMesh.geometry}
material={materials.MousepadMaterial}
rotation={[-Math.PI / 2, 0, 0]}
scale={[100, 121.73563, 1.32215]}
/>
</group>
<group
position={[4.125, 4.27318, 4.245]}
rotation={[0, -Math.PI / 2, 0]}
scale={3}
>
<group rotation={[-Math.PI / 2, 0, 0]} scale={100}>
<mesh
castShadow
receiveShadow
geometry={nodes.DeskLampChasis.geometry}
material={materials.DeskLampChasisMaterial}
/>
<mesh
castShadow
receiveShadow
geometry={nodes.Arm.geometry}
material={materials.ArmMaterial}
/>
<mesh
castShadow
receiveShadow
geometry={nodes.Light.geometry}
material={materials.LightMaterial}
/>
</group>
</group>
<group
position={[-5.5, 4.90808, -4.15]}
rotation={[0, Math.PI / 2, 0]}
scale={0.06}
>
<mesh
castShadow
receiveShadow
geometry={nodes.Printer3DMesh.geometry}
material={materials.Printer3DMaterial}
position={[0, 6.59846, 0]}
/>
</group>
<group
position={[3.81024, 3.85444, 6.13972]}
rotation={[-Math.PI, -0.44331, -Math.PI]}
scale={0.035}
>
<mesh
castShadow
receiveShadow
geometry={nodes.Paper1.geometry}
material={materials.PaperMaterial}
/>
<mesh
castShadow
receiveShadow
geometry={nodes.Paper2.geometry}
material={materials.PaperMaterial}
/>
</group>
<group
position={[2.08047, 4.30502, 6.90876]}
rotation={[-Math.PI, -0.34208, -Math.PI]}
scale={0.2}
>
<mesh
castShadow
receiveShadow
geometry={nodes.PenAccent.geometry}
material={materials.PenAccentMaterial}
/>
<mesh
castShadow
receiveShadow
geometry={nodes.PenBody.geometry}
material={materials.PenBodyMaterial}
/>
<mesh
castShadow
receiveShadow
geometry={nodes.PenTip.geometry}
material={materials.TipMaterial}
/>
</group>
<group
position={[2.22647, 4.69018, -3.38313]}
rotation={[2.71601, 0.78387, -1.26107]}
scale={0.01}
>
<mesh
castShadow
receiveShadow
geometry={nodes.HeadphonesBody.geometry}
material={materials.HeadphonesBodyMaterial}
/>
<mesh
castShadow
receiveShadow
geometry={nodes.HeadphonesAccent.geometry}
material={materials.HeadphonesAccentMaterial}
/>
<mesh
castShadow
receiveShadow
geometry={nodes.Cover.geometry}
material={materials.CoverMaterial}
/>
</group>
<group
position={[3.67873, 4.51496, 3.10402]}
rotation={[-Math.PI, 0.43633, -Math.PI]}
scale={5}
>
<mesh
castShadow
receiveShadow
geometry={nodes.MugMesh.geometry}
material={materials.MugMaterial}
position={[0, 0.00112, 0]}
/>
</group>
<group
position={[-1.5, 4.275, -4]}
rotation={[0, Math.PI / 2, 0]}
scale={[1.5, 1, 1.5]}
>
<mesh
castShadow
receiveShadow
geometry={nodes.DeskMatMesh.geometry}
material={materials.DeskMatMaterial}
rotation={[-Math.PI / 2, 0, 0]}
scale={[100, 121.73563, 1.32215]}
/>
</group>
<group
position={[1.00097, 4.26, -4.75722]}
rotation={[0, -Math.PI / 2, 0]}
scale={0.4}
>
<mesh
castShadow
receiveShadow
geometry={nodes.SolderingIronBody.geometry}
material={materials.SolderingIronBodyMaterial}
/>
<mesh
castShadow
receiveShadow
geometry={nodes.SolderingIronAccent.geometry}
material={materials.SolderingIronAccentMaterial}
/>
<mesh
castShadow
receiveShadow
geometry={nodes.Sponge.geometry}
material={materials.SpongeMaterial}
/>
<mesh
castShadow
receiveShadow
geometry={nodes.IronGrip.geometry}
material={materials.IronGripMaterial}
/>
<mesh
castShadow
receiveShadow
geometry={nodes.Switch.geometry}
material={materials.SwitchMaterial}
/>
<mesh
castShadow
receiveShadow
geometry={nodes.Holster.geometry}
material={materials.HolsterMaterial}
/>
<mesh
castShadow
receiveShadow
geometry={nodes.SwitchLabel.geometry}
material={materials.SwitchLabelMaterial}
/>
</group>
<group
position={[-5.67154, 0.12162, -2.214]}
rotation={[0, -Math.PI / 4, 0]}
scale={0.015}
>
<mesh
castShadow
receiveShadow
geometry={nodes.CalculatorMesh.geometry}
material={materials.CalculatorMaterial}
position={[-74.47193, 0, -82.42568]}
/>
</group>
<group
position={[-1, 0.025, 1.25]}
rotation={[0, Math.PI / 2, 0]}
scale={3}
>
<group rotation={[-Math.PI / 2, 0, 0]} scale={100}>
<mesh
castShadow
receiveShadow
geometry={nodes.MainRug.geometry}
material={materials.RugDarkMaterial}
/>
<mesh
castShadow
receiveShadow
geometry={nodes.Border.geometry}
material={materials.RugLightMaterial}
/>
</group>
</group>
<group rotation={[-Math.PI / 2, 0, 0]} scale={100}>
<mesh
castShadow
receiveShadow
geometry={nodes.LDeskMesh.geometry}
material={materials.LDeskMaterial}
position={[0, 0, 0.02012]}
scale={5}
/>
</group>
<mesh
castShadow
receiveShadow
geometry={nodes.ScrewdriverMesh.geometry}
material={materials.ScrewdriverMaterial}
position={[0.8, 4.3682, -3]}
rotation={[Math.PI, -Math.PI / 3, Math.PI]}
scale={5}
/>
<mesh
castShadow
receiveShadow
geometry={nodes.WalletMesh.geometry}
material={materials.WalletMaterial}
position={[3.95, 4.50877, -0.5]}
rotation={[0, -Math.PI / 4, 0]}
scale={0.5}
/>
<mesh
castShadow
receiveShadow
geometry={nodes.PlantMesh.geometry}
material={materials.PlantMaterial}
position={[4, 4.27081, -5]}
scale={0.25}
/>
<mesh
castShadow
receiveShadow
geometry={nodes.Controller1Mesh.geometry}
material={materials.Controller1Material}
position={[-5.55, 2, -3.15]}
scale={0.04}
/>
<mesh
castShadow
receiveShadow
geometry={nodes.Controller2Mesh.geometry}
material={materials.Controller2Material}
position={[-5.55, 2, -4.15]}
scale={0.04}
/>
<mesh
castShadow
receiveShadow
geometry={nodes.Wall.geometry}
material={materials.WallMaterial}
/>
<mesh
castShadow
receiveShadow
geometry={nodes.Floor.geometry}
material={materials.FloorMaterial}
/>
<mesh
geometry={nodes.WindowGlass.geometry}
position={[-2.501, 7.682, -6.529]}
>
<meshStandardMaterial
color="white"
metalness={1}
roughness={0}
opacity={0.5}
transparent
/>
</mesh>
<mesh
castShadow
receiveShadow
geometry={nodes.WindowFrame.geometry}
material={materials.WindowFrameMaterial}
/>
</>
);

View File

@@ -0,0 +1,73 @@
import { type PerspectiveCameraProps } from "@react-three/drei";
export const views: PerspectiveCameraProps[] = [
{
// MainView
position: [-21.795, 24.75, 21.239],
rotation: [-Math.PI / 4, -0.61087, -Math.PI / 6],
},
{
// PrinterView
position: [-6.6, 6.25, 2.521],
rotation: [-0.24997, -0.49109, -0.11983],
},
{
// ResumeView
position: [2.845, 6, 6.454],
rotation: [-Math.PI / 2, 0, -2.34921],
},
{
// PCView
position: [1.25, 5.9822, 1.725],
rotation: [0, -Math.PI / 2, 0],
},
{
// CellphoneView
position: [2.3584, 5.6, -2.0546],
rotation: [-Math.PI / 2, 0, -1.13795],
},
{
// DesktopView
position: [-3, 10.35, 5.8],
rotation: [-Math.PI / 4, -0.61087, -Math.PI / 6],
},
{
// PhoneView
position: [3.0143, 5.5, 6.0997],
rotation: [-2.55743, -0.69621, -2.74104],
},
{
// SideView
position: [0.5, 7.75, 4.15],
rotation: [-2.1005, -0.68155, -2.35619],
},
{
// CreditsView
position: [1.25, 5.9822, -2.075],
rotation: [0, -Math.PI / 2, 0],
},
] as const;
export enum View {
MainView = 0,
PrinterView = 1,
ResumeView = 2,
PCView = 3,
CellphoneView = 4,
DesktopView = 5,
PhoneView = 6,
SideView = 7,
CreditsView = 8,
}
export const hashtoView: Record<string, View> = {
hobbies: View.PrinterView,
resume: View.ResumeView,
projects: View.PCView,
socials: View.CellphoneView,
contact: View.PhoneView,
};
export const viewToHash = Object.fromEntries(
Object.entries(hashtoView).map(([hash, view]) => [view, hash])
) as Record<View, string>;

View File

@@ -0,0 +1,194 @@
"use client";
import {
SiAppstore,
SiAppstoreHex,
SiMatrix,
SiLinkedin,
SiLinkedinHex,
SiGitea,
SiGiteaHex,
SiGithub,
SiInstagram,
SiInstagramHex,
} from "@icons-pack/react-simple-icons";
import {
mdiMusic,
mdiCog,
mdiImageOutline,
mdiCloud,
mdiPhone,
mdiMessage,
mdiWeb,
mdiCamera,
mdiContacts,
mdiCalculator,
mdiNetworkStrength3,
mdiWifi,
mdiBattery80,
mdiHelp,
} from "@mdi/js";
import Icon from "@mdi/react";
import { useRef, useState } from "react";
import { Time } from "../util/Time";
import { useConfig } from "../hooks/useConfig";
export const CellphoneUI = () => {
const config = useConfig();
const [hovered, setHovered] = useState<boolean>(false);
const debounceTimeout = useRef<NodeJS.Timeout | undefined>(undefined);
const handleEnter = () => {
clearTimeout(debounceTimeout.current);
setHovered(true);
};
const handleLeave = () => {
debounceTimeout.current = setTimeout(() => {
setHovered(false);
}, 150);
};
const appClass =
"size-40 rounded-[2.5rem] bg-[#1e1e1e] p-6 transform transition-all duration-300 hover:scale-110";
const fillerClass = `opacity-${hovered ? 50 : 100}`;
return (
<div className="flex flex-col size-full">
<div className="flex text-white text-3xl justify-between py-2 px-4">
<Time />
<div className="flex space-x-2 items-center">
<Icon path={mdiNetworkStrength3} color="white" className="size-8" />
<Icon path={mdiWifi} color="white" className="size-8" />
<Icon path={mdiBattery80} color="white" className="size-8" />
<p>75%</p>
</div>
</div>
<div className="p-8 pt-0 flex flex-col size-full space-y-8">
<div className="h-full text-white p-10 mb-4 rounded-[5rem] grid grid-cols-4 auto-rows-min gap-20">
<div className={`${appClass} ${fillerClass}`}>
<SiAppstore color={SiAppstoreHex} className="size-full p-2" />
</div>
<div className={`${appClass} ${fillerClass}`}>
<Icon path={mdiMusic} color="#f74258" />
</div>
<div className={`${appClass} ${fillerClass}`}>
<Icon path={mdiCog} className="text-neutral-300" />
</div>
<div className={`${appClass} ${fillerClass}`}>
<Icon path={mdiImageOutline} className="text-neutral-300" />
</div>
<div className={`${appClass} ${fillerClass}`.replace("p-6", "p-8")}>
<Icon path={mdiContacts} className="text-neutral-300" />
</div>
<div className={`${appClass} ${fillerClass}`}>
<Icon path={mdiCloud} className="text-neutral-300" />
</div>
<div className={`${appClass} ${fillerClass}`}>
<Icon path={mdiCalculator} className="text-neutral-300" />
</div>
{config.socials?.matrix && (
<a
href={`https://matrix.to/#/${config.socials.matrix}`}
target="_blank"
className={appClass}
onMouseOver={handleEnter}
onMouseOut={handleLeave}
>
<SiMatrix color="white" className="size-full p-2" />
</a>
)}
{config.socials?.linkedin && (
<a
href={`https://linkedin.com/in/${config.socials.linkedin}`}
target="_blank"
className={appClass}
onMouseOver={handleEnter}
onMouseOut={handleLeave}
>
<SiLinkedin color={SiLinkedinHex} className="size-full p-2" />
</a>
)}
{config.socials?.gitea && (
<a
href={config.socials.gitea}
target="_blank"
className={appClass}
onMouseOver={handleEnter}
onMouseOut={handleLeave}
>
<SiGitea color={SiGiteaHex} className="size-full p-2" />
</a>
)}
{config.socials?.github && (
<a
href={`https://github.com/${config.socials.github}`}
target="_blank"
className={appClass}
onMouseOver={handleEnter}
onMouseOut={handleLeave}
>
<SiGithub color="white" className="size-full p-2" />
</a>
)}
{config.socials?.instagram && (
<a
href={`https://instagram.com/${config.socials.instagram}`}
target="_blank"
className={appClass}
onMouseOver={handleEnter}
onMouseOut={handleLeave}
>
<SiInstagram color={SiInstagramHex} className="size-full p-2" />
</a>
)}
</div>
<div className="h-16 rounded-[5rem] flex justify-end">
<button
className="bg-neutral-800/75 rounded-[5rem] text-white h-full aspect-square p-2"
onClick={() =>
alert(
"Each app represents a link! Hover over a valid link to see all links."
)
}
>
<Icon path={mdiHelp} className="h-full" />
</button>
</div>
<div className="h-64 bg-neutral-800/75 backdrop-blur-lg rounded-[5rem] text-black p-10 -mt-4">
<div className="flex h-full rounded-[2.5rem] gap-20 my-auto">
<a
href={config.phoneNumber ? `tel:${config.phoneNumber}` : ""}
target="_blank"
className={`${appClass} ${
!config.phoneNumber ? fillerClass : ""
}`}
onMouseOver={config.phoneNumber ? handleEnter : undefined}
onMouseOut={config.phoneNumber ? handleLeave : undefined}
>
<Icon path={mdiPhone} color="#23de20" />
</a>
<div className={`${appClass} ${fillerClass}`}>
<Icon path={mdiMessage} color="#23de20" />
</div>
<a
href={config.socials?.custom ? config.socials.custom : ""}
target="_blank"
className={`${appClass} ${
!config.socials?.custom ? fillerClass : ""
}`}
onMouseOver={config.socials?.custom ? handleEnter : undefined}
onMouseOut={config.socials?.custom ? handleLeave : undefined}
>
<Icon path={mdiWeb} color="#1e93f7" />
</a>
<div className={`${appClass} ${fillerClass}`}>
<Icon path={mdiCamera} className="text-neutral-300" />
</div>
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,154 @@
import { Html } from "@react-three/drei";
import { View } from "../scene/consts";
interface CreditsProps {
currentView: View;
}
export const Credits = ({ currentView }: CreditsProps) => {
return (
<Html
transform
receiveShadow
castShadow
pointerEvents={currentView === View.CreditsView ? "auto" : "none"}
occlude="blending"
className="w-[1452px] h-[810px] bg-neutral-400"
scale={10}
position={[0, 170, 0.25]}
raycast={currentView === View.CreditsView ? () => null : undefined}
distanceFactor={10}
>
<h1 className="text-9xl font-bold text-center py-24">Credits</h1>
<ul
className={`pointer-events-${
currentView === View.CreditsView ? "auto" : "none"
} w-full mx-24 text-blue-800 text-5xl`}
>
<li className="mb-4 text-black">
<a
className="hover:underline text-blue-800"
href="https://poly.pizza/m/BfrWTbXWBl"
target="_blank"
>
L Shaped Desk by
</a>
{" by "}
<a
className="hover:underline text-blue-800"
href="https://poly.pizza/u/Zsky"
target="_blank"
>
Zsky [CC-BY]
</a>
</li>
<li className="mb-4 text-black">
<a
className="hover:underline text-blue-800"
href="https://poly.pizza/m/dCEsSsJJ1Md"
target="_blank"
>
Office Chair
</a>
{" by "}
<a
className="hover:underline text-blue-800"
href="https://poly.pizza/u/CMHT%20Oculus"
target="_blank"
>
CMHT Oculus [CC-BY]
</a>
</li>
<li className="mb-4 text-black">
<a
className="hover:underline text-blue-800"
href="https://poly.pizza/m/YxfMuchpUF"
target="_blank"
>
Office Phone
</a>
{" and "}
<a
className="hover:underline text-blue-800"
href="https://poly.pizza/m/S5sNqsyyOs"
target="_blank"
>
Mousepad
</a>
{" by "}
<a
className="hover:underline text-blue-800"
href="https://poly.pizza/u/dook"
target="_blank"
>
dook [CC-BY]
</a>
</li>
<li className="mb-4 text-black">
<a
className="hover:underline text-blue-800"
href="https://poly.pizza/m/eLUe9OMQrj"
target="_blank"
>
Soldering Iron
</a>
{" by "}
<a
className="hover:underline text-blue-800"
href="https://poly.pizza/u/J-Toastie"
target="_blank"
>
J-Toastie [CC-BY]
</a>
</li>
<li className="mb-4">
<p className="text-black">
<a
className="hover:underline text-blue-800"
href="https://poly.pizza/m/uJDWrSJGVH"
target="_blank"
>
Light Desk
</a>
{", "}
<a
className="hover:underline text-blue-800"
href="https://poly.pizza/m/7H5qKjuxVY"
target="_blank"
>
Rug
</a>
{", and "}
<a
className="hover:underline text-blue-800"
href="https://poly.pizza/m/EipzkrS9nG"
target="_blank"
>
Window Large
</a>
{" by "}
<a
className="hover:underline text-blue-800"
href="https://poly.pizza/u/Quaternius"
target="_blank"
>
Quaternius [CC0]
</a>
</p>
</li>
<li className="flex">
<p className="text-black">
{"All other models by "}
<a
className="hover:underline text-blue-800"
href="https://poly.pizza/u/Poly%20by%20Google"
target="_blank"
>
Poly by Google [CC-BY]
</a>
</p>
</li>
</ul>
</Html>
);
};

487
src/components/ui/PCUI.tsx Normal file
View File

@@ -0,0 +1,487 @@
import {
SiArchlinux,
SiArchlinuxHex,
SiFirefoxbrowser,
SiFirefoxbrowserHex,
SiVscodium,
SiVscodiumHex,
} from "@icons-pack/react-simple-icons";
import {
mdiArrowLeft,
mdiArrowRight,
mdiArrowUp,
mdiBackspace,
mdiBattery80,
mdiBell,
mdiBluetooth,
mdiBrightness5,
mdiConsole,
mdiFileCode,
mdiFileImage,
mdiFolder,
mdiFolderDownload,
mdiFolderHome,
mdiFolderImage,
mdiFolderNetwork,
mdiFolderText,
mdiHarddisk,
mdiHelp,
mdiMagnify,
mdiMenu,
mdiPin,
mdiTrashCan,
mdiVolumeHigh,
mdiWifi,
mdiWindowClose,
mdiWindowMinimize,
mdiWindowRestore,
} from "@mdi/js";
import Icon from "@mdi/react";
import { Time } from "../util/Time";
import { LiveDate } from "../util/Date";
import { useConfig } from "../hooks/useConfig";
import { type HTMLAttributes, type FC, useState, Fragment } from "react";
import Image from "next/image";
export const PCUI = () => {
const config = useConfig();
const [openFolders, setOpenFolders] = useState<Set<number>>(new Set());
const [selectedFile, setSelectedFile] = useState<
[number | null, number | null]
>([null, null]);
const toggleFolder = (idx: number) => {
setOpenFolders((prev) => {
const next = new Set(prev);
next[next.has(idx) ? "delete" : "add"](idx);
return next;
});
};
return (
<div className="flex flex-col size-full select-none">
<div className="m-2 mb-0">
<Window className="flex h-[39.5rem] justify-between">
<div className="w-full overflow-y-auto max-h-full">
<table className="w-full table-fixed">
<thead className="sticky top-0 z-10 shadow-[0_1px_0_0_theme(colors.neutral.700)] bg-neutral-950">
<tr className="py-2 text-sm font-semibold text-left">
<th className="size-8 px-4">&nbsp;</th>
<th className="px-1">Name</th>
<th className="px-1">Tags</th>
<th className="px-1 w-24">Date</th>
<th className="px-1 w-16">Owner</th>
<th className="px-1 w-24">Permissions</th>
</tr>
</thead>
<tbody>
{config.projects ? (
config.projects.map((p, i) => (
<Fragment key={i}>
<tr
onClick={() => {
if (selectedFile[0] !== i) toggleFolder(i);
}}
className="h-12 cursor-pointer transition-all duration-300 hover:bg-blue-950 border-b border-neutral-700"
>
<td>
<Icon
path={mdiFolder}
className="text-blue-500 m-1"
/>
</td>
<td className="px-1 whitespace-nowrap overflow-x-clip">
{p.name}
</td>
<td className="px-1 whitespace-nowrap overflow-x-clip space-x-1">
{p.tags?.map((tag, j) => (
<span
key={j}
className="bg-neutral-800 text-neutral-400 rounded-lg px-1.5"
>
{tag}
</span>
))}
</td>
<td className="px-1 whitespace-nowrap text-neutral-400">
{p.date}
</td>
<td className="px-1 whitespace-nowrap text-neutral-400">
ahmed
</td>
<td className="px-1 whitespace-nowrap text-neutral-400">
drwxr-xr-x
</td>
</tr>
<tr
className={`cursor-pointer transition-all duration-300 ${
selectedFile[0] === i && selectedFile![1] === 0
? "bg-blue-900"
: "hover:bg-blue-950"
}`}
onClick={() =>
setSelectedFile(() =>
selectedFile[0] === i && selectedFile![1] === 0
? [null, null]
: [i, 0]
)
}
>
<td colSpan={6}>
<div
className={`grid overflow-hidden transition-all duration-300 border-b border-neutral-700 ${
openFolders.has(i)
? "h-12 opacity-100"
: "h-0 opacity-0"
}`}
style={{
gridTemplateColumns:
"2rem 1fr 1fr 6rem 4rem 6rem",
}}
>
<Icon
path={mdiFileCode}
className="text-orange-400 m-1 w-6 h-10 mx-auto"
/>
<p className="text-neutral-200 whitespace-nowrap mx-1 my-auto">
project.html
</p>
<div />
<p className="text-neutral-400 whitespace-nowrap mx-1 my-auto">
{p.date}
</p>
<p className="text-neutral-400 whitespace-nowrap mx-1 my-auto">
ahmed
</p>
<p className="text-neutral-400 whitespace-nowrap mx-1 my-auto">
-rw-r--r--
</p>
</div>
</td>
</tr>
{p.images?.map((img, j) => (
<tr
key={j}
className={`cursor-pointer transition-all duration-300 ${
selectedFile[0] === i && selectedFile[1] === j + 1
? "bg-blue-900"
: "hover:bg-blue-950"
}`}
onClick={() =>
setSelectedFile(() =>
selectedFile[0] === i && selectedFile[1] === j + 1
? [null, null]
: [i, j + 1]
)
}
>
<td colSpan={6}>
<div
className={`grid overflow-hidden transition-all duration-300 border-b border-neutral-700 ${
openFolders.has(i)
? "h-12 opacity-100"
: "h-0 opacity-0"
}`}
style={{
gridTemplateColumns:
"2rem 1fr 1fr 6rem 4rem 6rem",
}}
>
<Icon
path={mdiFileImage}
className="text-purple-400 m-1 w-6 h-10 mx-auto"
/>
<p className="text-neutral-200 whitespace-nowrap mx-1 my-auto">
{img}
</p>
<div />
<p className="text-neutral-400 whitespace-nowrap mx-1 my-auto">
{p.date}
</p>
<p className="text-neutral-400 whitespace-nowrap mx-1 my-auto">
ahmed
</p>
<p className="text-neutral-400 whitespace-nowrap mx-1 my-auto">
-rw-r--r--
</p>
</div>
</td>
</tr>
))}
</Fragment>
))
) : (
<tr />
)}
</tbody>
</table>
</div>
{config.projects && (
<div
className={`relative h-full transition-all duration-300 ${
selectedFile[0] !== null ? "w-[34rem]" : "w-0"
} overflow-hidden`}
>
<div
className={`absolute inset-y-0 right-0 bg-neutral-900 w-96 rounded-br-lg transform ${
selectedFile[0] !== null
? "translate-x-0"
: "translate-x-full"
} transition-transform duration-300 overflow-y-auto`}
>
{selectedFile[0] !== null && selectedFile[1] === 0 ? (
<div className="p-8">
<h1 className="text-center text-2xl font-semibold text-neutral-200 wrap-anywhere mb-8">
{config.projects[selectedFile[0]!].name}
</h1>
<div className="flex flex-wrap space-x-2 space-y-2 mb-8 justify-center">
{config.projects[selectedFile[0]!].tags?.map((tag, j) => (
<span
key={j}
className="bg-neutral-800 text-neutral-300 rounded-lg px-1.5 h-fit"
>
{tag}
</span>
))}
</div>
<p className="text-neutral-300 wrap-anywhere pb-8">
{config.projects[selectedFile[0]!].description}
</p>
{config.projects[selectedFile[0]!].links !== undefined && (
<>
<h2 className="text-xl font-semibold text-neutral-200 mb-2">
Links
</h2>
<ul>
{config.projects[selectedFile[0]!].links?.map(
(link, j) => (
<li
key={j}
className="text-neutral-300 wrap-anywhere"
>
<a
href={link}
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:underline"
>
{link}
</a>
</li>
)
)}
</ul>
</>
)}
</div>
) : selectedFile[0] !== null && selectedFile[1] !== 0 ? (
<div className="p-2">
<Image
src={`/images/${
config.projects[selectedFile[0]!]?.images?.[
selectedFile[1]! - 1
]
}`}
className="rounded-lg"
width={376}
height={624}
alt={`Project ${selectedFile[0]} image ${selectedFile[1]}`}
/>
</div>
) : (
<></>
)}
</div>
</div>
)}
</Window>
</div>
<Taskbar />
</div>
);
};
const Window: FC<HTMLAttributes<HTMLDivElement>> = ({ children, ...props }) => {
return (
<div className="bg-neutral-950 size-full rounded-xl flex flex-col relative">
<div className="relative flex items-center h-8 shrink-0">
<div className="flex h-full space-x-1">
<Icon
path={mdiFolder}
className="text-neutral-400 h-full w-fit rounded-tl-lg rounded-br-lg transition-all hover:bg-blue-800 hover:ring-blue-500 hover:ring-1 p-1.5"
/>
<Icon
path={mdiPin}
className="text-neutral-400 h-full w-fit rounded-b-lg transition-all hover:bg-blue-800 hover:ring-blue-500 hover:ring-1 p-1.5"
/>
</div>
<p className="absolute left-1/2 transform -translate-x-1/2 text-white">
projects Dolphin
</p>
<div className="flex h-full space-x-1 ml-auto justify-end">
<Icon
path={mdiWindowMinimize}
className="text-neutral-100 h-full w-fit rounded-b-lg transition-all hover:bg-amber-800 hover:ring-amber-500 hover:ring-1 p-1.5"
/>
<Icon
path={mdiWindowRestore}
className="text-neutral-100 h-full w-fit rounded-b-lg transition-all hover:bg-green-800 hover:ring-green-500 hover:ring-1 p-1.5"
/>
<Icon
path={mdiWindowClose}
className="text-neutral-100 h-full w-fit rounded-tr-lg rounded-bl-lg transition-all hover:bg-red-800 hover:ring-red-500 hover:ring-1 p-1.5"
/>
</div>
</div>
<div className="flex bg-neutral-950 size-full rounded-b-lg text-white">
<div className="h-full w-fit bg-neutral-950 flex flex-col rounded-bl-lg">
<div className="h-14 p-2 pr-1 flex justify-center w-fit space-x-1">
<Icon
path={mdiArrowLeft}
className="text-neutral-100 h-full w-fit rounded-md transition-all hover:ring-neutral-500 hover:ring-1 p-2"
/>
<Icon
path={mdiArrowRight}
className="text-neutral-100 h-full w-fit rounded-md transition-all hover:ring-neutral-500 hover:ring-1 p-2"
/>
<Icon
path={mdiArrowUp}
className="text-neutral-100 h-full w-fit rounded-md transition-all hover:ring-neutral-500 hover:ring-1 p-2"
/>
</div>
<div className="flex flex-col space-y-1">
<p className="text-neutral-400 px-2">Places</p>
<div className="flex px-2 h-6 space-x-2 transition-all hover:bg-blue-950">
<Icon path={mdiFolderHome} className="h-full text-blue-500" />
<p>Home</p>
</div>
<div className="flex px-2 h-6 space-x-2 transition-all hover:bg-blue-950">
<Icon path={mdiFolder} className="h-full text-blue-500" />
<p>Desktop</p>
</div>
<div className="flex px-2 h-6 space-x-2 transition-all hover:bg-blue-950">
<Icon path={mdiFolderText} className="h-full text-blue-500" />
<p>Documents</p>
</div>
<div className="flex px-2 h-6 space-x-2 transition-all hover:bg-blue-950">
<Icon path={mdiFolderDownload} className="h-full text-blue-500" />
<p>Downloads</p>
</div>
<div className="flex px-2 h-6 space-x-2 transition-all hover:bg-blue-950">
<Icon path={mdiFolderImage} className="h-full text-blue-500" />
<p>Pictures</p>
</div>
<div className="flex px-2 h-6 space-x-2 transition-all hover:bg-blue-950">
<Icon path={mdiFolder} className="h-full text-blue-500" />
<p>Videos</p>
</div>
<div className="flex px-2 h-6 space-x-2 transition-all hover:bg-blue-950">
<Icon path={mdiTrashCan} className="h-full text-neutral-400" />
<p>Trash</p>
</div>
<div className="h-8" />
<p className="text-neutral-400 px-2">Remote</p>
<div className="flex px-2 h-6 space-x-2 transition-all hover:bg-blue-950">
<Icon path={mdiFolderNetwork} className="h-full text-blue-500" />
<p>Network</p>
</div>
<div className="h-8" />
<p className="text-neutral-400 px-2">Devices</p>
<div className="flex px-2 h-6 space-x-2 transition-all hover:bg-blue-950">
<Icon path={mdiHarddisk} className="h-full text-neutral-400" />
<p>nvme0n1p2</p>
</div>
<div className="h-56 p-2">
<button
className="h-12 absolute bottom-2"
onClick={() =>
alert(
'Each folder represents a project! The folder\'s tags and date are connected to the project. Click on a folder to expand it, revealing a "project.html" file and any associated images that you can open.'
)
}
>
<Icon
path={mdiHelp}
className="text-neutral-100 h-full w-fit rounded-md transition-all hover:ring-neutral-500 hover:ring-1 p-2"
/>
</button>
</div>
</div>
</div>
<div className="w-full">
<div className="bg-neutral-950 flex items-center h-14 space-x-2 p-2 pl-1">
<div className="rounded-md ring-1 ring-neutral-600 size-full">
<input
type="text"
className="size-full text-white px-2"
defaultValue="/home/ahmed/Desktop/projects"
/>
</div>
<div className="flex justify-center h-full w-fit space-x-1">
<Icon
path={mdiBackspace}
className="text-neutral-100 h-full w-fit rounded-md transition-all hover:ring-neutral-500 hover:ring-1 p-2"
/>
<Icon
path={mdiMagnify}
className="text-neutral-100 h-full w-fit rounded-md transition-all hover:ring-neutral-500 hover:ring-1 p-2"
/>
<Icon
path={mdiMenu}
className="text-neutral-100 h-full w-fit rounded-md transition-all hover:ring-neutral-500 hover:ring-1 p-2"
/>
</div>
</div>
<div {...props}>{children}</div>
</div>
</div>
</div>
);
};
const Taskbar = () => {
return (
<div className="h-20 p-2">
<div className="bg-neutral-950 size-full rounded-xl flex justify-between space-x-3 p-3">
<div className="flex space-x-4 h-full w-fit items-center">
<SiArchlinux
color={SiArchlinuxHex}
className="h-full w-fit transition-transform hover:scale-110"
/>
<div className="w-8 h-full">
<div className="w-full h-1/2 bg-blue-900 outline-1 outline-blue-600 transition-all hover:bg-blue-700 hover:outline-blue-400" />
<div className="bg-neutral-800 w-full h-1/2 outline-1 outline-neutral-600 transition-all hover:bg-neutral-700 hover:outline-neutral-400" />
</div>
<Icon
path={mdiFolder}
className="text-blue-500 h-full w-fit transition-transform hover:scale-110 border-b-2"
/>
<SiVscodium
color={SiVscodiumHex}
className="h-full w-fit transition-transform hover:scale-110"
/>
<Icon
path={mdiConsole}
className="text-neutral-400 h-full w-fit transition-transform hover:scale-110"
/>
<SiFirefoxbrowser
color={SiFirefoxbrowserHex}
className="h-full w-fit transition-transform hover:scale-110"
/>
</div>
<div className="flex space-x-2 h-full w-fit items-center">
<Icon path={mdiBell} className="text-white h-1/2" />
<Icon path={mdiBrightness5} className="text-white h-1/2" />
<Icon path={mdiVolumeHigh} className="text-white h-1/2" />
<Icon path={mdiBluetooth} className="text-white h-1/2" />
<Icon path={mdiWifi} className="text-white h-1/2" />
<Icon path={mdiBattery80} className="text-white h-1/2" />
<div className="flex flex-col items-center">
<Time showAMPM className="text-white text-xl" />
<LiveDate className="text-white" />
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,30 @@
"use client";
import {
type JSX,
useState,
useEffect,
useCallback,
HTMLAttributes,
} from "react";
export const LiveDate = (
props: HTMLAttributes<HTMLParagraphElement>
): JSX.Element => {
const formatDate = useCallback((date: Date): string => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}, []);
const [date, setDate] = useState<string>(formatDate(new Date()));
useEffect(() => {
const id = setInterval(() => {
setDate(formatDate(new Date()));
}, 10000);
return () => clearInterval(id);
}, [formatDate]);
return <p {...props}>{date}</p>;
};

View File

@@ -0,0 +1,38 @@
"use client";
import { mdiOpenInNew } from "@mdi/js";
import Icon from "@mdi/react";
import { useState } from "react";
import { Document, Page as PdfPage, pdfjs } from "react-pdf";
import "react-pdf/dist/Page/AnnotationLayer.css";
import "react-pdf/dist/Page/TextLayer.css";
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
"pdfjs-dist/build/pdf.worker.min.mjs",
import.meta.url
).toString();
export function PDF({ url }: { url: string }) {
const [numPages, setNumPages] = useState<number>(0);
function onLoadSuccess({ numPages }: { numPages: number }) {
setNumPages(numPages);
}
return (
<div className="overflow-y-auto h-full">
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="fixed z-10 m-2 p-2 rounded-xl shadow-neutral-300 shadow-xl bg-white opacity-50 hover:opacity-100 transition-opacity duration-200 ease-in-out"
>
<Icon path={mdiOpenInNew} size={1.5} className="text-gray-600" />
</a>
<Document file={url} onLoadSuccess={onLoadSuccess}>
{Array.from({ length: numPages }, (_, i) => (
<PdfPage key={i} pageNumber={i + 1} width={800} />
))}
</Document>
</div>
);
}

View File

@@ -0,0 +1,39 @@
"use client";
import {
type JSX,
useState,
type HTMLAttributes,
useEffect,
useCallback,
} from "react";
interface TimeProps extends HTMLAttributes<HTMLParagraphElement> {
showAMPM?: boolean;
}
export const Time = ({
showAMPM = false,
...props
}: TimeProps): JSX.Element => {
const formatTime = useCallback(
(date: Date): string => {
const hours = date.getHours() % 12 || 12;
const minutes = String(date.getMinutes()).padStart(2, "0");
const ampm = showAMPM ? (date.getHours() >= 12 ? "PM" : "AM") : "";
return `${hours}:${minutes} ${ampm}`;
},
[showAMPM]
);
const [time, setTime] = useState<string>(formatTime(new Date()));
useEffect(() => {
const id = setInterval(() => {
setTime(formatTime(new Date()));
}, 5000);
return () => clearInterval(id);
}, [formatTime]);
return <p {...props}>{time}</p>;
};

98
src/instrumentation.ts Normal file
View File

@@ -0,0 +1,98 @@
import Ajv, { type JSONSchemaType } from "ajv";
import addFormats from "ajv-formats";
import { type Config } from "./types/config";
import { prettify } from "awesome-ajv-errors";
export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
const fs = await import("fs");
const phoneRegex = /^(\+\d{1,2}\s)?\(?\d{3}\)?[\s.-]\d{3}[\s.-]\d{4}$/;
const ajv = new Ajv({ allErrors: true });
addFormats(ajv);
ajv.addFormat("phone", {
validate: (data: string) => phoneRegex.test(data),
});
const configSchema: JSONSchemaType<Config> = {
type: "object",
additionalProperties: false,
required: ["name"],
properties: {
socials: {
type: "object",
additionalProperties: false,
nullable: true,
properties: {
matrix: { type: "string", nullable: true },
linkedin: { type: "string", nullable: true },
gitea: { type: "string", format: "uri", nullable: true },
github: { type: "string", nullable: true },
instagram: { type: "string", nullable: true },
custom: { type: "string", format: "uri", nullable: true },
},
},
projects: {
type: "array",
items: {
type: "object",
additionalProperties: false,
properties: {
name: { type: "string" },
description: { type: "string" },
date: { type: "string", format: "date" },
links: {
type: "array",
items: { type: "string", format: "uri" },
nullable: true,
},
images: {
type: "array",
items: { type: "string" },
nullable: true,
},
tags: {
type: "array",
items: { type: "string" },
nullable: true,
},
},
required: ["name", "description", "date"],
},
nullable: true,
},
phoneNumber: {
type: "string",
format: "phone",
nullable: true,
},
fallbackUrl: {
type: "string",
format: "uri",
nullable: true,
},
name: {
type: "array",
items: [{ type: "string" }, { type: "string" }],
minItems: 2,
maxItems: 2,
},
status: { type: "string", nullable: true },
location: { type: "string", nullable: true },
},
};
const config = JSON.parse(
fs.readFileSync("public/config/config.json", "utf-8")
);
const validateConfig = ajv.compile(configSchema);
if (!validateConfig(config)) {
console.error(prettify(validateConfig, { data: config }));
process.exit(1);
}
}
}

23
src/types/config.d.ts vendored Normal file
View File

@@ -0,0 +1,23 @@
export interface Config {
socials?: {
matrix?: string;
linkedin?: string;
gitea?: string;
github?: string;
instagram?: string;
custom?: string;
};
projects?: {
name: string;
description: string;
date: string;
links?: string[];
images?: string[];
tags?: string[];
}[];
phoneNumber?: string;
fallbackUrl?: string;
name: [string, string];
status?: string;
location?: string;
}

106
src/types/scene.d.ts vendored Normal file
View File

@@ -0,0 +1,106 @@
import { type Mesh, type MeshStandardMaterial } from "three";
import { type GLTF } from "three-stdlib";
export type GLTFResult = GLTF & {
nodes: {
LDeskMesh: Mesh;
Frame: Mesh;
OfficeChairChasis: Mesh;
Seat: Mesh;
Mouse: Mesh;
Monitor: Mesh;
CDSlot: Mesh;
PCMesh: Mesh;
CDSlotLining: Mesh;
Keyboard: Mesh;
MousepadMesh: Mesh;
DeskLampChasis: Mesh;
Arm: Mesh;
Light: Mesh;
SecondaryMonitorMesh: Mesh;
Printer3DMesh: Mesh;
Paper1: Mesh;
Paper2: Mesh;
Paper3: Mesh;
PenAccent: Mesh;
PenBody: Mesh;
PenTip: Mesh;
CellphoneMesh: Mesh;
Top: Mesh;
OfficePhoneBody: Mesh;
Screen: Mesh;
Buttons: Mesh;
Handle: Mesh;
HeadphonesBody: Mesh;
HeadphonesAccent: Mesh;
Cover: Mesh;
MugMesh: Mesh;
DeskMatMesh: Mesh;
SolderingIronBody: Mesh;
SolderingIronAccent: Mesh;
Sponge: Mesh;
IronGrip: Mesh;
Switch: Mesh;
Holster: Mesh;
SwitchLabel: Mesh;
ScrewdriverMesh: Mesh;
WalletMesh: Mesh;
PlantMesh: Mesh;
Controller1Mesh: Mesh;
Controller2Mesh: Mesh;
CalculatorMesh: Mesh;
MainRug: Mesh;
Border: Mesh;
WindowGlass: Mesh;
WindowFrame: Mesh;
Wall: Mesh;
Floor: Mesh;
};
materials: {
LDeskMaterial: MeshStandardMaterial;
FrameMaterial: MeshStandardMaterial;
OfficeChairChasisMaterial: MeshStandardMaterial;
SeatMaterial: MeshStandardMaterial;
PlasticMaterial: MeshStandardMaterial;
CDSlotLiningMaterial: MeshStandardMaterial;
MousepadMaterial: MeshStandardMaterial;
DeskLampChasisMaterial: MeshStandardMaterial;
ArmMaterial: MeshStandardMaterial;
LightMaterial: MeshStandardMaterial;
SecondaryMonitorMaterial: MeshStandardMaterial;
Printer3DMaterial: MeshStandardMaterial;
PaperMaterial: MeshStandardMaterial;
PenAccentMaterial: MeshStandardMaterial;
PenBodyMaterial: MeshStandardMaterial;
TipMaterial: MeshStandardMaterial;
CellphoneMaterial: MeshStandardMaterial;
TopMaterial: MeshStandardMaterial;
OfficePhoneBodyMaterial: MeshStandardMaterial;
ScreenMaterial: MeshStandardMaterial;
HandleAndButtonsMaterial: MeshStandardMaterial;
HeadphonesBodyMaterial: MeshStandardMaterial;
HeadphonesAccentMaterial: MeshStandardMaterial;
CoverMaterial: MeshStandardMaterial;
MugMaterial: MeshStandardMaterial;
DeskMatMaterial: MeshStandardMaterial;
SolderingIronBodyMaterial: MeshStandardMaterial;
SolderingIronAccentMaterial: MeshStandardMaterial;
SpongeMaterial: MeshStandardMaterial;
IronGripMaterial: MeshStandardMaterial;
SwitchMaterial: MeshStandardMaterial;
HolsterMaterial: MeshStandardMaterial;
SwitchLabelMaterial: MeshStandardMaterial;
ScrewdriverMaterial: MeshStandardMaterial;
WalletMaterial: MeshStandardMaterial;
PlantMaterial: MeshStandardMaterial;
Controller1Material: MeshStandardMaterial;
Controller2Material: MeshStandardMaterial;
CalculatorMaterial: MeshStandardMaterial;
RugDarkMaterial: MeshStandardMaterial;
RugLightMaterial: MeshStandardMaterial;
WindowGlassMaterial: MeshStandardMaterial;
WindowFrameMaterial: MeshStandardMaterial;
WallMaterial: MeshStandardMaterial;
FloorMaterial: MeshStandardMaterial;
};
};

45
src/util/stack.ts Normal file
View File

@@ -0,0 +1,45 @@
export default class Stack<T> {
private items: T[];
constructor() {
this.items = [];
}
public push(element: T): void {
this.items.push(element);
}
public pop(): T | undefined {
return this.items.pop();
}
public peek(): T | undefined {
return this.items[this.items.length - 1];
}
public isEmpty(): boolean {
return this.items.length === 0;
}
public size(): number {
return this.items.length;
}
public clear(): void {
this.items = [];
}
public print(): void {
console.log(this.items);
}
public toArray(): T[] {
return [...this.items];
}
public clone(): Stack<T> {
const newStack = new Stack<T>();
newStack.items = [...this.items];
return newStack;
}
}

1966
yarn.lock

File diff suppressed because it is too large Load Diff