Little rework
This commit is contained in:
+4
-1
@@ -41,4 +41,7 @@ yarn-error.log*
|
||||
next-env.d.ts
|
||||
|
||||
old/
|
||||
.next
|
||||
.next
|
||||
|
||||
# prisma generated client
|
||||
/src/generated
|
||||
Binary file not shown.
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
Generated
+859
File diff suppressed because it is too large
Load Diff
@@ -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";
|
||||
@@ -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"
|
||||
@@ -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
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 });
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isAdmin } from "@/lib/auth";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({ authenticated: await isAdmin() });
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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'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've got in mind, I'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
@@ -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
@@ -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
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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 {};
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
© {currentYear} • Space-Banane
|
||||
© {currentYear} • Paul W.
|
||||
</p>
|
||||
<div className="flex items-center gap-3 text-[10px] text-gray-600 uppercase tracking-[0.2em]">
|
||||
<a
|
||||
|
||||
+125
-138
@@ -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 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 & 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 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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user