Little rework
Build Check / build (push) Failing after 18s
Build Check / deploy-coolify (push) Has been skipped
Build Check / push-image (push) Has been skipped

This commit is contained in:
Space-Banane
2026-07-16 23:03:42 +02:00
parent 2045e95929
commit dfbe14d284
62 changed files with 2760 additions and 2380 deletions
+4 -1
View File
@@ -41,4 +41,7 @@ yarn-error.log*
next-env.d.ts next-env.d.ts
old/ old/
.next .next
# prisma generated client
/src/generated
Binary file not shown.
+36 -6
View File
@@ -4,18 +4,46 @@ Personal portfolio built with Next.js, React, TypeScript, and Tailwind CSS.
## What It Includes ## What It Includes
- Hero section with typing intro - Hero dev-card, live-count stat, and work-experience timeline
- Work experience and skills sections - Projects (served from our DB) with a `/projects` page and homepage teaser
- Uptime and activity panels - Luna section, DB-driven affiliates, and the SHSF Code Activity graph
- Project and mini-project showcases - Admin CMS at `/admin` for projects, work experience, and affiliates
- Contact page and admin page
## Data layer
Content (projects, work experience, affiliates) lives in **Postgres** and is
managed through Prisma + our own Next.js API routes:
- Public reads: `GET /api/projects`, `/api/experience`, `/api/affiliates`
- Admin writes: `POST/PUT/DELETE /api/admin/{projects,experience,affiliates}/…`
(cookie session issued by `POST /api/admin/login`)
- `/api/profile-image` serves the avatar (cached in memory)
- Only the **Code Activity** widget still uses the external SHSF API.
### Environment
Copy `.env.example` to `.env` and set:
| Var | Purpose |
| --- | --- |
| `DATABASE_URL` | Postgres connection string |
| `ADMIN_PASSWORD` | Password for the `/admin` login |
| `SESSION_SECRET` | Long random string used to sign the admin session cookie |
| `PROFILE_IMAGE_URL` | (optional) override for the avatar source |
## Development ## Development
Install dependencies: Install dependencies and generate the Prisma client:
```bash ```bash
pnpm install pnpm install
pnpm prisma generate
```
Apply the schema to your database:
```bash
pnpm prisma migrate dev
``` ```
Run the development server: Run the development server:
@@ -61,4 +89,6 @@ The app will be available on port `6756` via `docker-compose.yml`.
## Notes ## Notes
- `next.config.ts` uses `output: "standalone"` so the Docker image can ship a minimal runtime. - `next.config.ts` uses `output: "standalone"` so the Docker image can ship a minimal runtime.
- `pnpm build` runs `prisma generate` before `next build`.
- The activity graph on the home page is loaded from a remote SVG source. - The activity graph on the home page is loaded from a remote SVG source.
- The container needs the same env vars (`DATABASE_URL`, `ADMIN_PASSWORD`, `SESSION_SECRET`) at runtime.
+5 -1
View File
@@ -5,13 +5,16 @@
"packageManager": "pnpm@10.30.3", "packageManager": "pnpm@10.30.3",
"scripts": { "scripts": {
"dev": "next dev -p 5173", "dev": "next dev -p 5173",
"build": "next build", "build": "prisma generate && next build",
"start": "next start", "start": "next start",
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "dependencies": {
"@danielgtmn/umami-react": "^1.1.6", "@danielgtmn/umami-react": "^1.1.6",
"@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dotenv": "^17.4.2",
"framer-motion": "^12.36.0", "framer-motion": "^12.36.0",
"lucide-react": "^0.577.0", "lucide-react": "^0.577.0",
"next": "16.1.6", "next": "16.1.6",
@@ -27,6 +30,7 @@
"@types/react-dom": "^19", "@types/react-dom": "^19",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.1.6", "eslint-config-next": "16.1.6",
"prisma": "^7.8.0",
"tailwindcss": "^4", "tailwindcss": "^4",
"typescript": "^5" "typescript": "^5"
} }
+859
View File
File diff suppressed because it is too large Load Diff
+12
View File
@@ -0,0 +1,12 @@
import "dotenv/config";
import { defineConfig, env } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
datasource: {
url: env("DATABASE_URL"),
},
});
@@ -0,0 +1,61 @@
-- CreateEnum
CREATE TYPE "ProjectSize" AS ENUM ('Big', 'MediumSized', 'Small');
-- CreateTable
CREATE TABLE "Project" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"label" TEXT NOT NULL,
"size" "ProjectSize" NOT NULL DEFAULT 'MediumSized',
"imageUrl" TEXT,
"link" TEXT NOT NULL,
"linkIsDemo" BOOLEAN NOT NULL DEFAULT false,
"sourceCode" TEXT,
"description" TEXT NOT NULL,
"why" TEXT NOT NULL,
"note" TEXT,
"tags" TEXT[],
"loc" INTEGER,
"locEndpoint" TEXT,
"featured" BOOLEAN NOT NULL DEFAULT false,
"sortIndex" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Project_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "WorkExperience" (
"id" TEXT NOT NULL,
"company" TEXT NOT NULL,
"role" TEXT NOT NULL,
"fromDate" TIMESTAMP(3) NOT NULL,
"toDate" TIMESTAMP(3),
"url" TEXT,
"iconUrl" TEXT,
"summary" TEXT NOT NULL,
"tags" TEXT[],
"sortIndex" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "WorkExperience_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Affiliate" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"link" TEXT NOT NULL,
"icon" TEXT NOT NULL,
"location" TEXT NOT NULL,
"provides" TEXT[],
"good" TEXT[],
"bad" TEXT[],
"sortIndex" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Affiliate_pkey" PRIMARY KEY ("id")
);
@@ -0,0 +1,10 @@
/*
Warnings:
- You are about to drop the column `featured` on the `Project` table. All the data in the column will be lost.
- You are about to drop the column `sortIndex` on the `Project` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "Project" DROP COLUMN "featured",
DROP COLUMN "sortIndex";
+3
View File
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
+66
View File
@@ -0,0 +1,66 @@
// Prisma 7 (rust-free `prisma-client` generator + driver adapter).
// Client is generated into src/generated/prisma (gitignored) and imported
// from "../generated/prisma/client".
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
}
datasource db {
provider = "postgresql"
}
enum ProjectSize {
Big
MediumSized
Small
}
model Project {
id String @id @default(cuid())
name String
label String // free-type string, e.g. "Dev Tool", "SaaS"
size ProjectSize @default(MediumSized)
imageUrl String? // shown fully, nothing overlapping
link String
linkIsDemo Boolean @default(false)
sourceCode String? // github or gitea link
description String
why String
note String? // free note, replaces old {color, content}
tags String[] // max 3 enforced in the API
loc Int? // manually provided line-of-code count
locEndpoint String? // URL the backend hits for a plaintext LoC number
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model WorkExperience {
id String @id @default(cuid())
company String
role String
fromDate DateTime
toDate DateTime? // null = Present
url String?
iconUrl String?
summary String
tags String[] // max 6 enforced in the API
sortIndex Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Affiliate {
id String @id @default(cuid())
name String
link String
icon String
location String
provides String[]
good String[]
bad String[]
sortIndex Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
+462 -585
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,27 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { parseAffiliate } from "@/lib/dto";
import { guard, run, readBody } from "@/lib/adminRoute";
type Params = { params: Promise<{ id: string }> };
export async function PUT(req: Request, { params }: Params) {
const denied = await guard();
if (denied) return denied;
return run(async () => {
const { id } = await params;
const data = parseAffiliate(await readBody(req));
const updated = await prisma.affiliate.update({ where: { id }, data });
return NextResponse.json(updated);
});
}
export async function DELETE(_req: Request, { params }: Params) {
const denied = await guard();
if (denied) return denied;
return run(async () => {
const { id } = await params;
await prisma.affiliate.delete({ where: { id } });
return NextResponse.json({ ok: true });
});
}
+25
View File
@@ -0,0 +1,25 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { parseAffiliate } from "@/lib/dto";
import { guard, run, readBody } from "@/lib/adminRoute";
export async function GET() {
const denied = await guard();
if (denied) return denied;
return run(async () => {
const items = await prisma.affiliate.findMany({
orderBy: [{ sortIndex: "asc" }, { createdAt: "asc" }],
});
return NextResponse.json(items);
});
}
export async function POST(req: Request) {
const denied = await guard();
if (denied) return denied;
return run(async () => {
const data = parseAffiliate(await readBody(req));
const created = await prisma.affiliate.create({ data });
return NextResponse.json(created, { status: 201 });
});
}
@@ -0,0 +1,27 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { parseExperience } from "@/lib/dto";
import { guard, run, readBody } from "@/lib/adminRoute";
type Params = { params: Promise<{ id: string }> };
export async function PUT(req: Request, { params }: Params) {
const denied = await guard();
if (denied) return denied;
return run(async () => {
const { id } = await params;
const data = parseExperience(await readBody(req));
const updated = await prisma.workExperience.update({ where: { id }, data });
return NextResponse.json(updated);
});
}
export async function DELETE(_req: Request, { params }: Params) {
const denied = await guard();
if (denied) return denied;
return run(async () => {
const { id } = await params;
await prisma.workExperience.delete({ where: { id } });
return NextResponse.json({ ok: true });
});
}
+25
View File
@@ -0,0 +1,25 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { parseExperience } from "@/lib/dto";
import { guard, run, readBody } from "@/lib/adminRoute";
export async function GET() {
const denied = await guard();
if (denied) return denied;
return run(async () => {
const items = await prisma.workExperience.findMany({
orderBy: [{ sortIndex: "asc" }, { fromDate: "desc" }],
});
return NextResponse.json(items);
});
}
export async function POST(req: Request) {
const denied = await guard();
if (denied) return denied;
return run(async () => {
const data = parseExperience(await readBody(req));
const created = await prisma.workExperience.create({ data });
return NextResponse.json(created, { status: 201 });
});
}
+26
View File
@@ -0,0 +1,26 @@
import { NextResponse } from "next/server";
import { createSessionToken, verifyPassword, SESSION_COOKIE } from "@/lib/auth";
export async function POST(req: Request) {
let password = "";
try {
const body = await req.json();
password = typeof body?.password === "string" ? body.password : "";
} catch {
// ignore malformed body -> treated as invalid
}
if (!verifyPassword(password)) {
return NextResponse.json({ error: "Invalid password" }, { status: 401 });
}
const res = NextResponse.json({ ok: true });
res.cookies.set(SESSION_COOKIE, createSessionToken(), {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
maxAge: 60 * 60 * 24 * 7,
});
return res;
}
+8
View File
@@ -0,0 +1,8 @@
import { NextResponse } from "next/server";
import { SESSION_COOKIE } from "@/lib/auth";
export async function POST() {
const res = NextResponse.json({ ok: true });
res.cookies.set(SESSION_COOKIE, "", { path: "/", maxAge: 0 });
return res;
}
+27
View File
@@ -0,0 +1,27 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { parseProject } from "@/lib/dto";
import { guard, run, readBody } from "@/lib/adminRoute";
type Params = { params: Promise<{ id: string }> };
export async function PUT(req: Request, { params }: Params) {
const denied = await guard();
if (denied) return denied;
return run(async () => {
const { id } = await params;
const data = parseProject(await readBody(req));
const updated = await prisma.project.update({ where: { id }, data });
return NextResponse.json(updated);
});
}
export async function DELETE(_req: Request, { params }: Params) {
const denied = await guard();
if (denied) return denied;
return run(async () => {
const { id } = await params;
await prisma.project.delete({ where: { id } });
return NextResponse.json({ ok: true });
});
}
+25
View File
@@ -0,0 +1,25 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { parseProject } from "@/lib/dto";
import { guard, run, readBody } from "@/lib/adminRoute";
export async function GET() {
const denied = await guard();
if (denied) return denied;
return run(async () => {
const projects = await prisma.project.findMany({
orderBy: [{ size: "asc" }, { name: "asc" }],
});
return NextResponse.json(projects);
});
}
export async function POST(req: Request) {
const denied = await guard();
if (denied) return denied;
return run(async () => {
const data = parseProject(await readBody(req));
const created = await prisma.project.create({ data });
return NextResponse.json(created, { status: 201 });
});
}
+6
View File
@@ -0,0 +1,6 @@
import { NextResponse } from "next/server";
import { isAdmin } from "@/lib/auth";
export async function GET() {
return NextResponse.json({ authenticated: await isAdmin() });
}
+10
View File
@@ -0,0 +1,10 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
// Public: list affiliates.
export async function GET() {
const affiliates = await prisma.affiliate.findMany({
orderBy: [{ sortIndex: "asc" }, { createdAt: "asc" }],
});
return NextResponse.json(affiliates);
}
+10
View File
@@ -0,0 +1,10 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
// Public: list work experience, most recent first.
export async function GET() {
const experience = await prisma.workExperience.findMany({
orderBy: [{ sortIndex: "asc" }, { fromDate: "desc" }],
});
return NextResponse.json(experience);
}
+43
View File
@@ -0,0 +1,43 @@
import { NextResponse } from "next/server";
// Serves the profile picture through our own origin, caching the bytes in
// process memory so we don't hit the upstream CDN on every request.
const DEFAULT_IMAGE = "https://cdn.reversed.dev/pictures/20250405_120402.png";
const CACHE_TTL_MS = 1000 * 60 * 60; // 1 hour
type ImageCache = { body: ArrayBuffer; contentType: string; expiresAt: number };
let cached: ImageCache | null = null;
async function loadImage(): Promise<ImageCache | null> {
if (cached && cached.expiresAt > Date.now()) return cached;
const source = process.env.PROFILE_IMAGE_URL || DEFAULT_IMAGE;
try {
const res = await fetch(source, { signal: AbortSignal.timeout(8000) });
if (!res.ok) return cached; // fall back to stale copy if present
const body = await res.arrayBuffer();
cached = {
body,
contentType: res.headers.get("content-type") || "image/png",
expiresAt: Date.now() + CACHE_TTL_MS,
};
return cached;
} catch {
return cached;
}
}
export async function GET() {
const image = await loadImage();
if (!image) {
return NextResponse.json({ error: "Image unavailable" }, { status: 502 });
}
return new NextResponse(image.body, {
headers: {
"Content-Type": image.contentType,
"Cache-Control": "public, max-age=3600, stale-while-revalidate=86400",
},
});
}
+36
View File
@@ -0,0 +1,36 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { resolveLoc } from "@/lib/loc";
// Public: list all projects (served from our DB), each with a resolved
// line-of-code count (manual `loc` or fetched from `locEndpoint`, cached).
export async function GET() {
// Ordered by size (Big -> MediumSized -> Small via the enum's declared order),
// then alphabetically by name.
const projects = await prisma.project.findMany({
orderBy: [{ size: "asc" }, { name: "asc" }],
});
const withLoc = await Promise.all(
projects.map(async (p) => ({
id: p.id,
name: p.name,
label: p.label,
size: p.size,
imageUrl: p.imageUrl,
link: p.link,
linkIsDemo: p.linkIsDemo,
sourceCode: p.sourceCode,
description: p.description,
why: p.why,
note: p.note,
tags: p.tags,
loc: p.loc,
resolvedLoc: await resolveLoc(p.loc, p.locEndpoint),
createdAt: p.createdAt,
updatedAt: p.updatedAt,
})),
);
return NextResponse.json(withLoc);
}
+14 -15
View File
@@ -3,6 +3,7 @@
import { Contact as ContactSection } from "../../sections/Contact"; import { Contact as ContactSection } from "../../sections/Contact";
import { Navbar } from "../../components/Navbar"; import { Navbar } from "../../components/Navbar";
import { Footer } from "../../sections/Footer"; import { Footer } from "../../sections/Footer";
import { CONTACT_EMAIL } from "../../types";
export default function ContactPage() { export default function ContactPage() {
return ( return (
@@ -10,11 +11,11 @@ export default function ContactPage() {
<Navbar /> <Navbar />
<div className="mx-auto flex min-h-screen w-full max-w-5xl flex-col items-center justify-start space-y-10 px-4 pb-16 pt-20 text-white sm:justify-center sm:space-y-12 sm:pb-20 sm:pt-24"> <div className="mx-auto flex min-h-screen w-full max-w-5xl flex-col items-center justify-start space-y-10 px-4 pb-16 pt-20 text-white sm:justify-center sm:space-y-12 sm:pb-20 sm:pt-24">
<div className="text-center space-y-4 w-full"> <div className="text-center space-y-4 w-full">
<h1 className="text-4xl font-extrabold text-white sm:text-5xl md:text-6xl"> <h1 className="text-4xl font-semibold text-white sm:text-5xl md:text-6xl">
Let's <span className="text-transparent bg-clip-text bg-gradient-to-r from-blue-400 to-purple-500">Connect</span> Let&apos;s Connect
</h1> </h1>
<p className="mx-auto max-w-xl text-base text-gray-400 sm:text-lg md:text-xl"> <p className="mx-auto max-w-xl text-base text-gray-500 sm:text-lg md:text-xl">
Whatever you've got in mind, I'm just a few clicks away. Whatever you&apos;ve got in mind, I&apos;m just a few clicks away.
</p> </p>
</div> </div>
@@ -22,24 +23,22 @@ export default function ContactPage() {
<ContactSection /> <ContactSection />
</div> </div>
<div className="grid w-full max-w-4xl grid-cols-1 gap-4 text-center sm:grid-cols-2 md:grid-cols-3 sm:gap-6"> <div className="grid w-full max-w-2xl grid-cols-1 gap-4 text-center sm:grid-cols-2 sm:gap-6">
<div className="group w-full rounded-3xl border border-white/10 bg-white/5 p-5 backdrop-blur-sm transition-all duration-300 hover:border-blue-500/30 sm:p-6"> <a
href={`mailto:${CONTACT_EMAIL}`}
className="group w-full rounded-3xl border border-white/10 bg-white/[0.03] p-5 transition-all duration-300 hover:border-white/25 sm:p-6"
>
<div className="text-3xl mb-4 group-hover:scale-110 transition-transform">📧</div> <div className="text-3xl mb-4 group-hover:scale-110 transition-transform">📧</div>
<h3 className="font-bold mb-1">Email</h3> <h3 className="font-bold mb-1">Email</h3>
<p className="text-sm text-gray-500 underline underline-offset-4 decoration-white/20 hover:decoration-blue-500 transition-colors"> <p className="text-sm text-gray-500 underline underline-offset-4 decoration-white/20 group-hover:decoration-white/60 transition-colors">
space@reversed.dev Send me an email
</p> </p>
</div> </a>
<div className="group w-full rounded-3xl border border-white/10 bg-white/5 p-5 backdrop-blur-sm transition-all duration-300 hover:border-purple-500/30 sm:p-6"> <div className="group w-full rounded-3xl border border-white/10 bg-white/[0.03] p-5 transition-all duration-300 hover:border-white/25 sm:p-6">
<div className="text-3xl mb-4 group-hover:scale-110 transition-transform">👾</div> <div className="text-3xl mb-4 group-hover:scale-110 transition-transform">👾</div>
<h3 className="font-bold mb-1">Discord</h3> <h3 className="font-bold mb-1">Discord</h3>
<p className="text-sm text-gray-500">@getspaced</p> <p className="text-sm text-gray-500">@getspaced</p>
</div> </div>
<div className="group w-full rounded-3xl border border-white/10 bg-white/5 p-5 backdrop-blur-sm transition-all duration-300 hover:border-pink-500/30 sm:p-6">
<div className="text-3xl mb-4 group-hover:scale-110 transition-transform">💻</div>
<h3 className="font-bold mb-1">GitHub</h3>
<p className="text-sm text-gray-500">github.com/Space-Banane</p>
</div>
</div> </div>
</div> </div>
<Footer /> <Footer />
Binary file not shown.
Binary file not shown.
Binary file not shown.
+14 -1
View File
@@ -14,17 +14,30 @@
.animate-scale-in { animation: scale-in 0.3s ease-out; } .animate-scale-in { animation: scale-in 0.3s ease-out; }
:root { :root {
--background: #020205; --background: #0a0a0a;
--foreground: #ffffff; --foreground: #ffffff;
} }
@theme inline { @theme inline {
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--font-sans: var(--font-satoshi), ui-sans-serif, system-ui, sans-serif;
--font-display: var(--font-clash), var(--font-satoshi), ui-sans-serif, sans-serif;
} }
body { body {
background: var(--background); background: var(--background);
color: var(--foreground); color: var(--foreground);
/* Reference the next/font variables directly — `@theme inline` above does not
emit real --font-sans/--font-display custom properties. */
font-family: var(--font-satoshi), ui-sans-serif, system-ui, sans-serif;
}
/* Headings use Clash Display by default for a sharper, editorial feel. */
h1,
h2,
h3 {
font-family: var(--font-clash), var(--font-satoshi), ui-sans-serif, sans-serif;
letter-spacing: 0.01em;
} }
+22 -8
View File
@@ -1,10 +1,28 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import localFont from "next/font/local";
import "@/app/globals.css"; import "@/app/globals.css";
import { ClientProviders } from "../components/ClientProviders"; import { ClientProviders } from "../components/ClientProviders";
// Satoshi = body text, Clash Display = headings (both variable fonts).
const satoshi = localFont({
variable: "--font-satoshi",
display: "swap",
src: [
{ path: "./fonts/Satoshi-Variable.ttf", weight: "300 900", style: "normal" },
{ path: "./fonts/Satoshi-VariableItalic.ttf", weight: "300 900", style: "italic" },
],
});
const clashDisplay = localFont({
variable: "--font-clash",
display: "swap",
src: [{ path: "./fonts/ClashDisplay-Variable.ttf", weight: "200 700", style: "normal" }],
});
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Space's portfolio", title: "Paul W. — Portfolio",
description: "A developer from Germany building things.", description:
"Full-stack developer & open-source author. Building server infrastructure, developer tools, and web applications.",
icons: { icons: {
icon: "/favicon.png", icon: "/favicon.png",
}, },
@@ -16,14 +34,10 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<html lang="en"> <html lang="en" className={`${satoshi.variable} ${clashDisplay.variable}`}>
<body className="antialiased"> <body className="antialiased">
<ClientProviders> <ClientProviders>
<div className="relative min-h-screen bg-[#020205] text-white selection:bg-blue-500/30 overflow-x-hidden"> <div className="relative min-h-screen bg-[#0a0a0a] text-white selection:bg-white/20 overflow-x-hidden">
<div className="fixed inset-0 z-0 pointer-events-none">
<div className="absolute top-[-10%] left-[-10%] w-[40%] h-[40%] bg-blue-500/10 blur-[120px] rounded-full animate-pulse" />
<div className="absolute bottom-[-10%] right-[-10%] w-[40%] h-[40%] bg-purple-500/10 blur-[120px] rounded-full animate-pulse" />
</div>
{children} {children}
</div> </div>
</ClientProviders> </ClientProviders>
+19 -121
View File
@@ -1,134 +1,32 @@
"use client"; "use client";
import { useProfile } from "../context/ProfileContext"; import { useProfile } from "../context/ProfileContext";
import { Hero } from "../sections/Hero";
import { WorkExperience } from "../sections/WorkExperience";
import { Uptime } from "../sections/Uptime";
import { Activity } from "../sections/Activity";
import { Affiliates } from "../sections/Affiliates";
import { AgentSkills } from "../sections/AgentSkills";
import { ProjectCard } from "../components/ProjectCard";
import { MiniProjectCard } from "../components/MiniProjectCard";
import { ExperienceModal } from "../components/ExperienceModal";
import { MiniProjectModal } from "../components/MiniProjectModal";
import { Navbar } from "../components/Navbar"; import { Navbar } from "../components/Navbar";
import { Hero } from "../sections/Hero";
import { Stats } from "../sections/Stats";
import { WorkExperience } from "../sections/WorkExperience";
import { FeaturedProjects } from "../sections/FeaturedProjects";
import { Luna } from "../sections/Luna";
import { Affiliates } from "../sections/Affiliates";
import { Activity } from "../sections/Activity";
import { Footer } from "../sections/Footer"; import { Footer } from "../sections/Footer";
import { TechStack } from "../sections/TechStack";
import { TypingRoomIntro } from "../components/TypingRoomIntro";
import { useCallback, useEffect, useState } from "react";
import { SkillsExperience } from "@/sections/SkillsExperience";
const ENABLE_PAGE_ANIMATION = false;
export default function Home() { export default function Home() {
const { const { experiences } = useProfile();
glowColor, borderStatus, displayMessage, statusMessage,
rotatingMessages,
projects, miniProjects, setSelectedMiniProject,
experiences, setSelectedExperience, realWork,
selectedMiniProject, selectedExperience
} = useProfile();
const [showTypingIntro, setShowTypingIntro] = useState(ENABLE_PAGE_ANIMATION);
const oldUsernames = [
"getspaced (ingame)",
"Space (alternative)",
"Space-Banane (2022-2024)",
];
useEffect(() => {
// keep intro visible on every full page load; no-op here
}, []);
const handleIntroFinish = useCallback(() => {
setShowTypingIntro(false);
}, []);
return ( return (
<> <>
{ENABLE_PAGE_ANIMATION && ( <Navbar />
<TypingRoomIntro active={showTypingIntro} onFinish={handleIntroFinish} /> <div className="space-y-20 pb-16 pt-28 sm:space-y-24 sm:pb-20 sm:pt-32">
)} <Hero />
{!showTypingIntro && ( <Stats />
<> <WorkExperience experiences={experiences} />
<Navbar /> <FeaturedProjects />
<div className="space-y-20 pb-16 pt-16 sm:space-y-24 sm:pb-20 sm:pt-20"> <Luna />
<Hero <Affiliates />
glowColor={glowColor} <Activity />
borderStatus={borderStatus} </div>
displayMessage={displayMessage} <Footer />
rotatingMessages={rotatingMessages}
statusMessage={statusMessage}
oldUsernames={oldUsernames}
/>
<WorkExperience realWork={realWork} />
<Uptime />
<Activity />
<AgentSkills />
<section className="w-full max-w-6xl mx-auto space-y-8 px-4 sm:space-y-12">
<div className="text-center space-y-4">
<h2 className="text-3xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-blue-400 to-purple-500 sm:text-4xl">
Featured Projects
</h2>
<p className="mx-auto max-w-2xl text-sm text-gray-400 sm:text-base">
A selection of my personal favorites. Many more on my GitHub.
</p>
</div>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3 sm:gap-8">
{projects.map((project, index) => (
<ProjectCard key={index} project={project} />
))}
</div>
</section>
<section className="w-full max-w-6xl mx-auto space-y-8 px-4 sm:space-y-12">
<div className="text-center space-y-4">
<h2 className="text-2xl font-bold sm:text-3xl">More Projects</h2>
<p className="text-sm text-gray-400 sm:text-base">Smaller projects or tools I've built.</p>
</div>
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3 sm:gap-6">
{miniProjects.map((project, index) => (
<MiniProjectCard
key={index}
project={project}
onClick={() => setSelectedMiniProject(project)}
/>
))}
</div>
</section>
<Affiliates />
<TechStack />
<SkillsExperience
experiences={experiences}
onSelectExperience={setSelectedExperience}
/>
</div>
<Footer />
{selectedMiniProject && (
<MiniProjectModal
project={selectedMiniProject}
onClose={() => setSelectedMiniProject(null)}
/>
)}
{selectedExperience && (
<ExperienceModal
experience={selectedExperience}
onClose={() => setSelectedExperience(null)}
/>
)}
</>
)}
</> </>
); );
} }
+39
View File
@@ -0,0 +1,39 @@
"use client";
import { useProfile } from "../../context/ProfileContext";
import { Navbar } from "../../components/Navbar";
import { Footer } from "../../sections/Footer";
import { ProjectCard } from "../../components/ProjectCard";
export default function ProjectsPage() {
const { projects, loading } = useProfile();
return (
<>
<Navbar />
<div className="mx-auto w-full max-w-6xl px-4 pb-16 pt-28 sm:pb-20 sm:pt-32">
<div className="mb-10 space-y-4 text-center sm:mb-14">
<h1 className="text-4xl font-semibold text-white sm:text-5xl">Projects</h1>
<p className="mx-auto max-w-2xl text-base text-gray-500 sm:text-lg">
Everything I&apos;ve built and shipped big and small.
</p>
</div>
{loading ? (
<p className="text-center text-gray-500">Loading projects</p>
) : projects.length === 0 ? (
<div className="rounded-2xl border border-white/10 bg-white/5 px-5 py-12 text-center text-gray-400">
No projects published yet.
</div>
) : (
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3 sm:gap-8">
{projects.map((project) => (
<ProjectCard key={project.id} project={project} />
))}
</div>
)}
</div>
<Footer />
</>
);
}
-79
View File
@@ -1,79 +0,0 @@
"use client";
import type { Experience } from "../types";
import { getTypeColor, getTypeIcon } from "./utils";
export function ExperienceCard({
experience,
onClick,
}: {
experience: Experience;
onClick?: () => void;
}) {
const isClickable = !!(
experience.learned_at ||
experience.learned_from ||
experience.learned_because
);
return (
<div
onClick={isClickable ? onClick : undefined}
className={`group flex w-full items-center gap-3 rounded-lg border border-white/10 bg-gradient-to-br from-white/5 to-white/2 p-3 backdrop-blur-sm transition-all duration-200 ${
isClickable
? "cursor-pointer hover:border-purple-500/50 hover:bg-white/10 hover:-translate-y-0.5"
: ""
}`}
>
<div
className={`text-2xl p-2 rounded-lg ${getTypeColor(experience.type)} flex items-center justify-center shrink-0 relative ${
experience.image ? "w-10 h-10" : ""
}`}
>
{experience.image ? (
<img
src={experience.image}
alt={experience.name}
className="w-full h-full object-contain"
/>
) : (
getTypeIcon(experience.type)
)}
{isClickable && (
<div className="absolute -top-1 -right-1 w-3 h-3 bg-purple-500 rounded-full border-2 border-black animate-pulse" />
)}
</div>
<div className="flex-1 min-w-0">
<h4
className={`text-base font-semibold text-white transition-colors ${
isClickable ? "group-hover:text-purple-400" : ""
}`}
>
{experience.name}
</h4>
{experience.description && (
<p className="text-xs text-gray-400 mt-0.5 line-clamp-1">
{experience.description}
</p>
)}
</div>
{isClickable && (
<div className="text-purple-400 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M9 5l7 7-7 7"
/>
</svg>
</div>
)}
</div>
);
}
-119
View File
@@ -1,119 +0,0 @@
"use client";
import type { Experience } from "../types";
import { getTypeColor, getTypeIcon } from "./utils";
export function ExperienceModal({
experience,
onClose,
}: {
experience: Experience;
onClose: () => void;
}) {
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-3 bg-black/80 backdrop-blur-sm animate-fade-in sm:p-4"
onClick={onClose}
>
<div
className="relative max-h-[92vh] w-full max-w-[20rem] overflow-y-auto rounded-2xl border border-purple-500/30 bg-gradient-to-br from-gray-900 to-black shadow-2xl animate-scale-in sm:max-w-2xl"
onClick={(e) => e.stopPropagation()}
>
{/* Close Button */}
<button
onClick={onClose}
className="absolute right-3 top-3 z-10 rounded-full bg-white/10 p-2 text-white transition-colors hover:bg-white/20 sm:right-4 sm:top-4"
>
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
{/* Content */}
<div className="space-y-4 p-4 sm:space-y-6 sm:p-8">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:gap-4">
<div
className={`flex shrink-0 items-center justify-center rounded-lg p-3 text-2xl ${getTypeColor(experience.type)} ${experience.image ? "h-12 w-12 sm:h-16 sm:w-16" : ""}`}
>
{experience.image ? (
<img
src={experience.image}
alt={experience.name}
className="w-full h-full object-contain"
/>
) : (
getTypeIcon(experience.type)
)}
</div>
<div>
<h2 className="text-xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-purple-400 to-pink-500 sm:text-3xl">
{experience.name}
</h2>
<p className="text-sm text-gray-400 mt-1">
{getTypeIcon(experience.type)}{" "}
{experience.type.charAt(0).toUpperCase() +
experience.type.slice(1)}
</p>
</div>
</div>
<div className="space-y-4">
{experience.description && (
<div>
<h3 className="text-lg font-semibold text-purple-400 mb-2">
Description
</h3>
<p className="text-gray-300 leading-relaxed">
{experience.description}
</p>
</div>
)}
{experience.learned_at && (
<div>
<h3 className="text-lg font-semibold text-purple-400 mb-2">
📅 When I Learned It
</h3>
<p className="text-gray-300 leading-relaxed">
{experience.learned_at}
</p>
</div>
)}
{experience.learned_from && (
<div>
<h3 className="text-lg font-semibold text-purple-400 mb-2">
👨🏫 How I Learned It
</h3>
<p className="text-gray-300 leading-relaxed">
{experience.learned_from}
</p>
</div>
)}
{experience.learned_because && (
<div>
<h3 className="text-lg font-semibold text-purple-400 mb-2">
💡 Why I Learned It
</h3>
<p className="text-gray-300 leading-relaxed">
{experience.learned_because}
</p>
</div>
)}
</div>
</div>
</div>
</div>
);
}
-57
View File
@@ -1,57 +0,0 @@
"use client";
import type { MiniProject } from "../types";
export function MiniProjectCard({
project,
onClick,
}: {
project: MiniProject;
onClick: () => void;
}) {
return (
<div
onClick={onClick}
className="group relative flex w-full cursor-pointer flex-col rounded-2xl border border-white/10 bg-gradient-to-br from-white/5 to-white/2 p-4 backdrop-blur-sm transition-all duration-300 hover:-translate-y-2 hover:border-purple-500/50 hover:shadow-2xl hover:shadow-purple-500/20 sm:p-6"
>
{project.image && (
<div className="mb-4 flex aspect-video items-center justify-center overflow-hidden rounded-xl bg-black/20">
<img
src={project.image}
alt={project.title}
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-110"
/>
</div>
)}
<h3 className="mb-2 text-lg font-bold text-white transition-colors group-hover:text-purple-400 sm:text-xl">
{project.title}
</h3>
<p className="mb-4 line-clamp-2 text-sm text-gray-400">
{project.description}
</p>
{project.last_commit && (
<p className="mb-2 text-xs text-gray-400">
Last commit: {new Date(project.last_commit).toLocaleDateString()}
</p>
)}
<div className="mt-auto flex items-center text-sm font-medium text-purple-400">
Click to learn more
<svg
className="w-4 h-4 ml-1 group-hover:translate-x-1 transition-transform"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M9 5l7 7-7 7"
/>
</svg>
</div>
</div>
);
}
-125
View File
@@ -1,125 +0,0 @@
"use client";
import type { MiniProject } from "../types";
export function MiniProjectModal({
project,
onClose,
}: {
project: MiniProject;
onClose: () => void;
}) {
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-3 bg-black/80 backdrop-blur-sm animate-fade-in sm:p-4"
onClick={onClose}
>
<div
className="relative max-h-[92vh] w-full max-w-[20rem] overflow-y-auto rounded-2xl border border-purple-500/30 bg-gradient-to-br from-gray-900 to-black shadow-2xl animate-scale-in sm:max-w-3xl"
onClick={(e) => e.stopPropagation()}
>
{/* Close Button */}
<button
onClick={onClose}
className="absolute right-3 top-3 z-10 rounded-full bg-white/10 p-2 text-white transition-colors hover:bg-white/20 sm:right-4 sm:top-4"
>
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
{/* Image */}
{project.image && (
<div className="h-36 w-full overflow-hidden rounded-t-2xl bg-black/40 sm:h-64">
<img
src={project.image}
alt={project.title}
className="w-full h-full object-cover"
/>
</div>
)}
{/* Content */}
<div className="space-y-4 p-4 sm:space-y-6 sm:p-8">
<h2 className="text-xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-purple-400 to-pink-500 sm:text-3xl">
{project.title}
</h2>
{project.note && (
<div
className="p-4 rounded-lg border-l-4 bg-black/20 backdrop-blur-sm"
style={{
borderColor: project.note.color,
backgroundColor: `${project.note.color}15`,
}}
>
<p
className="text-sm font-medium"
style={{ color: project.note.color }}
>
{project.note.content}
</p>
</div>
)}
<div className="space-y-4">
<div>
<h3 className="text-lg font-semibold text-purple-400 mb-2">
Description
</h3>
<p className="text-gray-300 leading-relaxed">
{project.description}
</p>
</div>
<div>
<h3 className="text-lg font-semibold text-purple-400 mb-2">
Why I Made This
</h3>
<p className="text-gray-300 leading-relaxed">{project.why}</p>
</div>
{project.reproduction && (
<div>
<h3 className="text-lg font-semibold text-purple-400 mb-2">
How to Reproduce
</h3>
<pre className="text-sm text-gray-300 bg-black/40 p-4 rounded-lg overflow-x-auto border border-white/10">
<code>{project.reproduction}</code>
</pre>
</div>
)}
{project.github && (
<a
href={project.github}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-2 px-6 py-3 rounded-lg bg-gradient-to-r from-purple-500 to-pink-500 text-white font-semibold hover:from-purple-600 hover:to-pink-600 transition-all shadow-lg shadow-purple-500/30 hover:shadow-purple-500/50"
>
<svg
className="w-5 h-5"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
View on GitHub
</a>
)}
</div>
</div>
</div>
</div>
);
}
+2 -1
View File
@@ -10,6 +10,7 @@ export function Navbar() {
const navItems = [ const navItems = [
{ label: "Home", path: "/" }, { label: "Home", path: "/" },
{ label: "Projects", path: "/projects" },
{ label: "Connect", path: "/contact" }, { label: "Connect", path: "/contact" },
]; ];
@@ -18,7 +19,7 @@ export function Navbar() {
}; };
return ( return (
<nav className="fixed top-4 left-1/2 z-[100] w-[min(100vw-1rem,400px)] -translate-x-1/2 sm:top-8 sm:w-[min(90%,400px)]"> <nav className="fixed top-4 left-1/2 z-[100] w-[min(100vw-1rem,460px)] -translate-x-1/2 sm:top-8 sm:w-[min(90%,460px)]">
<div className="bg-black/20 backdrop-blur-xl border border-white/10 rounded-full px-3 py-2 sm:px-6 sm:py-3 flex items-center justify-between gap-2 sm:gap-4 shadow-2xl"> <div className="bg-black/20 backdrop-blur-xl border border-white/10 rounded-full px-3 py-2 sm:px-6 sm:py-3 flex items-center justify-between gap-2 sm:gap-4 shadow-2xl">
{navItems.map((item) => ( {navItems.map((item) => (
<Link <Link
+68 -36
View File
@@ -1,68 +1,100 @@
"use client"; "use client";
import type { Project } from "../types"; import type { Project } from "../types";
import { SIZE_LABELS } from "../types";
const SIZE_STYLES: Record<Project["size"], string> = {
Big: "border-white/25 bg-white/10 text-white",
MediumSized: "border-white/15 bg-white/[0.06] text-gray-200",
Small: "border-white/10 bg-white/[0.03] text-gray-400",
};
export function ProjectCard({ project }: { project: Project }) { export function ProjectCard({ project }: { project: Project }) {
const loc = project.resolvedLoc ?? project.loc ?? null;
return ( return (
<div className="group relative flex w-full flex-col rounded-2xl border border-white/10 bg-gradient-to-br from-white/10 to-white/5 p-4 backdrop-blur-sm transition-all duration-300 hover:-translate-y-1 hover:border-purple-500/30 hover:shadow-2xl hover:shadow-purple-500/10 sm:p-6"> <div className="group flex w-full flex-col rounded-2xl border border-white/10 bg-white/[0.03] p-4 transition-all duration-300 hover:-translate-y-1 hover:border-white/25 sm:p-6">
{project.image && ( {project.imageUrl ? (
<div className="relative mb-4 flex aspect-video items-center justify-center overflow-hidden rounded-xl bg-black/20 sm:mb-6"> // Image shown in full — no overlays over it.
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" /> <div className="mb-4 flex items-center justify-center overflow-hidden rounded-xl bg-black/30 p-3">
<img <img
src={project.image} src={project.imageUrl}
alt={project.name} alt={project.name}
className={`object-contain shadow-lg transition-transform duration-500 group-hover:scale-110 ${project.rounded === false ? "h-auto w-full max-h-20 rounded-lg sm:max-h-24" : "h-20 w-20 rounded-full sm:h-24 sm:w-24"}`} className="max-h-40 w-auto max-w-full object-contain"
/> />
</div> </div>
)} ) : null}
<h3 className="mb-2 text-lg font-bold text-white transition-colors group-hover:text-purple-400 sm:text-xl"> <div className="mb-2 flex flex-wrap items-center gap-2">
{project.name} <h3 className="text-lg font-semibold text-white sm:text-xl">{project.name}</h3>
</h3> <span className={`rounded-full border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide ${SIZE_STYLES[project.size]}`}>
<p className="mb-5 flex-grow text-sm leading-relaxed text-gray-400 sm:mb-6"> {SIZE_LABELS[project.size]}
{project.description} </span>
</p> {project.label ? (
{project.last_commit && ( <span className="rounded-full border border-white/10 bg-white/5 px-2 py-0.5 text-[10px] font-medium text-gray-300">
<p className="mb-2 text-xs text-gray-400"> {project.label}
Last commit: {new Date(project.last_commit).toLocaleDateString()} </span>
) : null}
</div>
<p className="mb-3 text-sm leading-relaxed text-gray-400">{project.description}</p>
{project.why ? (
<p className="mb-3 text-sm leading-relaxed text-gray-500">
<span className="font-semibold text-gray-400">Why:</span> {project.why}
</p> </p>
)} ) : null}
{project.note ? (
<div className="mb-3 rounded-lg border-l-2 border-white/25 bg-white/[0.04] px-3 py-2 text-xs text-gray-300">
{project.note}
</div>
) : null}
{project.tags.length > 0 ? (
<div className="mb-4 flex flex-wrap gap-2">
{project.tags.map((tag) => (
<span
key={`${project.id}-${tag}`}
className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1 text-xs font-medium text-gray-300"
>
{tag}
</span>
))}
</div>
) : null}
{loc !== null ? (
<p className="mb-4 font-mono text-xs bg-gradient-to-r from-indigo-400 via-pink-500 to-yellow-500 bg-clip-text text-transparent">
{loc.toLocaleString()} lines of code
</p>
) : null}
<div className="mt-auto flex flex-col gap-3 sm:flex-row"> <div className="mt-auto flex flex-col gap-3 sm:flex-row">
<a <a
href={project.link + "?utm_source=portfolio&ref=space"} href={project.link}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="flex flex-1 items-center justify-center gap-2 rounded-lg bg-white/10 px-4 py-2 text-center text-sm font-medium text-white transition-colors hover:bg-white/20" className="flex flex-1 items-center justify-center gap-2 rounded-lg bg-white/10 px-4 py-2 text-center text-sm font-medium text-white transition-colors hover:bg-white/20"
> >
Visit {project.linkIsDemo ? "Live Demo" : "Visit"}
<svg <svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
className="w-3 h-3" <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg> </svg>
</a> </a>
{project.open_source && ( {project.sourceCode ? (
<a <a
href={project.open_source.link} href={project.sourceCode}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="self-center rounded-lg border border-white/5 bg-white/5 p-2 text-gray-400 transition-colors hover:bg-white/10 hover:text-white sm:self-auto" className="flex items-center justify-center rounded-lg border border-white/5 bg-white/5 p-2 text-gray-400 transition-colors hover:bg-white/10 hover:text-white"
title="View Source" title="View Source"
> >
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"> <svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" /> <path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg> </svg>
</a> </a>
)} ) : null}
</div> </div>
</div> </div>
); );
-127
View File
@@ -1,127 +0,0 @@
"use client";
import { useEffect, useMemo, useState } from "react";
const BERLIN_TIME_ZONE = "Europe/Berlin";
const MATCH_COPY = "Your local time matches mine! No issues there.";
function getTimeZoneOffsetMinutes(date: Date, timeZone: string) {
const formatter = new Intl.DateTimeFormat("en-US", {
timeZone,
hour12: false,
hourCycle: "h23",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
const parts = formatter.formatToParts(date);
const values = parts.reduce<Record<string, string>>((acc, part) => {
if (part.type !== "literal") acc[part.type] = part.value;
return acc;
}, {});
const asUTC = Date.UTC(
Number(values.year),
Number(values.month) - 1,
Number(values.day),
Number(values.hour),
Number(values.minute),
Number(values.second),
);
return (asUTC - date.getTime()) / 60000;
}
export function TimezoneClockBlock({ warningThresholdHours = 5 }: { warningThresholdHours?: number }) {
const [now, setNow] = useState<Date | null>(null);
const localFormatter = useMemo(
() =>
new Intl.DateTimeFormat("en-GB", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
}),
[],
);
const berlinFormatter = useMemo(
() =>
new Intl.DateTimeFormat("en-GB", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
timeZone: BERLIN_TIME_ZONE,
}),
[],
);
useEffect(() => {
const updateNow = () => setNow(new Date());
updateNow();
const interval = setInterval(updateNow, 1000);
return () => clearInterval(interval);
}, []);
const localTime = now ? localFormatter.format(now) : "";
const berlinTime = now ? berlinFormatter.format(now) : "";
const clocksMatch = localTime !== "" && localTime === berlinTime;
const timeGapHours =
now === null
? 0
: Math.abs(
getTimeZoneOffsetMinutes(now, BERLIN_TIME_ZONE) + now.getTimezoneOffset(),
) / 60;
return (
<div className="pt-3 flex flex-col items-center gap-3 px-4">
<p className="text-[11px] uppercase tracking-[0.3em] text-gray-500">Time</p>
{clocksMatch && (
<div className="rounded-2xl border border-emerald-400/30 bg-emerald-500/10 px-4 py-3 backdrop-blur-sm shadow-lg text-center">
<p className="text-sm font-semibold text-emerald-100">{MATCH_COPY}</p>
</div>
)}
{!clocksMatch && timeGapHours >= warningThresholdHours && (
<div className="flex flex-col items-center gap-1.5 rounded-2xl border border-amber-500/30 bg-amber-500/10 px-5 py-4 backdrop-blur-sm shadow-lg max-w-sm text-center">
<p className="text-sm font-bold text-amber-400">
Large Time Gap ({Math.round(timeGapHours)}h)
</p>
<p className="text-xs font-medium text-amber-200/80 leading-relaxed">
Because we have a {Math.round(timeGapHours)}-hour time difference, our waking hours might barely overlap. Please expect delayed responses!
</p>
</div>
)}
<div className="flex w-full items-stretch justify-center gap-3">
<div className="relative flex-1 max-w-[11rem] min-w-0 rounded-2xl border border-white/15 bg-white/5 px-4 py-3 shadow-lg backdrop-blur-sm">
<div className="absolute right-0 top-1/2 h-0 w-0 -translate-y-1/2 translate-x-2 border-b-8 border-b-transparent border-l-8 border-l-white/15 border-t-8 border-t-transparent" />
<p className="text-[11px] uppercase tracking-[0.24em] text-gray-400 whitespace-nowrap">
Local time
</p>
<p className="mt-1 font-mono text-sm font-semibold text-gray-100">
{localTime}
</p>
</div>
<div className="relative flex-1 max-w-[11rem] min-w-0 rounded-2xl border border-white/15 bg-white/5 px-4 py-3 shadow-lg backdrop-blur-sm">
<div className="absolute left-0 top-1/2 h-0 w-0 -translate-x-2 -translate-y-1/2 border-b-8 border-b-transparent border-r-8 border-r-white/15 border-t-8 border-t-transparent" />
<p className="text-[11px] uppercase tracking-[0.24em] text-gray-400">
My time
</p>
<p className="mt-1 font-mono text-sm font-semibold text-gray-100">
{berlinTime}
</p>
</div>
</div>
</div>
);
}
-220
View File
@@ -1,220 +0,0 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { useEffect, useRef, useState } from "react";
interface TypingRoomIntroProps {
active: boolean;
onFinish: () => void;
}
export function TypingRoomIntro({ active, onFinish }: TypingRoomIntroProps) {
const [step, setStep] = useState(0);
const [garble, setGarble] = useState("");
const garbleRef = useRef<number | null>(null);
useEffect(() => {
if (!active) {
setStep(0);
return;
}
const timers = [
window.setTimeout(() => setStep(1), 280),
window.setTimeout(() => setStep(2), 900),
window.setTimeout(() => setStep(3), 1700),
window.setTimeout(() => setStep(4), 2800),
window.setTimeout(onFinish, 3600),
];
return () => {
timers.forEach((timer) => window.clearTimeout(timer));
};
}, [active, onFinish]);
// Garbled typing generator while Space is "typing"
useEffect(() => {
if (step >= 3 && step < 4) {
setGarble("");
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()[]{}<>~-=_+";
garbleRef.current = window.setInterval(() => {
// create a short random string to simulate garble
const len = 20;
let s = "";
for (let i = 0; i < len; i++) {
s += charset[Math.floor(Math.random() * charset.length)];
}
setGarble(s);
}, 120);
} else {
if (garbleRef.current) {
window.clearInterval(garbleRef.current);
garbleRef.current = null;
}
setGarble("");
}
return () => {
if (garbleRef.current) {
window.clearInterval(garbleRef.current);
garbleRef.current = null;
}
};
}, [step]);
useEffect(() => {
if (!active) {
return;
}
const originalOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = originalOverflow;
};
}, [active]);
useEffect(() => {
if (!active) {
return;
}
const handleEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
onFinish();
}
};
window.addEventListener("keydown", handleEscape);
return () => {
window.removeEventListener("keydown", handleEscape);
};
}, [active, onFinish]);
return (
<AnimatePresence>
{active && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, transition: { duration: 0.45, ease: "easeInOut" } }}
className="fixed inset-0 z-[220] flex items-center justify-center bg-[#020205] px-4"
aria-live="polite"
role="dialog"
aria-modal="true"
>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_20%_20%,rgba(56,189,248,0.25),transparent_45%),radial-gradient(circle_at_80%_70%,rgba(59,130,246,0.16),transparent_45%),#020205]" />
<div className="absolute inset-0 bg-[linear-gradient(transparent_95%,rgba(255,255,255,0.03)_100%)] bg-[length:100%_6px] opacity-60" />
<motion.div
initial={{ y: 32, scale: 0.96, opacity: 0 }}
animate={{ y: 0, scale: 1, opacity: 1 }}
exit={{ y: -24, scale: 0.98, opacity: 0 }}
transition={{ duration: 0.55, ease: [0.22, 1, 0.36, 1] }}
className="relative w-full max-w-[20rem] overflow-hidden rounded-3xl border border-cyan-300/20 bg-black/55 backdrop-blur-xl shadow-[0_32px_90px_rgba(14,165,233,0.25)] sm:max-w-2xl"
>
<div className="flex items-center justify-between border-b border-white/10 bg-white/5 px-4 py-3 sm:px-5">
<div className="flex items-center gap-2">
<span className="h-2.5 w-2.5 rounded-full bg-red-400/80" />
<span className="h-2.5 w-2.5 rounded-full bg-amber-300/80" />
<span className="h-2.5 w-2.5 rounded-full bg-emerald-400/80" />
</div>
<p className="text-xs uppercase tracking-[0.3em] text-cyan-100/70">
Orbital Chat
</p>
<span className="rounded-full border border-cyan-300/30 px-2 py-0.5 text-[10px] font-medium text-cyan-200/80">
live
</span>
</div>
<div className="space-y-4 p-4 sm:p-6 md:p-8">
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="text-xs uppercase tracking-[0.25em] text-cyan-100/50"
>
Breaking Websockets...
</motion.p>
{step >= 1 && (
<motion.div
initial={{ opacity: 0, x: -16 }}
animate={{ opacity: 1, x: 0 }}
className="max-w-[86%] rounded-2xl rounded-bl-md border border-white/10 bg-white/10 px-3 py-2.5 text-xs text-gray-100 sm:px-4 sm:py-3 sm:text-sm"
>
Why is this page taking so long to load??
</motion.div>
)}
{step >= 2 && (
<motion.div
initial={{ opacity: 0, x: 16 }}
animate={{ opacity: 1, x: 0 }}
className="ml-auto max-w-[88%] rounded-2xl rounded-br-md border border-cyan-300/30 bg-cyan-400/15 px-3 py-2.5 text-xs text-cyan-50 sm:px-4 sm:py-3 sm:text-sm"
>
I have to somehow hide the fact that my discord status is taking a while to load... :D
</motion.div>
)}
{step >= 4 && (
<motion.div
initial={{ opacity: 0, x: 12 }}
animate={{ opacity: 1, x: 0 }}
className="ml-auto max-w-[88%] rounded-2xl rounded-br-md border border-cyan-300/35 bg-cyan-400/20 px-3 py-2.5 text-xs text-cyan-50 sm:px-4 sm:py-3 sm:text-sm"
>
Ok done, just one more animation
</motion.div>
)}
</div>
<div className="relative border-t border-white/10 bg-black/35 px-4 pb-4 pt-4 sm:px-6 sm:pb-5">
{/* Small badge left-above the input showing typing status (hidden once final message shows) */}
{step < 4 && (
<div className="absolute left-4 -top-6 flex items-center gap-2 sm:left-6">
<span className="text-xs font-medium text-cyan-100 sm:text-sm">Space is typing...</span>
<div className="flex items-center gap-1.5">
{[0, 1, 2].map((dot) => (
<motion.span
key={dot}
animate={{ y: [0, -3, 0], opacity: [0.3, 1, 0.3] }}
transition={{ duration: 0.95, repeat: Infinity, delay: dot * 0.14, ease: "easeInOut" }}
className="h-1.5 w-1.5 rounded-full bg-cyan-200"
/>
))}
</div>
</div>
)}
<div className="flex items-center justify-between gap-3 text-xs text-cyan-100/70">
<span className="inline-flex items-center gap-2">
<span className="h-2 w-2 rounded-full bg-emerald-300 shadow-[0_0_10px_rgba(110,231,183,0.75)]" />
You & Space
</span>
<span className="text-[11px] text-gray-400">Esc to skip</span>
</div>
<div className="mt-3 flex min-h-[44px] items-center rounded-xl border border-white/10 bg-white/5 px-3 py-2.5 sm:px-4">
{step >= 3 && step < 4 ? (
<motion.div initial={{ opacity: 0, y: 6 }} animate={{ opacity: 1, y: 0 }} className="flex items-center gap-2.5 font-mono text-xs text-cyan-100 sm:text-sm">
<span className="select-all">{garble || "…"}</span>
</motion.div>
) : (
<p className="text-xs text-gray-500 sm:text-sm">Message Space</p>
)}
</div>
</div>
<motion.div
initial={{ scaleX: 0 }}
animate={{ scaleX: step >= 4 ? 1 : step / 4 }}
transition={{ duration: 0.45, ease: "easeOut" }}
className="h-1 origin-left bg-gradient-to-r from-cyan-300 via-blue-400 to-cyan-300"
/>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}
-35
View File
@@ -1,35 +0,0 @@
import type { Experience } from "../types";
export function getTypeIcon(type: Experience["type"]) {
switch (type) {
case "languages":
return "💻";
case "software":
return "☁️";
case "plattforms":
return "🚀";
case "experience":
return "🌍";
case "other":
return "📦";
default:
return "📦";
}
}
export function getTypeColor(type: Experience["type"]) {
switch (type) {
case "languages":
return "bg-blue-500/20";
case "software":
return "bg-purple-500/20";
case "plattforms":
return "bg-green-500/20";
case "experience":
return "bg-orange-500/20";
case "other":
return "bg-gray-500/20";
default:
return "bg-gray-500/20";
}
}
+34 -135
View File
@@ -1,156 +1,55 @@
"use client"; "use client";
import React, { createContext, useContext, useEffect, useState, useMemo } from "react"; import React, { createContext, useContext, useEffect, useState } from "react";
import type { Experience, MiniProject, Project, RealWork, Skill } from "../types"; import type { Affiliate, Project, WorkExperience } from "../types";
interface ProfileContextType { interface ProfileContextType {
status: string;
statusMessage: string;
borderStatus: string;
glowColor: string;
displayMessage: string;
isScrambling: boolean;
rotatingMessages: string[];
projects: Project[]; projects: Project[];
miniProjects: MiniProject[]; experiences: WorkExperience[];
experiences: Experience[]; affiliates: Affiliate[];
realWork: RealWork[]; loading: boolean;
skills: Skill[];
selectedMiniProject: MiniProject | null;
setSelectedMiniProject: (p: MiniProject | null) => void;
selectedExperience: Experience | null;
setSelectedExperience: (e: Experience | null) => void;
} }
const ProfileContext = createContext<ProfileContextType | undefined>(undefined); const ProfileContext = createContext<ProfileContextType | undefined>(undefined);
export function ProfileProvider({ children }: { children: React.ReactNode }) { export function ProfileProvider({ children }: { children: React.ReactNode }) {
const [status, setStatus] = useState("");
const [borderStatus, setBorderStatus] = useState("border-gray-700");
const [glowColor, setGlowColor] = useState("rgba(55, 65, 81, 0.5)");
const [statusMessage, setStatusMessage] = useState("[??????]");
const [messageIndex, setMessageIndex] = useState(0);
const [displayMessage, setDisplayMessage] = useState("");
const [isScrambling, setIsScrambling] = useState(false);
const [selectedMiniProject, setSelectedMiniProject] = useState<MiniProject | null>(null);
const [selectedExperience, setSelectedExperience] = useState<Experience | null>(null);
const [projects, setProjects] = useState<Project[]>([]); const [projects, setProjects] = useState<Project[]>([]);
const [miniProjects, setMiniProjects] = useState<MiniProject[]>([]); const [experiences, setExperiences] = useState<WorkExperience[]>([]);
const [experiences, setExperiences] = useState<Experience[]>([]); const [affiliates, setAffiliates] = useState<Affiliate[]>([]);
const [realWork, setRealWork] = useState<RealWork[]>([]); const [loading, setLoading] = useState(true);
const [skills, setSkills] = useState<Skill[]>([]);
const rotatingMessages = useMemo(
() => [
"Yelling at Luna",
"Assigning more Issues to myself and letting Luna do them",
"NOT coding in Rust 😂✌️",
"Rewriting the same helper 3 times",
"Micro-service-maxxing",
"Blaming SHSF for my bad code",
"Shipping fast SHSF code",
"Writing code with 1.4k+ Lines",
"Love-Hate relationship with TypeScript",
"Blaming Openai",
"Undescribable Music Taste"
],
[],
);
const characters =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()-_=+[]{}|;:',.<>?/`~" +
"?????????????????????????";
useEffect(() => { useEffect(() => {
if (status === "online") { let cancelled = false;
setBorderStatus("border-green-500");
setGlowColor("rgba(34, 197, 94, 0.5)");
setStatusMessage("Reachable");
} else if (status === "offline") {
setBorderStatus("border-gray-500");
setGlowColor("rgba(107, 114, 128, 0.5)");
setStatusMessage("Probably Away");
} else if (status === "dnd") {
setBorderStatus("border-red-600");
setGlowColor("rgba(220, 38, 38, 0.5)");
setStatusMessage("Not Reachable");
} else if (status === "idle") {
setBorderStatus("border-yellow-500");
setGlowColor("rgba(234, 179, 8, 0.5)");
setStatusMessage("Doing anything but work");
} else {
setBorderStatus("border-gray-700");
setGlowColor("rgba(55, 65, 81, 0.5)");
setStatusMessage("[??????]");
}
}, [status]);
useEffect(() => { async function load<T>(url: string, set: (v: T[]) => void) {
fetch("https://shsf-api.reversed.dev/api/exec/6/c084ec4a-1b20-491e-ab2e-67c5fa8881e6")
.then((res) => res.json())
.then((data) => setStatus(data.status))
.catch(() => setStatus("offline"));
const fetchRealWork = async () => {
try { try {
const res = await fetch("https://shsf-api.reversed.dev/api/exec/4/e942538c-caa1-49d1-8953-dfab1e62f8cb/real_work"); const res = await fetch(url);
if (res.ok) setRealWork(await res.json()); if (!res.ok) return;
} catch { setRealWork([]); } const json = await res.json();
}; if (!cancelled) set(Array.isArray(json) ? json : []);
} catch {
fetch("https://shsf-api.reversed.dev/api/exec/4/e942538c-caa1-49d1-8953-dfab1e62f8cb/projects") /* leave empty on failure */
.then(res => res.json()).then(setProjects).catch(() => setProjects([])); }
fetch("https://shsf-api.reversed.dev/api/exec/4/e942538c-caa1-49d1-8953-dfab1e62f8cb/mini_projects") }
.then(res => res.json()).then(setMiniProjects).catch(() => setMiniProjects([]));
fetch("https://shsf-api.reversed.dev/api/exec/4/e942538c-caa1-49d1-8953-dfab1e62f8cb/experience")
.then(res => res.json()).then(setExperiences).catch(() => setExperiences([]));
fetch("https://shsf-api.reversed.dev/api/exec/4/e942538c-caa1-49d1-8953-dfab1e62f8cb/skills")
.then(res => res.json()).then(setSkills).catch(() => setSkills([]));
fetchRealWork(); Promise.all([
load<Project>("/api/projects", setProjects),
load<WorkExperience>("/api/experience", setExperiences),
load<Affiliate>("/api/affiliates", setAffiliates),
]).finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, []); }, []);
useEffect(() => { return (
const targetMessage = rotatingMessages[messageIndex]; <ProfileContext.Provider value={{ projects, experiences, affiliates, loading }}>
if (isScrambling) { {children}
let iteration = 0; </ProfileContext.Provider>
const interval = setInterval(() => { );
setDisplayMessage(targetMessage.split("").map((char, i) => {
if (i < iteration) return targetMessage[i];
if (char === " " || /[\u{1F000}-\u{1F9FF}]/u.test(char)) return char;
return characters[Math.floor(Math.random() * characters.length)];
}).join(""));
if (iteration >= targetMessage.length) {
clearInterval(interval);
setIsScrambling(false);
}
iteration += 1;
}, 15);
return () => clearInterval(interval);
} else {
setDisplayMessage(targetMessage);
}
}, [messageIndex, isScrambling, characters, rotatingMessages]);
useEffect(() => {
const interval = setInterval(() => {
setIsScrambling(true);
setMessageIndex((prev) => (prev + 1) % rotatingMessages.length);
}, 7000);
return () => clearInterval(interval);
}, [rotatingMessages.length]);
const value = {
status, statusMessage, borderStatus, glowColor, displayMessage, isScrambling,
rotatingMessages,
projects, miniProjects, experiences, realWork, skills,
selectedMiniProject, setSelectedMiniProject,
selectedExperience, setSelectedExperience
};
return <ProfileContext.Provider value={value}>{children}</ProfileContext.Provider>;
} }
export function useProfile() { export function useProfile() {
+33
View File
@@ -0,0 +1,33 @@
import { NextResponse } from "next/server";
import { isAdmin } from "@/lib/auth";
import { ValidationError } from "@/lib/dto";
/** Returns a 401 response when the caller isn't an authenticated admin, else null. */
export async function guard(): Promise<NextResponse | null> {
if (!(await isAdmin())) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
return null;
}
/** Runs a handler, mapping ValidationError -> 400 and other errors -> 500. */
export async function run(handler: () => Promise<NextResponse>): Promise<NextResponse> {
try {
return await handler();
} catch (err) {
if (err instanceof ValidationError) {
return NextResponse.json({ error: err.message }, { status: 400 });
}
console.error(err);
return NextResponse.json({ error: "Internal error" }, { status: 500 });
}
}
export async function readBody(req: Request): Promise<Record<string, unknown>> {
try {
const body = await req.json();
return body && typeof body === "object" ? (body as Record<string, unknown>) : {};
} catch {
return {};
}
}
+50
View File
@@ -0,0 +1,50 @@
import crypto from "node:crypto";
import { cookies } from "next/headers";
export const SESSION_COOKIE = "admin_session";
const SESSION_TTL_MS = 1000 * 60 * 60 * 24 * 7; // 7 days
function getSecret() {
return process.env.SESSION_SECRET || "insecure-dev-secret-change-me";
}
function sign(value: string) {
return crypto.createHmac("sha256", getSecret()).update(value).digest("base64url");
}
/** Build a signed `<expiry>.<signature>` session token. */
export function createSessionToken(): string {
const expiry = String(Date.now() + SESSION_TTL_MS);
return `${expiry}.${sign(expiry)}`;
}
/** Verify a token's signature (constant-time) and expiry. */
export function verifySessionToken(token: string | undefined | null): boolean {
if (!token) return false;
const [expiry, signature] = token.split(".");
if (!expiry || !signature) return false;
const expected = sign(expiry);
const a = Buffer.from(signature);
const b = Buffer.from(expected);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return false;
const expiryMs = Number(expiry);
return Number.isFinite(expiryMs) && expiryMs > Date.now();
}
/** Constant-time password comparison against ADMIN_PASSWORD. */
export function verifyPassword(candidate: string): boolean {
const expected = process.env.ADMIN_PASSWORD;
if (!expected) return false;
const a = Buffer.from(candidate);
const b = Buffer.from(expected);
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}
/** True when the incoming request carries a valid admin session cookie. */
export async function isAdmin(): Promise<boolean> {
const store = await cookies();
return verifySessionToken(store.get(SESSION_COOKIE)?.value);
}
+113
View File
@@ -0,0 +1,113 @@
// Input parsing/validation for admin write endpoints. Each parser returns the
// Prisma-ready data object or throws a ValidationError (mapped to HTTP 400).
import { ProjectSize } from "@/generated/prisma/client";
export class ValidationError extends Error {}
function str(value: unknown, field: string): string {
if (typeof value !== "string" || value.trim() === "") {
throw new ValidationError(`"${field}" is required`);
}
return value.trim();
}
function optStr(value: unknown): string | null {
if (value === undefined || value === null || value === "") return null;
if (typeof value !== "string") throw new ValidationError("Expected a string");
const trimmed = value.trim();
return trimmed === "" ? null : trimmed;
}
function bool(value: unknown): boolean {
return value === true;
}
function int(value: unknown): number {
const n = typeof value === "number" ? value : Number.parseInt(String(value ?? 0), 10);
return Number.isFinite(n) ? Math.trunc(n) : 0;
}
function optInt(value: unknown): number | null {
if (value === undefined || value === null || value === "") return null;
const n = typeof value === "number" ? value : Number.parseInt(String(value), 10);
return Number.isFinite(n) ? Math.trunc(n) : null;
}
function tags(value: unknown, max: number): string[] {
if (!Array.isArray(value)) return [];
const cleaned = value
.map((t) => (typeof t === "string" ? t.trim() : ""))
.filter(Boolean);
if (cleaned.length > max) {
throw new ValidationError(`Too many tags (max ${max})`);
}
return cleaned;
}
function strList(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.map((t) => (typeof t === "string" ? t.trim() : "")).filter(Boolean);
}
function date(value: unknown, field: string): Date {
const d = new Date(String(value));
if (Number.isNaN(d.getTime())) throw new ValidationError(`"${field}" is not a valid date`);
return d;
}
function optDate(value: unknown): Date | null {
if (value === undefined || value === null || value === "") return null;
const d = new Date(String(value));
return Number.isNaN(d.getTime()) ? null : d;
}
export function parseProject(body: Record<string, unknown>) {
const size = String(body.size);
const validSize = (Object.values(ProjectSize) as string[]).includes(size)
? (size as ProjectSize)
: ProjectSize.MediumSized;
return {
name: str(body.name, "name"),
label: str(body.label, "label"),
size: validSize,
imageUrl: optStr(body.imageUrl),
link: str(body.link, "link"),
linkIsDemo: bool(body.linkIsDemo),
sourceCode: optStr(body.sourceCode),
description: str(body.description, "description"),
why: str(body.why, "why"),
note: optStr(body.note),
tags: tags(body.tags, 3),
loc: optInt(body.loc),
locEndpoint: optStr(body.locEndpoint),
};
}
export function parseExperience(body: Record<string, unknown>) {
return {
company: str(body.company, "company"),
role: str(body.role, "role"),
fromDate: date(body.fromDate, "fromDate"),
toDate: optDate(body.toDate),
url: optStr(body.url),
iconUrl: optStr(body.iconUrl),
summary: str(body.summary, "summary"),
tags: tags(body.tags, 6),
sortIndex: int(body.sortIndex),
};
}
export function parseAffiliate(body: Record<string, unknown>) {
return {
name: str(body.name, "name"),
link: str(body.link, "link"),
icon: str(body.icon, "icon"),
location: str(body.location, "location"),
provides: strList(body.provides),
good: strList(body.good),
bad: strList(body.bad),
sortIndex: int(body.sortIndex),
};
}
+40
View File
@@ -0,0 +1,40 @@
// In-memory, TTL-based cache for lines-of-code counts fetched from a project's
// `locEndpoint`. The endpoint is expected to return a plaintext integer.
type CacheEntry = { value: number | null; expiresAt: number };
const CACHE_TTL_MS = 1000 * 60 * 10; // 10 minutes
const cache = new Map<string, CacheEntry>();
async function fetchLocFromEndpoint(endpoint: string): Promise<number | null> {
const cached = cache.get(endpoint);
if (cached && cached.expiresAt > Date.now()) return cached.value;
let value: number | null = null;
try {
const res = await fetch(endpoint, { signal: AbortSignal.timeout(5000) });
if (res.ok) {
const text = (await res.text()).trim();
const parsed = Number.parseInt(text.replace(/[^0-9]/g, ""), 10);
value = Number.isFinite(parsed) ? parsed : null;
}
} catch {
value = null;
}
cache.set(endpoint, { value, expiresAt: Date.now() + CACHE_TTL_MS });
return value;
}
/**
* Resolve a project's line-of-code count: prefer the manually provided `loc`,
* otherwise hit `locEndpoint` (cached in memory). Returns null when unknown.
*/
export async function resolveLoc(
loc: number | null | undefined,
locEndpoint: string | null | undefined,
): Promise<number | null> {
if (typeof loc === "number") return loc;
if (locEndpoint) return fetchLocFromEndpoint(locEndpoint);
return null;
}
+21
View File
@@ -0,0 +1,21 @@
import { PrismaClient } from "@/generated/prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
function createClient() {
const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
throw new Error("DATABASE_URL is not set");
}
const adapter = new PrismaPg({ connectionString });
return new PrismaClient({ adapter });
}
export const prisma = globalForPrisma.prisma ?? createClient();
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}
+2 -4
View File
@@ -24,9 +24,7 @@ export function Activity() {
return ( return (
<section className="w-full max-w-6xl mx-auto space-y-6 px-4 sm:space-y-8"> <section className="w-full max-w-6xl mx-auto space-y-6 px-4 sm:space-y-8">
<div className="text-center space-y-4"> <div className="text-center space-y-4">
<h2 className="text-2xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-blue-400 to-purple-500 sm:text-3xl"> <h2 className="text-2xl font-semibold text-white sm:text-3xl">Code Activity</h2>
Code Activity
</h2>
<p className="mx-auto max-w-2xl text-sm text-gray-400 sm:text-base"> <p className="mx-auto max-w-2xl text-sm text-gray-400 sm:text-base">
Today, i've already been coding for Today, i've already been coding for
<span className="font-mono text-base text-green-400 ml-1"> <span className="font-mono text-base text-green-400 ml-1">
@@ -41,7 +39,7 @@ export function Activity() {
</div> </div>
<div className="mx-auto max-w-5xl rounded-3xl border border-white/10 bg-white/5 p-3 shadow-[0_20px_80px_rgba(0,0,0,0.35)] backdrop-blur-sm sm:p-4 md:p-6"> <div className="mx-auto max-w-5xl rounded-3xl border border-white/10 bg-white/5 p-3 shadow-[0_20px_80px_rgba(0,0,0,0.35)] backdrop-blur-sm sm:p-4 md:p-6">
<div className="overflow-x-auto rounded-2xl border border-white/10 bg-[#0a0a12]"> <div className="overflow-x-auto rounded-2xl bg-[#0d1117]">
<img <img
src={activitySvg} src={activitySvg}
alt="Git activity graph" alt="Git activity graph"
+64 -91
View File
@@ -1,121 +1,94 @@
"use client"; "use client";
type Affiliate = { import { useProfile } from "../context/ProfileContext";
name: string;
good: string[];
bad: string[];
link: string;
icon: string;
location: string;
provides: string[];
};
const affiliates: Affiliate[] = [
{
name: "SparkedHost",
good: ["Decent Support", "Good Bot Hosting", "Generous Webhosting"],
bad: ["Staff can be hit or miss"],
link: "https://billing.sparkedhost.com/aff.php?aff=1843",
icon: "https://sparkedhost.com/_next/static/media/logo-text.fce7e4c5.svg",
location: "Global",
provides: ["A ton of Game Hosting", "Web Hosting", "VPS", "Bot Hosting", "Domains"],
},
{
name: "Datalix",
good: ["Uptime", "Hardware", "Prices", "Support"],
bad: ["Nothing"],
link: "https://datalix.de/a/space",
icon: "https://cdn.datalix.de/images/header.png",
location: "Germany",
provides: ["KVM VPS", "Web Hosting", "Dedicated Servers", "Game Servers", "S3", "Nextcloud", "Reselling"],
},
];
export function Affiliates() { export function Affiliates() {
const { affiliates } = useProfile();
if (affiliates.length === 0) return null;
return ( return (
<section className="w-full max-w-5xl mx-auto space-y-6 px-4 sm:space-y-8"> <section className="w-full max-w-5xl mx-auto space-y-6 px-4 sm:space-y-8">
<div className="text-center space-y-4"> <div className="text-center space-y-4">
<h2 className="text-2xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-blue-400 to-purple-500 sm:text-3xl"> <h2 className="text-2xl font-semibold text-white sm:text-3xl">Affiliates</h2>
Affiliates <p className="mx-auto max-w-2xl text-sm text-gray-500 sm:text-base">
</h2> Hosting and services I actually use and recommend.
<p className="mx-auto max-w-2xl text-sm text-gray-400 sm:text-base">
Looking for hosting? I recommend checking out Datalix!
</p> </p>
</div> </div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 sm:gap-5"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2 sm:gap-5">
{affiliates.map((affiliate) => ( {affiliates.map((affiliate) => (
<a <a
key={affiliate.name} key={affiliate.id}
href={affiliate.link} href={affiliate.link}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="w-full rounded-xl border border-white/10 bg-white/5 p-4 backdrop-blur-sm transition-all duration-300 hover:border-purple-500/40 hover:bg-white/10" className="w-full rounded-xl border border-white/10 bg-white/[0.03] p-4 transition-all duration-300 hover:border-white/25 hover:bg-white/[0.06]"
> >
<div className="flex flex-col items-center text-center gap-2 mb-3"> <div className="mb-3 flex flex-col items-center gap-2 text-center">
<img {affiliate.icon ? (
src={affiliate.icon} <img
alt={affiliate.name} src={affiliate.icon}
className="h-7 max-w-[130px] w-auto object-contain rounded" alt={affiliate.name}
/> className="h-7 w-auto max-w-[130px] rounded object-contain"
/>
) : null}
<div> <div>
<h3 className="text-lg font-semibold text-white"> <h3 className="text-lg font-semibold text-white">{affiliate.name}</h3>
{affiliate.name} <p className="mt-1 text-[10px] font-medium uppercase leading-none tracking-widest text-gray-500">
</h3>
<p className="text-[10px] uppercase tracking-widest text-gray-500 font-medium leading-none mt-1">
{affiliate.location} {affiliate.location}
</p> </p>
</div> </div>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
<div> {affiliate.provides.length > 0 ? (
<p className="text-xs font-semibold text-blue-300 mb-1.5"> <div>
Provides <p className="mb-1.5 text-xs font-semibold text-gray-400">Provides</p>
</p> <div className="flex flex-wrap gap-2">
<div className="flex flex-wrap gap-2"> {affiliate.provides.map((item) => (
{affiliate.provides.map((item) => ( <span
<span key={item}
key={item} className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-2.5 py-1 text-xs text-gray-300"
className="inline-flex items-center gap-1.5 rounded-full border border-blue-400/30 bg-blue-500/10 px-2.5 py-1 text-xs text-blue-200" >
> {item}
{item} </span>
</span> ))}
))} </div>
</div> </div>
</div> ) : null}
<div> {affiliate.good.length > 0 ? (
<p className="text-xs font-semibold text-emerald-300 mb-1.5"> <div>
Good <p className="mb-1.5 text-xs font-semibold text-emerald-300">Good</p>
</p> <div className="flex flex-wrap gap-2">
<div className="flex flex-wrap gap-2"> {affiliate.good.map((item) => (
{affiliate.good.map((item) => ( <span
<span key={item}
key={item} className="inline-flex items-center gap-1.5 rounded-full border border-emerald-400/30 bg-emerald-500/10 px-2.5 py-1 text-xs text-emerald-200"
className="inline-flex items-center gap-1.5 rounded-full border border-emerald-400/30 bg-emerald-500/10 px-2.5 py-1 text-xs text-emerald-200" >
> <span className="font-bold">+</span>
<span className="font-bold">+</span> {item}
{item} </span>
</span> ))}
))} </div>
</div> </div>
</div> ) : null}
<div> {affiliate.bad.length > 0 ? (
<p className="text-xs font-semibold text-rose-300 mb-1.5"> <div>
Bad <p className="mb-1.5 text-xs font-semibold text-rose-300">Bad</p>
</p> <div className="flex flex-wrap gap-2">
<div className="flex flex-wrap gap-2"> {affiliate.bad.map((item) => (
{affiliate.bad.map((item) => ( <span
<span key={item}
key={item} className="inline-flex items-center gap-1.5 rounded-full border border-rose-400/30 bg-rose-500/10 px-2.5 py-1 text-xs text-rose-200"
className="inline-flex items-center gap-1.5 rounded-full border border-rose-400/30 bg-rose-500/10 px-2.5 py-1 text-xs text-rose-200" >
> <span className="font-bold">-</span>
<span className="font-bold">-</span> {item}
{item} </span>
</span> ))}
))} </div>
</div> </div>
</div> ) : null}
</div> </div>
</a> </a>
))} ))}
-86
View File
@@ -1,86 +0,0 @@
"use client";
import { useProfile } from "../context/ProfileContext";
export function AgentSkills() {
const { skills } = useProfile();
return (
<section className="w-full max-w-6xl mx-auto px-4 space-y-6 sm:space-y-8">
<div className="text-center space-y-3">
<h2 className="text-3xl font-bold leading-tight text-transparent bg-clip-text bg-gradient-to-r from-amber-400 to-orange-500 sm:text-4xl">
Agent Skills
</h2>
<p className="mx-auto max-w-2xl text-sm text-gray-400 sm:text-base">
Skills i throw at my Agents to help them solve my prompts.
</p>
</div>
{skills.length === 0 ? (
<div className="rounded-3xl border border-white/10 bg-white/5 px-5 py-8 text-center text-gray-400 sm:px-6 sm:py-10">
No agent skills published yet.
</div>
) : (
<div className="grid grid-cols-1 gap-5 lg:grid-cols-2 sm:gap-6">
{skills.map((skill, index) => (
<article
key={`${skill.name}-${index}`}
className="group w-full rounded-3xl border border-amber-500/20 bg-gradient-to-br from-amber-500/10 via-white/5 to-orange-500/5 p-5 shadow-[0_0_0_1px_rgba(245,158,11,0.08)] backdrop-blur-sm transition-all duration-300 hover:border-amber-500/40 sm:p-6 md:p-7"
>
<div className="flex flex-col gap-4 sm:flex-row sm:flex-wrap sm:items-start sm:justify-between">
<div className="space-y-2">
<div className="flex flex-wrap gap-2">
{(skill.emojis || []).map((emoji, emojiIndex) => (
<span
key={`${skill.name}-emoji-${emojiIndex}`}
className="inline-flex h-9 w-9 items-center justify-center rounded-xl bg-amber-500/15 text-lg border border-amber-500/20"
title={emoji}
>
{emoji}
</span>
))}
</div>
<h3 className="text-xl font-semibold text-white sm:text-2xl">{skill.name}</h3>
</div>
{skill.link ? (
<a
href={skill.link}
target="_blank"
rel="noreferrer"
className="inline-flex items-center justify-center rounded-full border border-amber-400/30 bg-amber-400/10 px-4 py-2 text-xs font-semibold text-amber-100 transition-colors hover:bg-amber-400/20 sm:self-start"
>
Open Link
</a>
) : null}
</div>
<div className="mt-5 space-y-4 text-sm leading-6 text-gray-300">
<p>
<span className="font-semibold text-amber-200">Description:</span> {skill.description}
</p>
<p>
<span className="font-semibold text-amber-200">Purpose:</span> {skill.purpose}
</p>
<p>
<span className="font-semibold text-amber-200">Solves:</span> {skill.what_it_solves}
</p>
</div>
<div className="mt-6 flex flex-wrap gap-2">
{(skill.tags || []).map((tag, tagIndex) => (
<span
key={`${skill.name}-tag-${tagIndex}`}
className="rounded-full border border-white/10 bg-black/20 px-3 py-1 text-xs font-medium text-gray-200"
>
{tag}
</span>
))}
</div>
</article>
))}
</div>
)}
</section>
);
}
+5 -5
View File
@@ -1,18 +1,18 @@
"use client"; "use client";
import { CONTACT_EMAIL } from "../types";
export function Contact() { export function Contact() {
return ( return (
<section className="mx-auto w-full max-w-2xl space-y-4 pb-8 text-center"> <section className="mx-auto w-full max-w-2xl space-y-4 pb-8 text-center">
<h2 className="text-2xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-green-400 via-blue-500 to-purple-500 sm:text-3xl"> <h2 className="text-2xl font-semibold text-white sm:text-3xl">Want to talk?</h2>
Want to talk? <div className="rounded-3xl border border-white/10 bg-white/[0.03] p-6 sm:p-8">
</h2>
<div className="rounded-3xl border border-blue-500/20 bg-gradient-to-br from-blue-500/10 via-purple-500/10 to-pink-500/10 p-6 backdrop-blur-md sm:p-8">
<p className="mb-6 text-base text-gray-300 sm:mb-8 sm:text-lg"> <p className="mb-6 text-base text-gray-300 sm:mb-8 sm:text-lg">
Im almost always down to talk about anything. Tech, life and whatever. Feel free to reach out to me on Discord or via Email, i usually respond pretty quickly. Im almost always down to talk about anything. Tech, life and whatever. Feel free to reach out to me on Discord or via Email, i usually respond pretty quickly.
</p> </p>
<div className="flex flex-col justify-center gap-3 sm:flex-row sm:gap-4"> <div className="flex flex-col justify-center gap-3 sm:flex-row sm:gap-4">
<a <a
href="mailto:space@reversed.dev" href={`mailto:${CONTACT_EMAIL}`}
className="w-full rounded-full bg-gradient-to-r from-white to-gray-100 px-6 py-3 font-bold text-black transition-all shadow-lg shadow-white/20 hover:from-gray-100 hover:to-white hover:shadow-white/30 sm:w-auto" className="w-full rounded-full bg-gradient-to-r from-white to-gray-100 px-6 py-3 font-bold text-black transition-all shadow-lg shadow-white/20 hover:from-gray-100 hover:to-white hover:shadow-white/30 sm:w-auto"
> >
Shoot me an Email Shoot me an Email
+49
View File
@@ -0,0 +1,49 @@
"use client";
import Link from "next/link";
import { useProfile } from "../context/ProfileContext";
import { ProjectCard } from "../components/ProjectCard";
// Homepage teaser: the first 3 projects (ordered by size then name, per the API)
// plus a button through to the full /projects page.
export function FeaturedProjects() {
const { projects, loading } = useProfile();
const top = projects.slice(0, 3);
return (
<section className="mx-auto w-full max-w-6xl space-y-8 px-4 sm:space-y-10">
<div className="space-y-3 text-center">
<h2 className="text-3xl font-semibold text-white sm:text-4xl">Featured Projects</h2>
<p className="mx-auto max-w-2xl text-sm text-gray-500 sm:text-base">
A few things I&apos;ve been building lately.
</p>
</div>
{loading ? (
<p className="text-center text-gray-500">Loading projects</p>
) : top.length === 0 ? (
<div className="rounded-2xl border border-white/10 bg-white/5 px-5 py-8 text-center text-gray-400">
No projects published yet.
</div>
) : (
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3 sm:gap-8">
{top.map((project) => (
<ProjectCard key={project.id} project={project} />
))}
</div>
)}
<div className="flex justify-center">
<Link
href="/projects"
className="inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/5 px-6 py-3 text-sm font-semibold text-white transition-all duration-300 hover:border-white/30 hover:bg-white/10"
>
View all projects
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M14 5l7 7m0 0l-7 7m7-7H3" />
</svg>
</Link>
</div>
</section>
);
}
+4 -5
View File
@@ -2,6 +2,7 @@
import { useUmami } from "@danielgtmn/umami-react"; import { useUmami } from "@danielgtmn/umami-react";
import { Github, Mail, MessageSquare } from "lucide-react"; import { Github, Mail, MessageSquare } from "lucide-react";
import { CONTACT_EMAIL } from "../types";
export function Footer() { export function Footer() {
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear();
@@ -29,12 +30,10 @@ export function Footer() {
<Github className="w-5 h-5" /> <Github className="w-5 h-5" />
</a> </a>
<a <a
href="mailto:space@reversed.dev" href={`mailto:${CONTACT_EMAIL}`}
className="hover:text-purple-400 transition-colors" className="hover:text-purple-400 transition-colors"
aria-label="Email" aria-label="Email"
onClick={() => onClick={() => handleLinkClick("Email", "mailto")}
handleLinkClick("Email", "mailto:space@reversed.dev")
}
> >
<Mail className="w-5 h-5" /> <Mail className="w-5 h-5" />
</a> </a>
@@ -58,7 +57,7 @@ export function Footer() {
{/* Branding & Attribution */} {/* Branding & Attribution */}
<div className="flex flex-col items-center gap-2 text-center"> <div className="flex flex-col items-center gap-2 text-center">
<p className="text-gray-500 text-xs tracking-wide"> <p className="text-gray-500 text-xs tracking-wide">
&copy; {currentYear} &bull; Space-Banane &copy; {currentYear} &bull; Paul W.
</p> </p>
<div className="flex items-center gap-3 text-[10px] text-gray-600 uppercase tracking-[0.2em]"> <div className="flex items-center gap-3 text-[10px] text-gray-600 uppercase tracking-[0.2em]">
<a <a
+125 -138
View File
@@ -1,32 +1,62 @@
import { useState, useRef } from "react"; "use client";
import { useRouter } from "next/navigation";
import { TimezoneClockBlock } from "../components/TimezoneClockBlock";
export function Hero({ import { useEffect, useRef, useState } from "react";
glowColor, import { useRouter } from "next/navigation";
borderStatus, import { Mail } from "lucide-react";
displayMessage, import { CONTACT_EMAIL } from "../types";
rotatingMessages,
statusMessage, const HANDLE = "getspaced";
oldUsernames,
timeGapWarningThreshold = 5, // Discord presence via SHSF (drives the avatar ring + name dot colour).
}: { const STATUS_URL =
glowColor: string; "https://shsf.reversed.dev/api/exec/6/c084ec4a-1b20-491e-ab2e-67c5fa8881e6";
borderStatus: string;
displayMessage: string; type Presence = "online" | "idle" | "dnd" | "offline";
rotatingMessages: string[];
statusMessage: string; const PRESENCE: Record<Presence, { ring: string; glow: string; dot: string; label: string }> = {
oldUsernames: string[]; online: {
timeGapWarningThreshold?: number; ring: "border-emerald-400/70",
}) { glow: "bg-emerald-500/30",
dot: "bg-emerald-400 shadow-[0_0_12px_rgba(52,211,153,0.8)]",
label: "Online",
},
idle: {
ring: "border-amber-400/70",
glow: "bg-amber-500/30",
dot: "bg-amber-400 shadow-[0_0_12px_rgba(251,191,36,0.8)]",
label: "Idle",
},
dnd: {
ring: "border-red-500/70",
glow: "bg-red-500/30",
dot: "bg-red-500 shadow-[0_0_12px_rgba(239,68,68,0.8)]",
label: "Do Not Disturb",
},
offline: {
ring: "border-gray-500/60",
glow: "bg-gray-500/20",
dot: "bg-gray-500",
label: "Offline",
},
};
function isPresence(value: unknown): value is Presence {
return value === "online" || value === "idle" || value === "dnd" || value === "offline";
}
export function Hero() {
const router = useRouter(); const router = useRouter();
const [movementCount, setMovementCount] = useState(0); const [presence, setPresence] = useState<Presence>("offline");
// Hidden easter egg: rapidly shake the mouse back and forth over the avatar
// (10 quick direction changes) to jump to the admin panel.
const lastX = useRef<number | null>(null); const lastX = useRef<number | null>(null);
const lastDir = useRef<"left" | "right" | null>(null); const lastDir = useRef<"left" | "right" | null>(null);
const lastTime = useRef<number>(0); const lastTime = useRef<number>(0);
const resetTimeout = useRef<NodeJS.Timeout | null>(null); const shakeCount = useRef<number>(0);
const resetTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleMouseMove = (e: React.MouseEvent) => { const handleAvatarMouseMove = (e: React.MouseEvent) => {
const currentX = e.clientX; const currentX = e.clientX;
const currentTime = Date.now(); const currentTime = Date.now();
@@ -35,22 +65,19 @@ export function Hero({
const velocity = Math.abs(deltaX) / (currentTime - lastTime.current || 1); const velocity = Math.abs(deltaX) / (currentTime - lastTime.current || 1);
const direction = deltaX > 0 ? "right" : "left"; const direction = deltaX > 0 ? "right" : "left";
// Thresholds: velocity > 2 (approx 2px/ms) and changed direction
if (velocity > 2 && direction !== lastDir.current) { if (velocity > 2 && direction !== lastDir.current) {
setMovementCount((prev) => { shakeCount.current += 1;
const newCount = prev + 1;
if (newCount >= 10) {
// Trigger navigation outside of the state update to avoid React warning
setTimeout(() => router.push("/admin"), 0);
return 0;
}
return newCount;
});
lastDir.current = direction; lastDir.current = direction;
// Reset if no movement for a while if (shakeCount.current >= 10) {
shakeCount.current = 0;
setTimeout(() => router.push("/admin"), 0);
}
if (resetTimeout.current) clearTimeout(resetTimeout.current); if (resetTimeout.current) clearTimeout(resetTimeout.current);
resetTimeout.current = setTimeout(() => setMovementCount(0), 1000); resetTimeout.current = setTimeout(() => {
shakeCount.current = 0;
}, 1000);
} }
} }
@@ -58,141 +85,101 @@ export function Hero({
lastTime.current = currentTime; lastTime.current = currentTime;
}; };
useEffect(() => {
let cancelled = false;
fetch(STATUS_URL)
.then((r) => r.json())
.then((d) => {
if (!cancelled && isPresence(d?.status)) setPresence(d.status);
})
.catch(() => {
/* keep offline on failure */
});
return () => {
cancelled = true;
};
}, []);
const style = PRESENCE[presence];
return ( return (
<section <section className="mx-auto w-full max-w-5xl px-4">
className="mt-16 flex flex-col items-center text-center space-y-6 px-4 sm:mt-20 sm:space-y-8 animate-fade-in" <div className="flex flex-col-reverse items-center gap-8 sm:flex-row sm:items-start sm:justify-between sm:gap-10">
data-old-usernames={oldUsernames.join(",")} {/* Left: identity */}
> <div className="flex-1 space-y-5 text-center sm:text-left">
<div className="relative group" onMouseMove={handleMouseMove}> <div className="space-y-2">
<div <h1 className="flex items-center justify-center gap-3 text-4xl font-extrabold text-white sm:justify-start sm:text-5xl">
className={`absolute -inset-1 rounded-full blur opacity-75 transition duration-500`} Paul&nbsp;W.
style={{ backgroundColor: glowColor }} <span
></div> className={`inline-block h-3 w-3 rounded-full transition-colors duration-500 ${style.dot}`}
<div title={style.label}
className={`relative h-32 w-32 overflow-hidden rounded-full border-4 bg-black transition-colors duration-500 sm:h-48 sm:w-48 ${borderStatus}`} aria-label={style.label}
> />
<img </h1>
src="https://cdn.reversed.dev/pictures/20250405_120402.png" <p className="font-mono text-sm text-gray-500">{HANDLE}</p>
alt="Space"
className="h-full w-full scale-110 object-cover transition-transform duration-700 group-hover:scale-125"
/>
</div>
{/* Rotating Message Bubble (Left Side) */}
<div className="absolute -left-4 top-4 hidden -translate-x-full md:block">
<div className="relative bg-gradient-to-br from-purple-500/20 to-pink-500/10 backdrop-blur-sm border border-purple-500/30 rounded-2xl px-4 py-3 shadow-lg">
{/* Arrow pointing to profile */}
<div className="absolute right-0 top-1/2 translate-x-2 -translate-y-1/2 w-0 h-0 border-t-8 border-t-transparent border-b-8 border-b-transparent border-l-8 border-l-purple-500/30"></div>
<div className="absolute right-0 top-1/2 translate-x-1.5 -translate-y-1/2 w-0 h-0 border-t-8 border-t-transparent border-b-8 border-b-transparent border-l-8 border-l-purple-500/20"></div>
<p className="text-sm text-gray-200 whitespace-nowrap font-medium font-mono">
{displayMessage || rotatingMessages[0]}
</p>
</div> </div>
</div>
{/* Status Bubble (Right Side) */} <p className="mx-auto max-w-xl text-base leading-relaxed text-gray-400 sm:mx-0 sm:text-lg">
<div className="absolute -right-4 top-8 hidden translate-x-full md:block"> Full-stack developer &amp; open-source author. Building server
<div className="relative bg-gradient-to-br from-white/10 to-white/5 backdrop-blur-sm border border-white/20 rounded-2xl px-4 py-3 shadow-lg"> infrastructure, developer tools, and web applications.
{/* Arrow pointing to profile */}
<div className="absolute left-0 top-1/2 -translate-x-2 -translate-y-1/2 w-0 h-0 border-t-8 border-t-transparent border-b-8 border-b-transparent border-r-8 border-r-white/20"></div>
<div className="absolute left-0 top-1/2 -translate-x-1.5 -translate-y-1/2 w-0 h-0 border-t-8 border-t-transparent border-b-8 border-b-transparent border-r-8 border-r-white/10"></div>
<p className="text-sm text-gray-300 whitespace-nowrap">
Currently{" "}
<span className="text-white font-semibold">
{statusMessage}
</span>
<br />
on Discord
</p>
</div>
<p className="text-xs text-gray-400/70 mt-1">
Data from <a href="https://github.com/Space-Banane/shsf-discord-status" className="text-blue-400 hover:underline">Discord</a> (cached & delayed)
</p> </p>
</div>
</div>
{/* Mobile: Discord status + message (hidden on md+ where bubbles appear) */} <div className="flex flex-wrap items-center justify-center gap-3 sm:justify-start">
<div className="flex flex-col items-center gap-2 md:hidden w-full max-w-sm">
{(displayMessage || rotatingMessages[0]) && (
<div className="w-full bg-gradient-to-br from-purple-500/20 to-pink-500/10 backdrop-blur-sm border border-purple-500/30 rounded-2xl px-4 py-2.5 text-sm text-gray-200 font-mono text-center">
{displayMessage || rotatingMessages[0]}
</div>
)}
{statusMessage && (
<div className="flex items-center gap-2 rounded-2xl border border-white/15 bg-white/5 px-4 py-2.5 text-sm backdrop-blur-sm">
<span className="h-2 w-2 shrink-0 rounded-full bg-green-400" />
<span className="text-gray-300">Currently</span>
<span className="font-semibold text-white">{statusMessage}</span>
<span className="text-gray-400">on Discord</span>
</div>
)}
</div>
{/* Name & Description */}
<div className="space-y-4 max-w-2xl">
<h1 className="text-4xl font-extrabold leading-tight sm:text-5xl md:text-7xl">
Hey, I'm{" "}
<span
className="relative inline-block text-transparent bg-clip-text bg-gradient-to-r from-blue-400 via-purple-500 to-pink-500 px-2"
style={{ marginLeft: "0.15em", marginRight: "0.15em" }}
>
Space²
</span>
</h1>
<p className="text-base font-light text-gray-400 sm:text-xl md:text-2xl">
A{" "}
<span className="line-through decoration-purple-500/50 decoration-2">
Self-proclaimed
</span>{" "}
Developer breaking things to see how they&nbsp;work.
</p>
{/* Luna AI Profile Link */}
<div className="pt-4 flex justify-center">
<div className="flex flex-wrap items-center justify-center gap-3">
<a <a
href="https://luna.spaceistyping.com" href={`mailto:${CONTACT_EMAIL}`}
target="_blank" className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-5 py-2.5 text-sm font-medium text-gray-200 transition-all duration-300 hover:border-white/25 hover:bg-white/10"
rel="noopener noreferrer"
className="group relative flex w-full items-center justify-center gap-3 rounded-full border border-white/10 bg-white/5 px-5 py-2.5 text-center shadow-lg transition-all duration-300 hover:border-purple-500/50 hover:bg-white/10 hover:shadow-purple-500/10 sm:w-auto"
> >
<div className="w-3 h-3 rounded-full bg-purple-500" /> <Mail className="h-4 w-4" />
<span className="text-sm font-medium text-gray-300 group-hover:text-white transition-colors"> Contact
<span className="text-purple-400 font-semibold">Luna</span>
</span>
</a> </a>
<a <a
href="https://github.com/Space-Banane" href="https://github.com/Space-Banane"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="group relative flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-4 py-2 shadow-sm transition-all duration-300 hover:border-gray-400 hover:bg-white/10" className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-4 py-2.5 text-sm font-medium text-gray-200 transition-all duration-300 hover:border-gray-400 hover:bg-white/10"
aria-label="GitHub" aria-label="GitHub"
> >
<svg className="w-6 h-6 text-gray-300 group-hover:text-white" viewBox="0 0 24 24" fill="currentColor" aria-hidden> <svg className="h-5 w-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
<path d="M12 2C6.48 2 2 6.48 2 12c0 4.42 2.87 8.17 6.84 9.49.5.09.68-.22.68-.48 0-.24-.01-.87-.01-1.71-2.78.6-3.37-1.34-3.37-1.34-.45-1.16-1.11-1.47-1.11-1.47-.91-.62.07-.61.07-.61 1 .07 1.53 1.03 1.53 1.03.89 1.52 2.34 1.08 2.91.83.09-.65.35-1.08.64-1.33-2.22-.25-4.56-1.11-4.56-4.95 0-1.09.39-1.98 1.03-2.68-.1-.25-.45-1.27.1-2.65 0 0 .84-.27 2.75 1.02A9.56 9.56 0 0112 6.8c.85.004 1.71.115 2.51.338 1.91-1.29 2.75-1.02 2.75-1.02.55 1.38.2 2.4.10 2.65.64.7 1.03 1.59 1.03 2.68 0 3.85-2.34 4.7-4.57 4.95.36.31.68.92.68 1.86 0 1.34-.01 2.42-.01 2.75 0 .26.18.58.69.48A10.01 10.01 0 0022 12c0-5.52-4.48-10-10-10z" /> <path d="M12 2C6.48 2 2 6.48 2 12c0 4.42 2.87 8.17 6.84 9.49.5.09.68-.22.68-.48 0-.24-.01-.87-.01-1.71-2.78.6-3.37-1.34-3.37-1.34-.45-1.16-1.11-1.47-1.11-1.47-.91-.62.07-.61.07-.61 1 .07 1.53 1.03 1.53 1.03.89 1.52 2.34 1.08 2.91.83.09-.65.35-1.08.64-1.33-2.22-.25-4.56-1.11-4.56-4.95 0-1.09.39-1.98 1.03-2.68-.1-.25-.45-1.27.1-2.65 0 0 .84-.27 2.75 1.02A9.56 9.56 0 0112 6.8c.85.004 1.71.115 2.51.338 1.91-1.29 2.75-1.02 2.75-1.02.55 1.38.2 2.4.10 2.65.64.7 1.03 1.59 1.03 2.68 0 3.85-2.34 4.7-4.57 4.95.36.31.68.92.68 1.86 0 1.34-.01 2.42-.01 2.75 0 .26.18.58.69.48A10.01 10.01 0 0022 12c0-5.52-4.48-10-10-10z" />
</svg> </svg>
GitHub
</a> </a>
<a <a
href="https://gitea.reversed.dev/space" href="https://gitea.reversed.dev/space"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="group relative flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-4 py-2 shadow-sm transition-all duration-300 hover:border-yellow-400 hover:bg-white/10" className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-4 py-2.5 text-sm font-medium text-gray-200 transition-all duration-300 hover:border-emerald-400/50 hover:bg-white/10"
aria-label="Gitea" aria-label="Gitea"
> >
<img <img
src="https://gitea.reversed.dev/assets/img/logo.svg" src="https://gitea.reversed.dev/assets/img/logo.svg"
alt="Gitea" alt=""
className="w-6 h-6" className="h-5 w-5"
/> />
Gitea
</a> </a>
</div> </div>
<p className="text-xs uppercase tracking-[0.2em] text-gray-600">
<span className="text-gray-500"></span> Europe/Berlin
</p>
</div> </div>
<TimezoneClockBlock warningThresholdHours={timeGapWarningThreshold} /> {/* Right: avatar with live status ring */}
<div className="relative shrink-0" onMouseMove={handleAvatarMouseMove}>
<div className={`absolute -inset-1 rounded-full blur-md transition-colors duration-500 ${style.glow}`} />
<div
className={`relative h-32 w-32 overflow-hidden rounded-full border-4 bg-black transition-colors duration-500 sm:h-44 sm:w-44 ${style.ring}`}
>
<img
src="/api/profile-image"
alt="Paul W."
className="h-full w-full scale-105 object-cover"
/>
</div>
</div>
</div> </div>
</section> </section>
); );
+53
View File
@@ -0,0 +1,53 @@
"use client";
export function Luna() {
return (
<section className="mx-auto w-full max-w-4xl px-4">
<div className="rounded-3xl border border-white/10 bg-white/[0.03] p-6 sm:p-8 md:p-10">
<div className="space-y-5">
<div className="flex items-center gap-3">
<span className="h-2.5 w-2.5 rounded-full bg-white/70" />
<p className="text-xs font-medium uppercase tracking-[0.25em] text-gray-500">
AI Agent
</p>
</div>
<h2 className="text-2xl font-semibold text-white sm:text-3xl">Meet Luna</h2>
<p className="max-w-2xl text-base leading-relaxed text-gray-400 sm:text-lg">
Luna is a personal agent running in{" "}
<span className="font-medium text-gray-200">Openclaw</span> on my homelab. Luna
codes, reviews PRs, and handles a bunch of other tasks throughout the day.
</p>
<div className="flex flex-wrap gap-2">
{["Writes code", "Reviews PRs", "Runs on Openclaw", "OpenAI (ChatGPT Plus)"].map(
(tag) => (
<span
key={tag}
className="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs font-medium text-gray-300"
>
{tag}
</span>
),
)}
</div>
<div className="pt-1">
<a
href="https://luna.spaceistyping.com"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/5 px-5 py-2.5 text-sm font-semibold text-white transition-all duration-300 hover:border-white/30 hover:bg-white/10"
>
Visit Luna
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M14 5l7 7m0 0l-7 7m7-7H3" />
</svg>
</a>
</div>
</div>
</div>
</section>
);
}
-63
View File
@@ -1,63 +0,0 @@
"use client";
import { ExperienceCard } from "@/components/ExperienceCard";
import type { Experience } from "../types";
const ENABLE_SKILLS_EXPERIENCE = true;
interface SkillsExperienceProps {
experiences: Experience[];
onSelectExperience: (experience: Experience) => void;
}
export function SkillsExperience({
experiences,
onSelectExperience,
}: SkillsExperienceProps) {
if (!ENABLE_SKILLS_EXPERIENCE) {
return null;
}
const groupedExperiences = experiences.reduce(
(acc, exp) => {
if (!acc[exp.type]) acc[exp.type] = [];
acc[exp.type].push(exp);
return acc;
},
{} as Record<string, Experience[]>,
);
return (
<section className="w-full max-w-6xl mx-auto space-y-8 px-4 pb-20 sm:space-y-12">
<div className="text-center space-y-4">
<h2 className="text-2xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-blue-400 to-purple-500 sm:text-3xl">
Skills & Experience
</h2>
<p className="text-sm text-gray-400 sm:text-base">
<span className="text-red-400">
THIS IS MISSING A LOT OF THINGS, BE AWARE
</span>{" "}
- Things I've worked with over the years.
</p>
</div>
<div className="grid grid-cols-1 gap-8 md:grid-cols-2 sm:gap-12">
{Object.entries(groupedExperiences).map(([type, items]) => (
<div key={type} className="space-y-6">
<h3 className="border-l-4 border-blue-500 pl-4 text-lg font-semibold capitalize sm:text-xl">
{type}
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{items.map((exp, index) => (
<ExperienceCard
key={index}
experience={exp}
onClick={() => onSelectExperience(exp)}
/>
))}
</div>
</div>
))}
</div>
</section>
);
}
+25
View File
@@ -0,0 +1,25 @@
"use client";
import { GitBranch } from "lucide-react";
import { useProfile } from "../context/ProfileContext";
export function Stats() {
const { projects, loading } = useProfile();
return (
<section className="mx-auto w-full max-w-5xl px-4">
<div className="grid grid-cols-1 gap-4 sm:max-w-xs">
<div className="rounded-2xl border border-white/10 bg-white/[0.03] p-5">
<div className="mb-3 flex items-center justify-between">
<span className="text-sm text-gray-400">Projects</span>
<GitBranch className="h-4 w-4 text-gray-500" />
</div>
<p className="text-3xl font-bold text-white">
{loading ? "—" : projects.length}
</p>
<p className="mt-1 text-xs text-gray-500">and many more private ones</p>
</div>
</div>
</section>
);
}
-113
View File
@@ -1,113 +0,0 @@
"use client";
const ENABLE_TECH_STACK = false;
const techStack = [
{
title: "Monitoring & Testing",
items: [
{ name: "GitHub Actions", description: "CI/CD & automation" },
{ name: "Vitest", description: "Unit & integration testing" },
{ name: "Sentry", description: "Error monitoring" },
{ name: "Umami.is", description: "Privacy-friendly analytics" },
],
},
{
title: "Software Stack",
items: [
{ name: "Next.js / React", description: "Frontend & SSR framework" },
{ name: "Tailwind CSS", description: "Utility-first styling" },
{ name: "TypeScript", description: "Typed JavaScript" },
{
name: "Custom Library & Webserver API",
description: "Backend & shared logic",
},
],
},
{
title: "Virtualisation",
items: [
{ name: "Proxmox", description: "Hypervisor & VM management" },
{ name: "Docker", description: "Containerization" },
{ name: "LXC", description: "Lightweight containers" },
],
},
{
title: "OS",
items: [
{ name: "Ubuntu", description: "Linux distribution" },
{
name: "Windows Server 2025",
description: "Active Directory & domain controller",
},
{ name: "Debian", description: "Linux distribution" },
],
},
{
title: "Servers",
items: [
{ name: "KVMs from datalix.de", description: "Cloud provider" },
{ name: "Home server", description: "Self-hosted option" },
{ name: "AWS", description: "Cloud computing" }
],
},
];
export function TechStack() {
if (!ENABLE_TECH_STACK) {
// Disabled
return (
<section className="w-full max-w-4xl mx-auto px-4 space-y-10 mt-16 mb-24">
<div className="text-center space-y-3">
<h2 className="text-3xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-cyan-400 to-blue-500 sm:text-4xl">
Tech Stack
</h2>
<p className="mx-auto max-w-2xl text-sm text-gray-400 md:text-base">
This section is currently disabled. It may be re-enabled in the future, but for now, it's hidden.
</p>
</div>
</section>
)
}
return (
<section className="w-full max-w-4xl mx-auto px-4 space-y-10 mt-16 mb-24">
<div className="text-center space-y-3">
<h2 className="text-3xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-cyan-400 to-blue-500 sm:text-4xl">
Tech Stack
</h2>
<p className="mx-auto max-w-2xl text-sm text-gray-400 md:text-base">
A overview of the tools and technologies I use to build and host my applications.
</p>
</div>
<div className="max-w-2xl mx-auto space-y-4">
{techStack.map((layer) => (
<div
key={layer.title}
className="p-5 rounded-2xl bg-gradient-to-br from-cyan-500/[0.07] to-blue-500/[0.03] backdrop-blur-md border border-cyan-500/10 hover:border-cyan-500/30 transition-all duration-500"
>
<h3 className="text-lg font-bold text-white mb-3 flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-cyan-500" />
{layer.title}
</h3>
<ul className="space-y-3">
{layer.items.map((item) => (
<li
key={item.name}
className="flex flex-col group cursor-default"
>
<span className="text-sm font-semibold text-cyan-100 group-hover:text-cyan-400 transition-colors">
{item.name}
</span>
<span className="text-[11px] text-gray-500 font-normal leading-relaxed">
{item.description}
</span>
</li>
))}
</ul>
</div>
))}
</div>
</section>
);
}
-39
View File
@@ -1,39 +0,0 @@
"use client";
export function Uptime() {
return (
<section className="w-full max-w-6xl mx-auto space-y-8 px-4 sm:space-y-10">
<div className="text-center space-y-4">
<h2 className="text-2xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-blue-400 to-purple-500 sm:text-3xl">
Downtime
</h2>
<p className="mx-auto max-w-2xl text-sm text-gray-400 sm:text-base">
I love selfhosting stuff, can you tell?
</p>
</div>
<div className="mx-auto max-w-3xl">
<div className="w-full rounded-2xl border border-white/6 bg-gradient-to-br from-gray-800/20 to-transparent p-5 backdrop-blur-sm sm:p-6">
<h3 className="mb-4 text-center text-xl font-semibold text-white sm:text-2xl">
Github vs HomeLab Uptime
</h3>
<div className="flex flex-col items-center justify-between gap-6 sm:flex-row">
<div className="flex-1 text-center">
<img src="/gh_down.png" alt="GitHub downtime" className="mx-auto h-24 object-contain sm:h-28" />
<h4 className="mt-3 font-medium text-white">GitHub</h4>
<p className="text-gray-400 mt-1">Goes down more.</p>
</div>
<div className="h-px w-24 bg-white/6 sm:h-24 sm:w-px" />
<div className="flex-1 text-center">
<img src="/homelab_down.png" alt="HomeLab" className="mx-auto h-24 object-contain sm:h-28" />
<h4 className="mt-3 font-medium text-white">HomeLab</h4>
<p className="text-gray-400 mt-1">Goes down less.</p>
</div>
</div>
</div>
</div>
</section>
);
}
+102 -121
View File
@@ -1,133 +1,114 @@
"use client"; "use client";
import type { RealWork } from "../types"; import type { WorkExperience as WorkExperienceType } from "../types";
interface WorkExperienceProps { interface WorkExperienceProps {
realWork: RealWork[]; experiences: WorkExperienceType[];
} }
function getWorkDateTimestamp(dateValue?: string) { function formatMonth(iso: string) {
if (!dateValue) return Number.NEGATIVE_INFINITY; const d = new Date(iso);
const timestamp = Date.parse(dateValue); if (Number.isNaN(d.getTime())) return iso;
return Number.isNaN(timestamp) ? Number.NEGATIVE_INFINITY : timestamp; return d.toLocaleDateString("en-US", { year: "numeric", month: "short", timeZone: "UTC" });
} }
function getSortTimestamp(entry: RealWork) { /** "X yr Y mo" duration between two dates (toDate null = now). */
if (!entry.until || entry.until.trim().toLowerCase() === "present") { function formatDuration(fromIso: string, toIso?: string | null) {
return Number.POSITIVE_INFINITY; const from = new Date(fromIso);
} const to = toIso ? new Date(toIso) : new Date();
const untilTimestamp = getWorkDateTimestamp(entry.until); if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime())) return "";
if (untilTimestamp !== Number.NEGATIVE_INFINITY) return untilTimestamp;
return getWorkDateTimestamp(entry.from); let months =
(to.getFullYear() - from.getFullYear()) * 12 + (to.getMonth() - from.getMonth());
if (to.getDate() >= from.getDate()) months += 1; // count the current/partial month
months = Math.max(months, 1);
const years = Math.floor(months / 12);
const rem = months % 12;
const parts: string[] = [];
if (years > 0) parts.push(`${years} yr`);
if (rem > 0) parts.push(`${rem} mo`);
return parts.join(" ");
} }
function formatWorkDate(dateValue: string) { export function WorkExperience({ experiences }: WorkExperienceProps) {
const yearMonthMatch = /^(\d{4})-(\d{2})$/.exec(dateValue.trim()); return (
if (yearMonthMatch) { <section className="w-full max-w-4xl mx-auto px-4 space-y-6 sm:space-y-8">
const year = Number.parseInt(yearMonthMatch[1], 10); <div className="text-center space-y-2">
const monthIndex = Number.parseInt(yearMonthMatch[2], 10) - 1; <h2 className="text-2xl font-semibold text-white sm:text-3xl">Work Experience</h2>
if (monthIndex >= 0 && monthIndex <= 11) { <p className="mx-auto max-w-2xl text-sm text-gray-500 sm:text-base">
return new Date(Date.UTC(year, monthIndex, 1)).toLocaleDateString("en-US", { Actual work that i did at actual companies.
year: "numeric", </p>
month: "short", </div>
timeZone: "UTC",
});
}
}
const timestamp = Date.parse(dateValue); {experiences.length === 0 ? (
if (Number.isNaN(timestamp)) return dateValue; <div className="rounded-2xl border border-white/10 bg-white/[0.03] p-5 text-center sm:p-6 md:p-8">
return new Date(timestamp).toLocaleDateString("en-US", { <p className="text-gray-400">No work experience entries available yet.</p>
year: "numeric", </div>
month: "short", ) : (
}); <div className="space-y-4">
} {experiences.map((entry) => (
<div
export function WorkExperience({ realWork }: WorkExperienceProps) { key={entry.id}
const sortedRealWork = [...realWork].sort( className="w-full space-y-4 rounded-2xl border border-white/10 bg-white/[0.03] p-5 transition-colors hover:border-white/20 sm:space-y-5 sm:p-6 md:p-8"
(a, b) => >
getSortTimestamp(b) - getSortTimestamp(a) || <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-2">
getWorkDateTimestamp(b.from) - getWorkDateTimestamp(a.from), <div className="flex items-start gap-3">
); {entry.iconUrl ? (
<img
return ( src={entry.iconUrl}
<section className="w-full max-w-4xl mx-auto px-4 space-y-6 sm:space-y-8"> alt=""
<div className="text-center space-y-2"> className="mt-1 h-10 w-10 shrink-0 rounded-lg object-contain"
<h2 className="text-2xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-cyan-400 to-blue-500 sm:text-3xl"> />
Work Experience ) : null}
</h2> <div>
<p className="mx-auto max-w-2xl text-sm text-gray-400 sm:text-base"> <h3 className="text-xl font-semibold text-white sm:text-2xl">
Actual work that i did at actual companies. {entry.company}
</p> </h3>
</div> <p className="mt-1 text-sm font-medium text-gray-300">{entry.role}</p>
<p className="mt-1 text-xs font-medium uppercase tracking-wide text-gray-500">
{realWork.length === 0 ? ( {formatMonth(entry.fromDate)} {" "}
<div className="rounded-2xl border border-cyan-500/20 bg-gradient-to-br from-cyan-500/10 to-blue-500/5 p-5 text-center backdrop-blur-sm sm:p-6 md:p-8"> {entry.toDate ? formatMonth(entry.toDate) : "Present"}
<p className="text-gray-300">No work experience entries available yet.</p> <span className="ml-2 text-gray-600">
</div> · {formatDuration(entry.fromDate, entry.toDate)}
) : ( </span>
<div className="space-y-4"> </p>
{sortedRealWork.map((entry, index) => ( </div>
<div </div>
key={`${entry.company}-${index}`} {entry.url ? (
className="w-full space-y-4 rounded-2xl border border-cyan-500/20 bg-gradient-to-br from-cyan-500/10 to-blue-500/5 p-5 backdrop-blur-sm sm:space-y-5 sm:p-6 md:p-8" <a
> href={entry.url}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-2"> target="_blank"
<div> rel="noreferrer"
<h3 className="text-xl font-semibold text-white sm:text-2xl">{entry.company}</h3> className="inline-flex items-center gap-2 self-start rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-sm font-medium text-gray-300 transition-colors hover:border-white/25 hover:bg-white/10 sm:self-auto"
{entry.role ? ( >
<p className="text-sm font-medium text-cyan-200/90 mt-1">{entry.role}</p> <span>{new URL(entry.url).hostname.replace(/^www\./, "")}</span>
) : null} <svg viewBox="0 0 20 20" fill="currentColor" aria-hidden className="h-4 w-4">
{entry.from || entry.until ? ( <path d="M5 6.75A1.75 1.75 0 0 1 6.75 5h2a.75.75 0 0 0 0-1.5h-2A3.25 3.25 0 0 0 3.5 6.75v6.5A3.25 3.25 0 0 0 6.75 16.5h6.5a3.25 3.25 0 0 0 3.25-3.25v-2a.75.75 0 0 0-1.5 0v2A1.75 1.75 0 0 1 13.25 15h-6.5A1.75 1.75 0 0 1 5 13.25v-6.5Z" />
<p className="text-xs font-medium uppercase tracking-wide text-cyan-300/80 mt-1"> <path d="M11.25 3.5a.75.75 0 0 0 0 1.5h2.69l-4.72 4.72a.75.75 0 1 0 1.06 1.06L15 6.06v2.69a.75.75 0 0 0 1.5 0V3.5h-5.25Z" />
{entry.from ? formatWorkDate(entry.from) : "Unknown"} -{" "} </svg>
{entry.until </a>
? entry.until.trim().toLowerCase() === "present" ) : null}
? "Present" </div>
: formatWorkDate(entry.until)
: "Present"} <p className="leading-relaxed text-gray-300">{entry.summary}</p>
</p>
) : null} {entry.tags.length > 0 ? (
</div> <div className="flex flex-wrap gap-2">
{entry.url ? ( {entry.tags.map((tag) => (
<a <span
href={entry.url} key={`${entry.id}-${tag}`}
target="_blank" className="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs font-medium text-gray-300"
rel="noreferrer" >
className="inline-flex items-center gap-2 self-start sm:self-auto rounded-full border border-cyan-400/30 bg-cyan-400/10 px-3 py-1.5 text-sm font-medium text-cyan-200 hover:bg-cyan-400/20 hover:border-cyan-300/50 transition-colors" {tag}
> </span>
<span>{new URL(entry.url).hostname.replace(/^www\./, "")}</span> ))}
<svg </div>
viewBox="0 0 20 20" ) : null}
fill="currentColor" </div>
aria-hidden="true" ))}
className="h-4 w-4" </div>
> )}
<path d="M5 6.75A1.75 1.75 0 0 1 6.75 5h2a.75.75 0 0 0 0-1.5h-2A3.25 3.25 0 0 0 3.5 6.75v6.5A3.25 3.25 0 0 0 6.75 16.5h6.5a3.25 3.25 0 0 0 3.25-3.25v-2a.75.75 0 0 0-1.5 0v2A1.75 1.75 0 0 1 13.25 15h-6.5A1.75 1.75 0 0 1 5 13.25v-6.5Z" /> </section>
<path d="M11.25 3.5a.75.75 0 0 0 0 1.5h2.69l-4.72 4.72a.75.75 0 1 0 1.06 1.06L15 6.06v2.69a.75.75 0 0 0 1.5 0V3.5h-5.25Z" /> );
</svg>
</a>
) : null}
</div>
<p className="text-gray-300 leading-relaxed">{entry.summary}</p>
{entry.tags && entry.tags.length > 0 ? (
<div className="flex flex-wrap gap-2">
{entry.tags.map((tag) => (
<span
key={`${entry.company}-${tag}`}
className="px-3 py-1 rounded-full text-xs font-semibold bg-cyan-500/20 text-cyan-200 border border-cyan-500/30"
>
{tag}
</span>
))}
</div>
) : null}
</div>
))}
</div>
)}
</section>
);
} }
+49 -43
View File
@@ -1,53 +1,59 @@
export interface MiniProject { // Shapes returned by our own API endpoints (dates are ISO strings over JSON).
title: string;
description: string;
image?: string;
github?: string;
reproduction?: string;
why: string;
note?: {
color: string;
content: string;
};
last_commit?: Date;
}
export interface Experience { export type ProjectSize = "Big" | "MediumSized" | "Small";
name: string;
type: "languages" | "software" | "plattforms" | "experience" | "other";
description: string;
image?: string;
learned_at?: string;
learned_from?: string;
learned_because?: string;
}
export interface Skill {
name: string;
description: string;
purpose: string;
what_it_solves: string;
link: string;
emojis?: string[];
tags?: string[];
}
export interface Project { export interface Project {
id: string;
name: string; name: string;
description: string; label: string;
image?: string; size: ProjectSize;
imageUrl?: string | null;
link: string; link: string;
open_source: false | { link: string }; linkIsDemo: boolean;
rounded?: boolean; sourceCode?: string | null;
last_commit?: Date; description: string;
why: string;
note?: string | null;
tags: string[];
loc?: number | null;
/** Resolved line-of-code count (manual `loc` or fetched from `locEndpoint`). */
resolvedLoc?: number | null;
createdAt?: string;
updatedAt?: string;
} }
export interface RealWork { export interface WorkExperience {
id: string;
company: string; company: string;
role?: string; role: string;
from?: string; fromDate: string;
until?: string; toDate?: string | null;
url?: string; url?: string | null;
iconUrl?: string | null;
summary: string; summary: string;
tags?: string[]; tags: string[];
sortIndex: number;
} }
export interface Affiliate {
id: string;
name: string;
link: string;
icon: string;
location: string;
provides: string[];
good: string[];
bad: string[];
sortIndex: number;
}
export const PROJECT_SIZES: ProjectSize[] = ["Big", "MediumSized", "Small"];
export const SIZE_LABELS: Record<ProjectSize, string> = {
Big: "Big",
MediumSized: "Medium Sized",
Small: "Small",
};
/** Contact email — only ever used inside mailto: links, never rendered as text. */
export const CONTACT_EMAIL = "paulwaehner923+profile@gmail.com";