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
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
- Hero section with typing intro
- Work experience and skills sections
- Uptime and activity panels
- Project and mini-project showcases
- Contact page and admin page
- Hero dev-card, live-count stat, and work-experience timeline
- Projects (served from our DB) with a `/projects` page and homepage teaser
- Luna section, DB-driven affiliates, and the SHSF Code Activity graph
- Admin CMS at `/admin` for projects, work experience, and affiliates
## 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
Install dependencies:
Install dependencies and generate the Prisma client:
```bash
pnpm install
pnpm prisma generate
```
Apply the schema to your database:
```bash
pnpm prisma migrate dev
```
Run the development server:
@@ -61,4 +89,6 @@ The app will be available on port `6756` via `docker-compose.yml`.
## Notes
- `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 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",
"scripts": {
"dev": "next dev -p 5173",
"build": "next build",
"build": "prisma generate && next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@danielgtmn/umami-react": "^1.1.6",
"@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0",
"clsx": "^2.1.1",
"dotenv": "^17.4.2",
"framer-motion": "^12.36.0",
"lucide-react": "^0.577.0",
"next": "16.1.6",
@@ -27,6 +30,7 @@
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"prisma": "^7.8.0",
"tailwindcss": "^4",
"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 { Navbar } from "../../components/Navbar";
import { Footer } from "../../sections/Footer";
import { CONTACT_EMAIL } from "../../types";
export default function ContactPage() {
return (
@@ -10,11 +11,11 @@ export default function ContactPage() {
<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="text-center space-y-4 w-full">
<h1 className="text-4xl font-extrabold 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>
<h1 className="text-4xl font-semibold text-white sm:text-5xl md:text-6xl">
Let&apos;s Connect
</h1>
<p className="mx-auto max-w-xl text-base text-gray-400 sm:text-lg md:text-xl">
Whatever you've got in mind, I'm just a few clicks away.
<p className="mx-auto max-w-xl text-base text-gray-500 sm:text-lg md:text-xl">
Whatever you&apos;ve got in mind, I&apos;m just a few clicks away.
</p>
</div>
@@ -22,24 +23,22 @@ export default function ContactPage() {
<ContactSection />
</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="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">
<div className="grid w-full max-w-2xl grid-cols-1 gap-4 text-center sm:grid-cols-2 sm:gap-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>
<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">
space@reversed.dev
<p className="text-sm text-gray-500 underline underline-offset-4 decoration-white/20 group-hover:decoration-white/60 transition-colors">
Send me an email
</p>
</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-purple-500/30 sm:p-6">
</a>
<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>
<h3 className="font-bold mb-1">Discord</h3>
<p className="text-sm text-gray-500">@getspaced</p>
</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>
<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; }
:root {
--background: #020205;
--background: #0a0a0a;
--foreground: #ffffff;
}
@theme inline {
--color-background: var(--background);
--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 {
background: var(--background);
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 localFont from "next/font/local";
import "@/app/globals.css";
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 = {
title: "Space's portfolio",
description: "A developer from Germany building things.",
title: "Paul W. — Portfolio",
description:
"Full-stack developer & open-source author. Building server infrastructure, developer tools, and web applications.",
icons: {
icon: "/favicon.png",
},
@@ -16,14 +34,10 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en">
<html lang="en" className={`${satoshi.variable} ${clashDisplay.variable}`}>
<body className="antialiased">
<ClientProviders>
<div className="relative min-h-screen bg-[#020205] text-white selection:bg-blue-500/30 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>
<div className="relative min-h-screen bg-[#0a0a0a] text-white selection:bg-white/20 overflow-x-hidden">
{children}
</div>
</ClientProviders>
+19 -121
View File
@@ -1,134 +1,32 @@
"use client";
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 { 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 { 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() {
const {
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);
}, []);
const { experiences } = useProfile();
return (
<>
{ENABLE_PAGE_ANIMATION && (
<TypingRoomIntro active={showTypingIntro} onFinish={handleIntroFinish} />
)}
{!showTypingIntro && (
<>
<Navbar />
<div className="space-y-20 pb-16 pt-16 sm:space-y-24 sm:pb-20 sm:pt-20">
<Hero
glowColor={glowColor}
borderStatus={borderStatus}
displayMessage={displayMessage}
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)}
/>
)}
</>
)}
<Navbar />
<div className="space-y-20 pb-16 pt-28 sm:space-y-24 sm:pb-20 sm:pt-32">
<Hero />
<Stats />
<WorkExperience experiences={experiences} />
<FeaturedProjects />
<Luna />
<Affiliates />
<Activity />
</div>
<Footer />
</>
);
}
+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 = [
{ label: "Home", path: "/" },
{ label: "Projects", path: "/projects" },
{ label: "Connect", path: "/contact" },
];
@@ -18,7 +19,7 @@ export function Navbar() {
};
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">
{navItems.map((item) => (
<Link
+68 -36
View File
@@ -1,68 +1,100 @@
"use client";
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 }) {
const loc = project.resolvedLoc ?? project.loc ?? null;
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">
{project.image && (
<div className="relative mb-4 flex aspect-video items-center justify-center overflow-hidden rounded-xl bg-black/20 sm:mb-6">
<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="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.imageUrl ? (
// Image shown in full — no overlays over it.
<div className="mb-4 flex items-center justify-center overflow-hidden rounded-xl bg-black/30 p-3">
<img
src={project.image}
src={project.imageUrl}
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>
)}
) : null}
<h3 className="mb-2 text-lg font-bold text-white transition-colors group-hover:text-purple-400 sm:text-xl">
{project.name}
</h3>
<p className="mb-5 flex-grow text-sm leading-relaxed text-gray-400 sm:mb-6">
{project.description}
</p>
{project.last_commit && (
<p className="mb-2 text-xs text-gray-400">
Last commit: {new Date(project.last_commit).toLocaleDateString()}
<div className="mb-2 flex flex-wrap items-center gap-2">
<h3 className="text-lg font-semibold text-white sm:text-xl">{project.name}</h3>
<span className={`rounded-full border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide ${SIZE_STYLES[project.size]}`}>
{SIZE_LABELS[project.size]}
</span>
{project.label ? (
<span className="rounded-full border border-white/10 bg-white/5 px-2 py-0.5 text-[10px] font-medium text-gray-300">
{project.label}
</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>
)}
) : 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">
<a
href={project.link + "?utm_source=portfolio&ref=space"}
href={project.link}
target="_blank"
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"
>
Visit
<svg
className="w-3 h-3"
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"
/>
{project.linkIsDemo ? "Live Demo" : "Visit"}
<svg className="h-3 w-3" 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>
</a>
{project.open_source && (
{project.sourceCode ? (
<a
href={project.open_source.link}
href={project.sourceCode}
target="_blank"
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"
>
<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" />
</svg>
</a>
)}
) : null}
</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";
import React, { createContext, useContext, useEffect, useState, useMemo } from "react";
import type { Experience, MiniProject, Project, RealWork, Skill } from "../types";
import React, { createContext, useContext, useEffect, useState } from "react";
import type { Affiliate, Project, WorkExperience } from "../types";
interface ProfileContextType {
status: string;
statusMessage: string;
borderStatus: string;
glowColor: string;
displayMessage: string;
isScrambling: boolean;
rotatingMessages: string[];
projects: Project[];
miniProjects: MiniProject[];
experiences: Experience[];
realWork: RealWork[];
skills: Skill[];
selectedMiniProject: MiniProject | null;
setSelectedMiniProject: (p: MiniProject | null) => void;
selectedExperience: Experience | null;
setSelectedExperience: (e: Experience | null) => void;
experiences: WorkExperience[];
affiliates: Affiliate[];
loading: boolean;
}
const ProfileContext = createContext<ProfileContextType | undefined>(undefined);
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 [miniProjects, setMiniProjects] = useState<MiniProject[]>([]);
const [experiences, setExperiences] = useState<Experience[]>([]);
const [realWork, setRealWork] = useState<RealWork[]>([]);
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!@#$%^&*()-_=+[]{}|;:',.<>?/`~" +
"?????????????????????????";
const [experiences, setExperiences] = useState<WorkExperience[]>([]);
const [affiliates, setAffiliates] = useState<Affiliate[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (status === "online") {
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]);
let cancelled = false;
useEffect(() => {
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 () => {
async function load<T>(url: string, set: (v: T[]) => void) {
try {
const res = await fetch("https://shsf-api.reversed.dev/api/exec/4/e942538c-caa1-49d1-8953-dfab1e62f8cb/real_work");
if (res.ok) setRealWork(await res.json());
} catch { setRealWork([]); }
};
fetch("https://shsf-api.reversed.dev/api/exec/4/e942538c-caa1-49d1-8953-dfab1e62f8cb/projects")
.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([]));
const res = await fetch(url);
if (!res.ok) return;
const json = await res.json();
if (!cancelled) set(Array.isArray(json) ? json : []);
} catch {
/* leave empty on failure */
}
}
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(() => {
const targetMessage = rotatingMessages[messageIndex];
if (isScrambling) {
let iteration = 0;
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>;
return (
<ProfileContext.Provider value={{ projects, experiences, affiliates, loading }}>
{children}
</ProfileContext.Provider>
);
}
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 (
<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">
<h2 className="text-2xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-blue-400 to-purple-500 sm:text-3xl">
Code Activity
</h2>
<h2 className="text-2xl font-semibold text-white sm:text-3xl">Code Activity</h2>
<p className="mx-auto max-w-2xl text-sm text-gray-400 sm:text-base">
Today, i've already been coding for
<span className="font-mono text-base text-green-400 ml-1">
@@ -41,7 +39,7 @@ export function Activity() {
</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="overflow-x-auto rounded-2xl border border-white/10 bg-[#0a0a12]">
<div className="overflow-x-auto rounded-2xl bg-[#0d1117]">
<img
src={activitySvg}
alt="Git activity graph"
+64 -91
View File
@@ -1,121 +1,94 @@
"use client";
type Affiliate = {
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"],
},
];
import { useProfile } from "../context/ProfileContext";
export function Affiliates() {
const { affiliates } = useProfile();
if (affiliates.length === 0) return null;
return (
<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">
<h2 className="text-2xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-blue-400 to-purple-500 sm:text-3xl">
Affiliates
</h2>
<p className="mx-auto max-w-2xl text-sm text-gray-400 sm:text-base">
Looking for hosting? I recommend checking out Datalix!
<h2 className="text-2xl font-semibold text-white sm:text-3xl">Affiliates</h2>
<p className="mx-auto max-w-2xl text-sm text-gray-500 sm:text-base">
Hosting and services I actually use and recommend.
</p>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 sm:gap-5">
{affiliates.map((affiliate) => (
<a
key={affiliate.name}
key={affiliate.id}
href={affiliate.link}
target="_blank"
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">
<img
src={affiliate.icon}
alt={affiliate.name}
className="h-7 max-w-[130px] w-auto object-contain rounded"
/>
<div className="mb-3 flex flex-col items-center gap-2 text-center">
{affiliate.icon ? (
<img
src={affiliate.icon}
alt={affiliate.name}
className="h-7 w-auto max-w-[130px] rounded object-contain"
/>
) : null}
<div>
<h3 className="text-lg font-semibold text-white">
{affiliate.name}
</h3>
<p className="text-[10px] uppercase tracking-widest text-gray-500 font-medium leading-none mt-1">
<h3 className="text-lg font-semibold text-white">{affiliate.name}</h3>
<p className="mt-1 text-[10px] font-medium uppercase leading-none tracking-widest text-gray-500">
{affiliate.location}
</p>
</div>
</div>
<div className="space-y-3">
<div>
<p className="text-xs font-semibold text-blue-300 mb-1.5">
Provides
</p>
<div className="flex flex-wrap gap-2">
{affiliate.provides.map((item) => (
<span
key={item}
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}
</span>
))}
{affiliate.provides.length > 0 ? (
<div>
<p className="mb-1.5 text-xs font-semibold text-gray-400">Provides</p>
<div className="flex flex-wrap gap-2">
{affiliate.provides.map((item) => (
<span
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"
>
{item}
</span>
))}
</div>
</div>
</div>
<div>
<p className="text-xs font-semibold text-emerald-300 mb-1.5">
Good
</p>
<div className="flex flex-wrap gap-2">
{affiliate.good.map((item) => (
<span
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"
>
<span className="font-bold">+</span>
{item}
</span>
))}
) : null}
{affiliate.good.length > 0 ? (
<div>
<p className="mb-1.5 text-xs font-semibold text-emerald-300">Good</p>
<div className="flex flex-wrap gap-2">
{affiliate.good.map((item) => (
<span
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"
>
<span className="font-bold">+</span>
{item}
</span>
))}
</div>
</div>
</div>
<div>
<p className="text-xs font-semibold text-rose-300 mb-1.5">
Bad
</p>
<div className="flex flex-wrap gap-2">
{affiliate.bad.map((item) => (
<span
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"
>
<span className="font-bold">-</span>
{item}
</span>
))}
) : null}
{affiliate.bad.length > 0 ? (
<div>
<p className="mb-1.5 text-xs font-semibold text-rose-300">Bad</p>
<div className="flex flex-wrap gap-2">
{affiliate.bad.map((item) => (
<span
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"
>
<span className="font-bold">-</span>
{item}
</span>
))}
</div>
</div>
</div>
) : null}
</div>
</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";
import { CONTACT_EMAIL } from "../types";
export function Contact() {
return (
<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">
Want to talk?
</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">
<h2 className="text-2xl font-semibold text-white sm:text-3xl">Want to talk?</h2>
<div className="rounded-3xl border border-white/10 bg-white/[0.03] p-6 sm:p-8">
<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.
</p>
<div className="flex flex-col justify-center gap-3 sm:flex-row sm:gap-4">
<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"
>
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 { Github, Mail, MessageSquare } from "lucide-react";
import { CONTACT_EMAIL } from "../types";
export function Footer() {
const currentYear = new Date().getFullYear();
@@ -29,12 +30,10 @@ export function Footer() {
<Github className="w-5 h-5" />
</a>
<a
href="mailto:space@reversed.dev"
href={`mailto:${CONTACT_EMAIL}`}
className="hover:text-purple-400 transition-colors"
aria-label="Email"
onClick={() =>
handleLinkClick("Email", "mailto:space@reversed.dev")
}
onClick={() => handleLinkClick("Email", "mailto")}
>
<Mail className="w-5 h-5" />
</a>
@@ -58,7 +57,7 @@ export function Footer() {
{/* Branding & Attribution */}
<div className="flex flex-col items-center gap-2 text-center">
<p className="text-gray-500 text-xs tracking-wide">
&copy; {currentYear} &bull; Space-Banane
&copy; {currentYear} &bull; Paul W.
</p>
<div className="flex items-center gap-3 text-[10px] text-gray-600 uppercase tracking-[0.2em]">
<a
+125 -138
View File
@@ -1,32 +1,62 @@
import { useState, useRef } from "react";
import { useRouter } from "next/navigation";
import { TimezoneClockBlock } from "../components/TimezoneClockBlock";
"use client";
export function Hero({
glowColor,
borderStatus,
displayMessage,
rotatingMessages,
statusMessage,
oldUsernames,
timeGapWarningThreshold = 5,
}: {
glowColor: string;
borderStatus: string;
displayMessage: string;
rotatingMessages: string[];
statusMessage: string;
oldUsernames: string[];
timeGapWarningThreshold?: number;
}) {
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { Mail } from "lucide-react";
import { CONTACT_EMAIL } from "../types";
const HANDLE = "getspaced";
// Discord presence via SHSF (drives the avatar ring + name dot colour).
const STATUS_URL =
"https://shsf.reversed.dev/api/exec/6/c084ec4a-1b20-491e-ab2e-67c5fa8881e6";
type Presence = "online" | "idle" | "dnd" | "offline";
const PRESENCE: Record<Presence, { ring: string; glow: string; dot: string; label: string }> = {
online: {
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 [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 lastDir = useRef<"left" | "right" | null>(null);
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 currentTime = Date.now();
@@ -35,22 +65,19 @@ export function Hero({
const velocity = Math.abs(deltaX) / (currentTime - lastTime.current || 1);
const direction = deltaX > 0 ? "right" : "left";
// Thresholds: velocity > 2 (approx 2px/ms) and changed direction
if (velocity > 2 && direction !== lastDir.current) {
setMovementCount((prev) => {
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;
});
shakeCount.current += 1;
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);
resetTimeout.current = setTimeout(() => setMovementCount(0), 1000);
resetTimeout.current = setTimeout(() => {
shakeCount.current = 0;
}, 1000);
}
}
@@ -58,141 +85,101 @@ export function Hero({
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 (
<section
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"
data-old-usernames={oldUsernames.join(",")}
>
<div className="relative group" onMouseMove={handleMouseMove}>
<div
className={`absolute -inset-1 rounded-full blur opacity-75 transition duration-500`}
style={{ backgroundColor: glowColor }}
></div>
<div
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}`}
>
<img
src="https://cdn.reversed.dev/pictures/20250405_120402.png"
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>
<section className="mx-auto w-full max-w-5xl px-4">
<div className="flex flex-col-reverse items-center gap-8 sm:flex-row sm:items-start sm:justify-between sm:gap-10">
{/* Left: identity */}
<div className="flex-1 space-y-5 text-center sm:text-left">
<div className="space-y-2">
<h1 className="flex items-center justify-center gap-3 text-4xl font-extrabold text-white sm:justify-start sm:text-5xl">
Paul&nbsp;W.
<span
className={`inline-block h-3 w-3 rounded-full transition-colors duration-500 ${style.dot}`}
title={style.label}
aria-label={style.label}
/>
</h1>
<p className="font-mono text-sm text-gray-500">{HANDLE}</p>
</div>
</div>
{/* Status Bubble (Right Side) */}
<div className="absolute -right-4 top-8 hidden translate-x-full md:block">
<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">
{/* 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 className="mx-auto max-w-xl text-base leading-relaxed text-gray-400 sm:mx-0 sm:text-lg">
Full-stack developer &amp; open-source author. Building server
infrastructure, developer tools, and web applications.
</p>
</div>
</div>
{/* Mobile: Discord status + message (hidden on md+ where bubbles appear) */}
<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">
<div className="flex flex-wrap items-center justify-center gap-3 sm:justify-start">
<a
href="https://luna.spaceistyping.com"
target="_blank"
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"
href={`mailto:${CONTACT_EMAIL}`}
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"
>
<div className="w-3 h-3 rounded-full bg-purple-500" />
<span className="text-sm font-medium text-gray-300 group-hover:text-white transition-colors">
<span className="text-purple-400 font-semibold">Luna</span>
</span>
<Mail className="h-4 w-4" />
Contact
</a>
<a
href="https://github.com/Space-Banane"
target="_blank"
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"
>
<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" />
</svg>
GitHub
</a>
<a
href="https://gitea.reversed.dev/space"
target="_blank"
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"
>
<img
src="https://gitea.reversed.dev/assets/img/logo.svg"
alt="Gitea"
className="w-6 h-6"
alt=""
className="h-5 w-5"
/>
Gitea
</a>
</div>
<p className="text-xs uppercase tracking-[0.2em] text-gray-600">
<span className="text-gray-500"></span> Europe/Berlin
</p>
</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>
</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";
import type { RealWork } from "../types";
import type { WorkExperience as WorkExperienceType } from "../types";
interface WorkExperienceProps {
realWork: RealWork[];
experiences: WorkExperienceType[];
}
function getWorkDateTimestamp(dateValue?: string) {
if (!dateValue) return Number.NEGATIVE_INFINITY;
const timestamp = Date.parse(dateValue);
return Number.isNaN(timestamp) ? Number.NEGATIVE_INFINITY : timestamp;
function formatMonth(iso: string) {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleDateString("en-US", { year: "numeric", month: "short", timeZone: "UTC" });
}
function getSortTimestamp(entry: RealWork) {
if (!entry.until || entry.until.trim().toLowerCase() === "present") {
return Number.POSITIVE_INFINITY;
}
const untilTimestamp = getWorkDateTimestamp(entry.until);
if (untilTimestamp !== Number.NEGATIVE_INFINITY) return untilTimestamp;
return getWorkDateTimestamp(entry.from);
/** "X yr Y mo" duration between two dates (toDate null = now). */
function formatDuration(fromIso: string, toIso?: string | null) {
const from = new Date(fromIso);
const to = toIso ? new Date(toIso) : new Date();
if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime())) return "";
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) {
const yearMonthMatch = /^(\d{4})-(\d{2})$/.exec(dateValue.trim());
if (yearMonthMatch) {
const year = Number.parseInt(yearMonthMatch[1], 10);
const monthIndex = Number.parseInt(yearMonthMatch[2], 10) - 1;
if (monthIndex >= 0 && monthIndex <= 11) {
return new Date(Date.UTC(year, monthIndex, 1)).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
timeZone: "UTC",
});
}
}
export function WorkExperience({ experiences }: WorkExperienceProps) {
return (
<section className="w-full max-w-4xl mx-auto px-4 space-y-6 sm:space-y-8">
<div className="text-center space-y-2">
<h2 className="text-2xl font-semibold text-white sm:text-3xl">Work Experience</h2>
<p className="mx-auto max-w-2xl text-sm text-gray-500 sm:text-base">
Actual work that i did at actual companies.
</p>
</div>
const timestamp = Date.parse(dateValue);
if (Number.isNaN(timestamp)) return dateValue;
return new Date(timestamp).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
});
}
export function WorkExperience({ realWork }: WorkExperienceProps) {
const sortedRealWork = [...realWork].sort(
(a, b) =>
getSortTimestamp(b) - getSortTimestamp(a) ||
getWorkDateTimestamp(b.from) - getWorkDateTimestamp(a.from),
);
return (
<section className="w-full max-w-4xl mx-auto px-4 space-y-6 sm:space-y-8">
<div className="text-center space-y-2">
<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
</h2>
<p className="mx-auto max-w-2xl text-sm text-gray-400 sm:text-base">
Actual work that i did at actual companies.
</p>
</div>
{realWork.length === 0 ? (
<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">
<p className="text-gray-300">No work experience entries available yet.</p>
</div>
) : (
<div className="space-y-4">
{sortedRealWork.map((entry, index) => (
<div
key={`${entry.company}-${index}`}
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"
>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-2">
<div>
<h3 className="text-xl font-semibold text-white sm:text-2xl">{entry.company}</h3>
{entry.role ? (
<p className="text-sm font-medium text-cyan-200/90 mt-1">{entry.role}</p>
) : null}
{entry.from || entry.until ? (
<p className="text-xs font-medium uppercase tracking-wide text-cyan-300/80 mt-1">
{entry.from ? formatWorkDate(entry.from) : "Unknown"} -{" "}
{entry.until
? entry.until.trim().toLowerCase() === "present"
? "Present"
: formatWorkDate(entry.until)
: "Present"}
</p>
) : null}
</div>
{entry.url ? (
<a
href={entry.url}
target="_blank"
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"
>
<span>{new URL(entry.url).hostname.replace(/^www\./, "")}</span>
<svg
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
className="h-4 w-4"
>
<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" />
<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>
);
{experiences.length === 0 ? (
<div className="rounded-2xl border border-white/10 bg-white/[0.03] p-5 text-center sm:p-6 md:p-8">
<p className="text-gray-400">No work experience entries available yet.</p>
</div>
) : (
<div className="space-y-4">
{experiences.map((entry) => (
<div
key={entry.id}
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"
>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-2">
<div className="flex items-start gap-3">
{entry.iconUrl ? (
<img
src={entry.iconUrl}
alt=""
className="mt-1 h-10 w-10 shrink-0 rounded-lg object-contain"
/>
) : null}
<div>
<h3 className="text-xl font-semibold text-white sm:text-2xl">
{entry.company}
</h3>
<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">
{formatMonth(entry.fromDate)} {" "}
{entry.toDate ? formatMonth(entry.toDate) : "Present"}
<span className="ml-2 text-gray-600">
· {formatDuration(entry.fromDate, entry.toDate)}
</span>
</p>
</div>
</div>
{entry.url ? (
<a
href={entry.url}
target="_blank"
rel="noreferrer"
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"
>
<span>{new URL(entry.url).hostname.replace(/^www\./, "")}</span>
<svg viewBox="0 0 20 20" fill="currentColor" aria-hidden className="h-4 w-4">
<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" />
<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="leading-relaxed text-gray-300">{entry.summary}</p>
{entry.tags.length > 0 ? (
<div className="flex flex-wrap gap-2">
{entry.tags.map((tag) => (
<span
key={`${entry.id}-${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>
) : null}
</div>
))}
</div>
)}
</section>
);
}
+49 -43
View File
@@ -1,53 +1,59 @@
export interface MiniProject {
title: string;
description: string;
image?: string;
github?: string;
reproduction?: string;
why: string;
note?: {
color: string;
content: string;
};
last_commit?: Date;
}
// Shapes returned by our own API endpoints (dates are ISO strings over JSON).
export interface Experience {
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 type ProjectSize = "Big" | "MediumSized" | "Small";
export interface Project {
id: string;
name: string;
description: string;
image?: string;
label: string;
size: ProjectSize;
imageUrl?: string | null;
link: string;
open_source: false | { link: string };
rounded?: boolean;
last_commit?: Date;
linkIsDemo: boolean;
sourceCode?: string | null;
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;
role?: string;
from?: string;
until?: string;
url?: string;
role: string;
fromDate: string;
toDate?: string | null;
url?: string | null;
iconUrl?: string | null;
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";