Portfolio
2
.gitignore
vendored
@@ -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
@@ -0,0 +1 @@
|
||||
nodeLinker: node-modules
|
||||
17
package.json
@@ -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",
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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
BIN
public/static/cellphone-bg.webp
Normal file
|
After Width: | Height: | Size: 115 KiB |
BIN
public/static/inter-bold.ttf
Normal file
BIN
public/static/inter.ttf
Normal file
BIN
public/static/pc-bg.webp
Normal file
|
After Width: | Height: | Size: 56 KiB |
@@ -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 |
@@ -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 |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 3.7 KiB |
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
118
src/app/page.tsx
@@ -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;
|
||||
|
||||
15
src/components/hooks/useConfig.ts
Normal 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;
|
||||
};
|
||||
11
src/components/hooks/useDisableRaycast.ts
Normal 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;
|
||||
};
|
||||
21
src/components/hooks/useWindowSize.ts
Normal 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;
|
||||
}
|
||||
554
src/components/scene/Buttons.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
484
src/components/scene/Scene.tsx
Normal 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}
|
||||
>
|
||||
📍 {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");
|
||||
383
src/components/scene/StaticMeshes.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
73
src/components/scene/consts.ts
Normal 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>;
|
||||
194
src/components/ui/CellphoneUI.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
154
src/components/ui/Credits.tsx
Normal 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
@@ -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"> </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>
|
||||
);
|
||||
};
|
||||
30
src/components/util/Date.tsx
Normal 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>;
|
||||
};
|
||||
38
src/components/util/PDF.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
src/components/util/Time.tsx
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||