Compare commits

...

19 Commits

Author SHA1 Message Date
space 6552273ec0 Merge pull request 'Fix blog cover images on blog cards' (#3) from fix/blog-card-cover-images into main
Build Check / build (push) Successful in 29s
Build Check / push-image (push) Successful in 56s
Build Check / deploy-coolify (push) Successful in 7s
Reviewed-on: #3
2026-07-18 01:26:24 +02:00
luna c0c3658628 Fix blog card cover images
Build Check / build (pull_request) Successful in 29s
Build Check / push-image (pull_request) Has been skipped
Build Check / deploy-coolify (pull_request) Has been skipped
2026-07-17 23:19:35 +00:00
Space-Banane 589656e09b Update Dockerfile to simplify server startup command
Build Check / build (push) Successful in 46s
Build Check / push-image (push) Successful in 2m10s
Build Check / deploy-coolify (push) Successful in 7s
2026-07-18 01:16:48 +02:00
Space-Banane 7074f8d1d6 Update Dockerfile to use npx for Prisma migration command
Build Check / build (push) Successful in 36s
Build Check / push-image (push) Successful in 1m10s
Build Check / deploy-coolify (push) Successful in 8s
2026-07-18 01:13:03 +02:00
Space-Banane 7e38f89e37 Update Dockerfile to run database migrations before starting the server
Build Check / build (push) Successful in 33s
Build Check / push-image (push) Successful in 55s
Build Check / deploy-coolify (push) Successful in 7s
2026-07-18 01:08:07 +02:00
space 3a4dc01b90 Merge pull request 'Add blog authors support' (#2) from feat/blog-authors into main
Build Check / build (push) Successful in 29s
Build Check / push-image (push) Successful in 57s
Build Check / deploy-coolify (push) Successful in 7s
Reviewed-on: #2
2026-07-18 01:02:46 +02:00
luna 6df4983f74 Add blog authors support
Build Check / build (pull_request) Successful in 30s
Build Check / push-image (pull_request) Has been skipped
Build Check / deploy-coolify (pull_request) Has been skipped
2026-07-17 22:59:28 +00:00
space 7ae6aba990 Merge pull request 'Add blog CMS with view tracking' (#1) from feat/blog-cms into main
Build Check / build (push) Successful in 32s
Build Check / push-image (push) Successful in 58s
Build Check / deploy-coolify (push) Successful in 7s
Reviewed-on: #1
2026-07-18 00:28:24 +02:00
luna ffb57a9f34 Polish blog landing copy
Build Check / build (pull_request) Successful in 32s
Build Check / push-image (pull_request) Has been skipped
Build Check / deploy-coolify (pull_request) Has been skipped
2026-07-17 22:17:53 +00:00
luna 76b1c79104 Refine blog copy and remove seeded posts
Build Check / build (pull_request) Successful in 32s
Build Check / push-image (pull_request) Has been skipped
Build Check / deploy-coolify (pull_request) Has been skipped
2026-07-17 22:14:59 +00:00
luna 912e5edb81 Add blog CMS with tracked public posts
Build Check / build (pull_request) Successful in 34s
Build Check / push-image (pull_request) Has been skipped
Build Check / deploy-coolify (pull_request) Has been skipped
2026-07-17 22:02:25 +00:00
Space-Banane ef63164d58 fix: add DATABASE_URL environment variable in Dockerfile
Build Check / build (push) Successful in 30s
Build Check / push-image (push) Successful in 1m0s
Build Check / deploy-coolify (push) Successful in 7s
2026-07-16 23:45:30 +02:00
Space-Banane 5fea1541f8 fix: add DATABASE_URL environment variable to build job
Build Check / build (push) Successful in 29s
Build Check / push-image (push) Failing after 45s
Build Check / deploy-coolify (push) Has been skipped
2026-07-16 23:32:31 +02:00
Space-Banane dfbe14d284 Little rework
Build Check / build (push) Failing after 18s
Build Check / deploy-coolify (push) Has been skipped
Build Check / push-image (push) Has been skipped
2026-07-16 23:03:42 +02:00
Space-Banane 2045e95929 fix: remove unnecessary max-width classes from various components
Build Check / build (push) Successful in 45s
Build Check / push-image (push) Successful in 53s
Build Check / deploy-coolify (push) Successful in 6s
feat: add launch configuration for development environment
2026-07-08 18:55:33 +02:00
Space-Banane 1148f6f22a fix: update attribution text in Activity section & changed ports
Build Check / push-image (push) Successful in 2m11s
Build Check / deploy-coolify (push) Successful in 7s
Build Check / build (push) Successful in 48s
2026-06-04 23:01:50 +02:00
Space-Banane ffdf9dabdc fix. unbork contact page
Build Check / build (push) Successful in 57s
Build Check / push-image (push) Successful in 2m43s
Build Check / deploy-coolify (push) Successful in 6s
2026-06-03 16:01:11 +02:00
Space-Banane aebeed3964 Codex tried to make the site more responsive on mobile
Build Check / build (push) Successful in 48s
Build Check / push-image (push) Successful in 2m43s
Build Check / deploy-coolify (push) Successful in 7s
2026-06-03 15:58:29 +02:00
Space-Banane 40526e115e feat: Major update 2026-06-03 15:45:02 +02:00
76 changed files with 4828 additions and 2009 deletions
+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "portfolio-dev",
"runtimeExecutable": "pnpm",
"runtimeArgs": ["run", "dev"],
"port": 5173
}
]
}
+2
View File
@@ -9,6 +9,8 @@ on:
jobs:
build:
runs-on: ubuntu-latest
env:
DATABASE_URL: "postgresql://fake:fake@localhost:5432/fake?schema=public"
steps:
- name: Checkout repository
+3
View File
@@ -42,3 +42,6 @@ next-env.d.ts
old/
.next
# prisma generated client
/src/generated
Binary file not shown.
+1
View File
@@ -1,6 +1,7 @@
FROM node:24-alpine AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
ENV DATABASE_URL="postgresql://postgres:postgres@db:5432/postgres"
RUN corepack enable
FROM base AS deps
+39 -7
View File
@@ -4,18 +4,48 @@ 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
- Blog system with DB-backed markdown posts, admin editing, and view tracking
- Luna section, DB-driven affiliates, and the SHSF Code Activity graph
- Admin CMS at `/admin` for projects, blog posts, work experience, and affiliates
## Data layer
Content (projects, blog posts, work experience, affiliates) lives in **Postgres** and is
managed through Prisma + our own Next.js API routes:
- Public reads: `GET /api/projects`, `/api/blogs`, `/api/blogs/top`, `/api/experience`, `/api/affiliates`
- Admin writes: `POST/PUT/DELETE /api/admin/{projects,blogs,experience,affiliates}/…`
(cookie session issued by `POST /api/admin/login`)
- Blog content views increment when `/blogs/[slug]` or `GET /api/blogs/[slug]` is requested
- `/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:
@@ -24,7 +54,7 @@ Run the development server:
pnpm dev
```
Open [http://localhost:3000](http://localhost:3000) in your browser.
Open [http://localhost:5173](http://localhost:5173) in your browser.
## Production Build
@@ -61,4 +91,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.
+8 -2
View File
@@ -4,20 +4,25 @@
"private": true,
"packageManager": "pnpm@10.30.3",
"scripts": {
"dev": "next dev",
"build": "next build",
"dev": "next dev -p 5173",
"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",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.13.1",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.5.0"
},
"devDependencies": {
@@ -27,6 +32,7 @@
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"prisma": "^7.8.0",
"tailwindcss": "^4",
"typescript": "^5"
}
+1733
View File
File diff suppressed because it is too large Load Diff
+12
View File
@@ -0,0 +1,12 @@
import "dotenv/config";
import { defineConfig, env } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
datasource: {
url: env("DATABASE_URL"),
},
});
@@ -0,0 +1,61 @@
-- CreateEnum
CREATE TYPE "ProjectSize" AS ENUM ('Big', 'MediumSized', 'Small');
-- CreateTable
CREATE TABLE "Project" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"label" TEXT NOT NULL,
"size" "ProjectSize" NOT NULL DEFAULT 'MediumSized',
"imageUrl" TEXT,
"link" TEXT NOT NULL,
"linkIsDemo" BOOLEAN NOT NULL DEFAULT false,
"sourceCode" TEXT,
"description" TEXT NOT NULL,
"why" TEXT NOT NULL,
"note" TEXT,
"tags" TEXT[],
"loc" INTEGER,
"locEndpoint" TEXT,
"featured" BOOLEAN NOT NULL DEFAULT false,
"sortIndex" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Project_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "WorkExperience" (
"id" TEXT NOT NULL,
"company" TEXT NOT NULL,
"role" TEXT NOT NULL,
"fromDate" TIMESTAMP(3) NOT NULL,
"toDate" TIMESTAMP(3),
"url" TEXT,
"iconUrl" TEXT,
"summary" TEXT NOT NULL,
"tags" TEXT[],
"sortIndex" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "WorkExperience_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Affiliate" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"link" TEXT NOT NULL,
"icon" TEXT NOT NULL,
"location" TEXT NOT NULL,
"provides" TEXT[],
"good" TEXT[],
"bad" TEXT[],
"sortIndex" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Affiliate_pkey" PRIMARY KEY ("id")
);
@@ -0,0 +1,10 @@
/*
Warnings:
- You are about to drop the column `featured` on the `Project` table. All the data in the column will be lost.
- You are about to drop the column `sortIndex` on the `Project` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "Project" DROP COLUMN "featured",
DROP COLUMN "sortIndex";
@@ -0,0 +1,19 @@
-- CreateTable
CREATE TABLE "BlogPost" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"excerpt" TEXT NOT NULL,
"coverImageUrl" TEXT,
"content" TEXT NOT NULL,
"isPublished" BOOLEAN NOT NULL DEFAULT true,
"views" INTEGER NOT NULL DEFAULT 0,
"publishedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "BlogPost_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "BlogPost_slug_key" ON "BlogPost"("slug");
@@ -0,0 +1,20 @@
-- CreateTable
CREATE TABLE "BlogAuthor" (
"id" TEXT NOT NULL,
"blogPostId" TEXT NOT NULL,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"type" TEXT NOT NULL,
"name" TEXT NOT NULL,
"website" TEXT,
"imageUrl" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "BlogAuthor_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "BlogAuthor_blogPostId_sortOrder_idx" ON "BlogAuthor"("blogPostId", "sortOrder");
-- AddForeignKey
ALTER TABLE "BlogAuthor" ADD CONSTRAINT "BlogAuthor_blogPostId_fkey" FOREIGN KEY ("blogPostId") REFERENCES "BlogPost"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+3
View File
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
+96
View File
@@ -0,0 +1,96 @@
// 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
}
model BlogPost {
id String @id @default(cuid())
title String
slug String @unique
excerpt String
coverImageUrl String?
content String @db.Text
isPublished Boolean @default(true)
views Int @default(0)
publishedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
authors BlogAuthor[]
}
model BlogAuthor {
id String @id @default(cuid())
blogPostId String
blogPost BlogPost @relation(fields: [blogPostId], references: [id], onDelete: Cascade)
sortOrder Int @default(0)
type String
name String
website String?
imageUrl String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([blogPostId, sortOrder])
}
+715 -436
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,27 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { parseAffiliate } from "@/lib/dto";
import { guard, run, readBody } from "@/lib/adminRoute";
type Params = { params: Promise<{ id: string }> };
export async function PUT(req: Request, { params }: Params) {
const denied = await guard();
if (denied) return denied;
return run(async () => {
const { id } = await params;
const data = parseAffiliate(await readBody(req));
const updated = await prisma.affiliate.update({ where: { id }, data });
return NextResponse.json(updated);
});
}
export async function DELETE(_req: Request, { params }: Params) {
const denied = await guard();
if (denied) return denied;
return run(async () => {
const { id } = await params;
await prisma.affiliate.delete({ where: { id } });
return NextResponse.json({ ok: true });
});
}
+25
View File
@@ -0,0 +1,25 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { parseAffiliate } from "@/lib/dto";
import { guard, run, readBody } from "@/lib/adminRoute";
export async function GET() {
const denied = await guard();
if (denied) return denied;
return run(async () => {
const items = await prisma.affiliate.findMany({
orderBy: [{ sortIndex: "asc" }, { createdAt: "asc" }],
});
return NextResponse.json(items);
});
}
export async function POST(req: Request) {
const denied = await guard();
if (denied) return denied;
return run(async () => {
const data = parseAffiliate(await readBody(req));
const created = await prisma.affiliate.create({ data });
return NextResponse.json(created, { status: 201 });
});
}
+46
View File
@@ -0,0 +1,46 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { parseBlogPost } from "@/lib/dto";
import { guard, run, readBody } from "@/lib/adminRoute";
import { blogPostInclude } from "@/lib/blogs";
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 = parseBlogPost(await readBody(req));
const updated = await prisma.blogPost.update({
where: { id },
data: {
title: data.title,
slug: data.slug,
excerpt: data.excerpt,
coverImageUrl: data.coverImageUrl,
content: data.content,
isPublished: data.isPublished,
publishedAt: data.publishedAt,
authors: {
deleteMany: {},
create: data.authors,
},
},
include: blogPostInclude,
});
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.blogPost.delete({ where: { id } });
return NextResponse.json({ ok: true });
});
}
+43
View File
@@ -0,0 +1,43 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { parseBlogPost } from "@/lib/dto";
import { guard, run, readBody } from "@/lib/adminRoute";
import { blogPostInclude } from "@/lib/blogs";
export async function GET() {
const denied = await guard();
if (denied) return denied;
return run(async () => {
const posts = await prisma.blogPost.findMany({
include: blogPostInclude,
orderBy: [{ updatedAt: "desc" }],
});
return NextResponse.json(posts);
});
}
export async function POST(req: Request) {
const denied = await guard();
if (denied) return denied;
return run(async () => {
const data = parseBlogPost(await readBody(req));
const created = await prisma.blogPost.create({
data: {
title: data.title,
slug: data.slug,
excerpt: data.excerpt,
coverImageUrl: data.coverImageUrl,
content: data.content,
isPublished: data.isPublished,
publishedAt: data.publishedAt,
authors: {
create: data.authors,
},
},
include: blogPostInclude,
});
return NextResponse.json(created, { status: 201 });
});
}
@@ -0,0 +1,27 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { parseExperience } from "@/lib/dto";
import { guard, run, readBody } from "@/lib/adminRoute";
type Params = { params: Promise<{ id: string }> };
export async function PUT(req: Request, { params }: Params) {
const denied = await guard();
if (denied) return denied;
return run(async () => {
const { id } = await params;
const data = parseExperience(await readBody(req));
const updated = await prisma.workExperience.update({ where: { id }, data });
return NextResponse.json(updated);
});
}
export async function DELETE(_req: Request, { params }: Params) {
const denied = await guard();
if (denied) return denied;
return run(async () => {
const { id } = await params;
await prisma.workExperience.delete({ where: { id } });
return NextResponse.json({ ok: true });
});
}
+25
View File
@@ -0,0 +1,25 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { parseExperience } from "@/lib/dto";
import { guard, run, readBody } from "@/lib/adminRoute";
export async function GET() {
const denied = await guard();
if (denied) return denied;
return run(async () => {
const items = await prisma.workExperience.findMany({
orderBy: [{ sortIndex: "asc" }, { fromDate: "desc" }],
});
return NextResponse.json(items);
});
}
export async function POST(req: Request) {
const denied = await guard();
if (denied) return denied;
return run(async () => {
const data = parseExperience(await readBody(req));
const created = await prisma.workExperience.create({ data });
return NextResponse.json(created, { status: 201 });
});
}
+26
View File
@@ -0,0 +1,26 @@
import { NextResponse } from "next/server";
import { createSessionToken, verifyPassword, SESSION_COOKIE } from "@/lib/auth";
export async function POST(req: Request) {
let password = "";
try {
const body = await req.json();
password = typeof body?.password === "string" ? body.password : "";
} catch {
// ignore malformed body -> treated as invalid
}
if (!verifyPassword(password)) {
return NextResponse.json({ error: "Invalid password" }, { status: 401 });
}
const res = NextResponse.json({ ok: true });
res.cookies.set(SESSION_COOKIE, createSessionToken(), {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
maxAge: 60 * 60 * 24 * 7,
});
return res;
}
+8
View File
@@ -0,0 +1,8 @@
import { NextResponse } from "next/server";
import { SESSION_COOKIE } from "@/lib/auth";
export async function POST() {
const res = NextResponse.json({ ok: true });
res.cookies.set(SESSION_COOKIE, "", { path: "/", maxAge: 0 });
return res;
}
+27
View File
@@ -0,0 +1,27 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { parseProject } from "@/lib/dto";
import { guard, run, readBody } from "@/lib/adminRoute";
type Params = { params: Promise<{ id: string }> };
export async function PUT(req: Request, { params }: Params) {
const denied = await guard();
if (denied) return denied;
return run(async () => {
const { id } = await params;
const data = parseProject(await readBody(req));
const updated = await prisma.project.update({ where: { id }, data });
return NextResponse.json(updated);
});
}
export async function DELETE(_req: Request, { params }: Params) {
const denied = await guard();
if (denied) return denied;
return run(async () => {
const { id } = await params;
await prisma.project.delete({ where: { id } });
return NextResponse.json({ ok: true });
});
}
+25
View File
@@ -0,0 +1,25 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { parseProject } from "@/lib/dto";
import { guard, run, readBody } from "@/lib/adminRoute";
export async function GET() {
const denied = await guard();
if (denied) return denied;
return run(async () => {
const projects = await prisma.project.findMany({
orderBy: [{ size: "asc" }, { name: "asc" }],
});
return NextResponse.json(projects);
});
}
export async function POST(req: Request) {
const denied = await guard();
if (denied) return denied;
return run(async () => {
const data = parseProject(await readBody(req));
const created = await prisma.project.create({ data });
return NextResponse.json(created, { status: 201 });
});
}
+6
View File
@@ -0,0 +1,6 @@
import { NextResponse } from "next/server";
import { isAdmin } from "@/lib/auth";
export async function GET() {
return NextResponse.json({ authenticated: await isAdmin() });
}
+10
View File
@@ -0,0 +1,10 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
// Public: list affiliates.
export async function GET() {
const affiliates = await prisma.affiliate.findMany({
orderBy: [{ sortIndex: "asc" }, { createdAt: "asc" }],
});
return NextResponse.json(affiliates);
}
+33
View File
@@ -0,0 +1,33 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { blogPostInclude, toBlogPost } from "@/lib/blogs";
type Params = { params: Promise<{ slug: string }> };
export const dynamic = "force-dynamic";
export async function GET(_req: Request, { params }: Params) {
const { slug } = await params;
const post = await prisma.$transaction(async (tx) => {
const found = await tx.blogPost.findFirst({
where: { slug, isPublished: true },
});
if (!found) {
return null;
}
return tx.blogPost.update({
where: { id: found.id },
data: { views: { increment: 1 } },
include: blogPostInclude,
});
});
if (!post) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
return NextResponse.json(toBlogPost(post));
}
+15
View File
@@ -0,0 +1,15 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { blogPostInclude, toBlogSummary } from "@/lib/blogs";
export const dynamic = "force-dynamic";
export async function GET() {
const posts = await prisma.blogPost.findMany({
include: blogPostInclude,
where: { isPublished: true },
orderBy: [{ publishedAt: "desc" }, { createdAt: "desc" }],
});
return NextResponse.json(posts.map(toBlogSummary));
}
+16
View File
@@ -0,0 +1,16 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { blogPostInclude, toBlogSummary } from "@/lib/blogs";
export const dynamic = "force-dynamic";
export async function GET() {
const posts = await prisma.blogPost.findMany({
include: blogPostInclude,
where: { isPublished: true },
orderBy: [{ views: "desc" }, { publishedAt: "desc" }, { createdAt: "desc" }],
take: 3,
});
return NextResponse.json(posts.map(toBlogSummary));
}
+10
View File
@@ -0,0 +1,10 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
// Public: list work experience, most recent first.
export async function GET() {
const experience = await prisma.workExperience.findMany({
orderBy: [{ sortIndex: "asc" }, { fromDate: "desc" }],
});
return NextResponse.json(experience);
}
+43
View File
@@ -0,0 +1,43 @@
import { NextResponse } from "next/server";
// Serves the profile picture through our own origin, caching the bytes in
// process memory so we don't hit the upstream CDN on every request.
const DEFAULT_IMAGE = "https://cdn.reversed.dev/pictures/20250405_120402.png";
const CACHE_TTL_MS = 1000 * 60 * 60; // 1 hour
type ImageCache = { body: ArrayBuffer; contentType: string; expiresAt: number };
let cached: ImageCache | null = null;
async function loadImage(): Promise<ImageCache | null> {
if (cached && cached.expiresAt > Date.now()) return cached;
const source = process.env.PROFILE_IMAGE_URL || DEFAULT_IMAGE;
try {
const res = await fetch(source, { signal: AbortSignal.timeout(8000) });
if (!res.ok) return cached; // fall back to stale copy if present
const body = await res.arrayBuffer();
cached = {
body,
contentType: res.headers.get("content-type") || "image/png",
expiresAt: Date.now() + CACHE_TTL_MS,
};
return cached;
} catch {
return cached;
}
}
export async function GET() {
const image = await loadImage();
if (!image) {
return NextResponse.json({ error: "Image unavailable" }, { status: 502 });
}
return new NextResponse(image.body, {
headers: {
"Content-Type": image.contentType,
"Cache-Control": "public, max-age=3600, stale-while-revalidate=86400",
},
});
}
+36
View File
@@ -0,0 +1,36 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { resolveLoc } from "@/lib/loc";
// Public: list all projects (served from our DB), each with a resolved
// line-of-code count (manual `loc` or fetched from `locEndpoint`, cached).
export async function GET() {
// Ordered by size (Big -> MediumSized -> Small via the enum's declared order),
// then alphabetically by name.
const projects = await prisma.project.findMany({
orderBy: [{ size: "asc" }, { name: "asc" }],
});
const withLoc = await Promise.all(
projects.map(async (p) => ({
id: p.id,
name: p.name,
label: p.label,
size: p.size,
imageUrl: p.imageUrl,
link: p.link,
linkIsDemo: p.linkIsDemo,
sourceCode: p.sourceCode,
description: p.description,
why: p.why,
note: p.note,
tags: p.tags,
loc: p.loc,
resolvedLoc: await resolveLoc(p.loc, p.locEndpoint),
createdAt: p.createdAt,
updatedAt: p.updatedAt,
})),
);
return NextResponse.json(withLoc);
}
+97
View File
@@ -0,0 +1,97 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { Navbar } from "@/components/Navbar";
import { Footer } from "@/sections/Footer";
import { MarkdownContent } from "@/components/MarkdownContent";
import { BlogAuthorList } from "@/components/BlogAuthorList";
import { prisma } from "@/lib/prisma";
import { blogPostInclude, getReadingTimeMinutes } from "@/lib/blogs";
type Params = { params: Promise<{ slug: string }> };
export const dynamic = "force-dynamic";
function formatDate(value: Date | null) {
if (!value) return "Draft";
return new Intl.DateTimeFormat("en", {
day: "2-digit",
month: "long",
year: "numeric",
}).format(value);
}
async function getPost(slug: string) {
return prisma.$transaction(async (tx) => {
const found = await tx.blogPost.findFirst({
where: { slug, isPublished: true },
});
if (!found) return null;
return tx.blogPost.update({
where: { id: found.id },
data: { views: { increment: 1 } },
include: blogPostInclude,
});
});
}
export async function generateMetadata({ params }: Params): Promise<Metadata> {
const { slug } = await params;
const post = await prisma.blogPost.findFirst({
where: { slug, isPublished: true },
});
if (!post) {
return {
title: "Post not found | Paul W. Portfolio",
};
}
return {
title: `${post.title} | Paul W. Portfolio`,
description: post.excerpt,
};
}
export default async function BlogDetailPage({ params }: Params) {
const { slug } = await params;
const post = await getPost(slug);
if (!post) {
notFound();
}
return (
<>
<Navbar />
<div className="mx-auto w-full max-w-4xl px-4 pb-20 pt-28 sm:pt-32">
<article className="rounded-[2rem] border border-white/10 bg-white/[0.03] px-6 py-10 sm:px-10 sm:py-14">
<div className="space-y-4 border-b border-white/10 pb-8">
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-gray-500">
blog post
</p>
<h1 className="max-w-3xl text-4xl font-semibold text-white sm:text-5xl">
{post.title}
</h1>
<p className="max-w-2xl text-sm leading-7 text-gray-400 sm:text-base">
{post.excerpt}
</p>
<div className="flex flex-wrap items-center gap-3 text-xs uppercase tracking-[0.18em] text-gray-500">
<span>{formatDate(post.publishedAt)}</span>
<span>{post.views} views</span>
<span>{getReadingTimeMinutes(post.content)} min read</span>
</div>
{post.authors.length > 0 ? <BlogAuthorList authors={post.authors} /> : null}
</div>
<div className="pt-8">
<MarkdownContent content={post.content} />
</div>
</article>
</div>
<Footer />
</>
);
}
+66
View File
@@ -0,0 +1,66 @@
import Link from "next/link";
import { Navbar } from "@/components/Navbar";
import { Footer } from "@/sections/Footer";
import { BlogCard } from "@/components/BlogCard";
import { prisma } from "@/lib/prisma";
import { blogPostInclude, toBlogSummary } from "@/lib/blogs";
export const metadata = {
title: "Blog | Paul W. Portfolio",
description: "Notes on infrastructure, tooling, and shipping software.",
};
export const dynamic = "force-dynamic";
export default async function BlogsPage() {
const posts = await prisma.blogPost.findMany({
include: blogPostInclude,
where: { isPublished: true },
orderBy: [{ publishedAt: "desc" }, { createdAt: "desc" }],
});
const blogs = posts.map(toBlogSummary);
return (
<>
<Navbar />
<div className="mx-auto w-full max-w-6xl px-4 pb-20 pt-28 sm:pt-32">
<section className="rounded-[2rem] border border-white/10 bg-white/[0.03] px-6 py-10 sm:px-10 sm:py-14">
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-gray-500">
writing
</p>
<h1 className="mt-4 max-w-3xl text-4xl font-semibold text-white sm:text-5xl">
Technical writing on infrastructure, software, and product work.
</h1>
<p className="mt-4 max-w-2xl text-sm leading-7 text-gray-400 sm:text-base">
Notes, updates, and technical writing across current projects and systems.
</p>
</section>
<section className="mt-10">
{blogs.length === 0 ? (
<div className="rounded-[1.75rem] border border-white/10 bg-white/[0.03] px-6 py-10 text-center text-gray-400">
No published posts yet.
</div>
) : (
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-3">
{blogs.map((blog) => (
<BlogCard key={blog.id} blog={blog} />
))}
</div>
)}
</section>
<div className="mt-10 flex justify-center">
<Link
href="/"
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"
>
Back home
</Link>
</div>
</div>
<Footer />
</>
);
}
+16 -17
View File
@@ -3,43 +3,42 @@
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 (
<>
<Navbar />
<div className="flex flex-col items-center justify-center min-h-screen px-4 space-y-12 w-full max-w-4xl mx-auto pt-24 pb-20 text-white">
<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-5xl md:text-6xl font-extrabold text-white">
Let's <span className="text-transparent bg-clip-text bg-gradient-to-r from-blue-400 to-purple-500">Connect</span>
<h1 className="text-4xl font-semibold text-white sm:text-5xl md:text-6xl">
Let&apos;s Connect
</h1>
<p className="text-gray-400 text-xl max-w-xl mx-auto">
Whatever you've got in mind, I'm just a few clicks away.
<p className="mx-auto max-w-xl text-base text-gray-500 sm:text-lg md:text-xl">
Whatever you&apos;ve got in mind, I&apos;m just a few clicks away.
</p>
</div>
<div className="w-full max-w-3xl">
<div className="w-full max-w-2xl">
<ContactSection />
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6 w-full max-w-4xl text-center">
<div className="bg-white/5 border border-white/10 p-6 rounded-3xl backdrop-blur-sm group hover:border-blue-500/30 transition-all duration-300">
<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="bg-white/5 border border-white/10 p-6 rounded-3xl backdrop-blur-sm group hover:border-purple-500/30 transition-all duration-300">
</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="bg-white/5 border border-white/10 p-6 rounded-3xl backdrop-blur-sm group hover:border-pink-500/30 transition-all duration-300">
<div className="text-3xl mb-4 group-hover:scale-110 transition-transform">💻</div>
<h3 className="font-bold mb-1">GitHub</h3>
<p className="text-sm text-gray-500">github.com/Space-Banane</p>
</div>
</div>
</div>
<Footer />
Binary file not shown.
Binary file not shown.
Binary file not shown.
+14 -1
View File
@@ -14,17 +14,30 @@
.animate-scale-in { animation: scale-in 0.3s ease-out; }
:root {
--background: #020205;
--background: #0a0a0a;
--foreground: #ffffff;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-satoshi), ui-sans-serif, system-ui, sans-serif;
--font-display: var(--font-clash), var(--font-satoshi), ui-sans-serif, sans-serif;
}
body {
background: var(--background);
color: var(--foreground);
/* Reference the next/font variables directly — `@theme inline` above does not
emit real --font-sans/--font-display custom properties. */
font-family: var(--font-satoshi), ui-sans-serif, system-ui, sans-serif;
}
/* Headings use Clash Display by default for a sharper, editorial feel. */
h1,
h2,
h3 {
font-family: var(--font-clash), var(--font-satoshi), ui-sans-serif, sans-serif;
letter-spacing: 0.01em;
}
+22 -8
View File
@@ -1,10 +1,28 @@
import type { Metadata } from "next";
import localFont from "next/font/local";
import "@/app/globals.css";
import { ClientProviders } from "../components/ClientProviders";
// Satoshi = body text, Clash Display = headings (both variable fonts).
const satoshi = localFont({
variable: "--font-satoshi",
display: "swap",
src: [
{ path: "./fonts/Satoshi-Variable.ttf", weight: "300 900", style: "normal" },
{ path: "./fonts/Satoshi-VariableItalic.ttf", weight: "300 900", style: "italic" },
],
});
const clashDisplay = localFont({
variable: "--font-clash",
display: "swap",
src: [{ path: "./fonts/ClashDisplay-Variable.ttf", weight: "200 700", style: "normal" }],
});
export const metadata: Metadata = {
title: "Space's portfolio",
description: "A developer from Germany building things.",
title: "Paul W. — Portfolio",
description:
"Full-stack developer & open-source author. Building server infrastructure, developer tools, and web applications.",
icons: {
icon: "/favicon.png",
},
@@ -16,14 +34,10 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en">
<html lang="en" className={`${satoshi.variable} ${clashDisplay.variable}`}>
<body className="antialiased">
<ClientProviders>
<div className="relative min-h-screen bg-[#020205] text-white selection:bg-blue-500/30 overflow-x-hidden">
<div className="fixed inset-0 z-0 pointer-events-none">
<div className="absolute top-[-10%] left-[-10%] w-[40%] h-[40%] bg-blue-500/10 blur-[120px] rounded-full animate-pulse" />
<div className="absolute bottom-[-10%] right-[-10%] w-[40%] h-[40%] bg-purple-500/10 blur-[120px] rounded-full animate-pulse" />
</div>
<div className="relative min-h-screen bg-[#0a0a0a] text-white selection:bg-white/20 overflow-x-hidden">
{children}
</div>
</ClientProviders>
+15 -141
View File
@@ -1,158 +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 { ProjectCard } from "../components/ProjectCard";
import { MiniProjectCard } from "../components/MiniProjectCard";
import { ExperienceCard } from "../components/ExperienceCard";
import { ExperienceModal } from "../components/ExperienceModal";
import { MiniProjectModal } from "../components/MiniProjectModal";
import { Navbar } from "../components/Navbar";
import { Hero } from "../sections/Hero";
import { TopBlogs } from "../sections/TopBlogs";
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 type { Experience } from "../types";
export default function Home() {
const {
glowColor, borderStatus, displayMessage, statusMessage,
rotatingMessages,
projects, miniProjects, setSelectedMiniProject,
experiences, setSelectedExperience, realWork,
selectedMiniProject, selectedExperience
} = useProfile();
const [showTypingIntro, setShowTypingIntro] = useState(true);
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 groupedExperiences = experiences.reduce(
(acc, exp) => {
if (!acc[exp.type]) acc[exp.type] = [];
acc[exp.type].push(exp);
return acc;
},
{} as Record<string, Experience[]>,
);
const { experiences } = useProfile();
return (
<>
<TypingRoomIntro active={showTypingIntro} onFinish={handleIntroFinish} />
{!showTypingIntro && (
<>
<Navbar />
<div className="space-y-24 pb-20 pt-20">
<Hero
glowColor={glowColor}
borderStatus={borderStatus}
displayMessage={displayMessage}
rotatingMessages={rotatingMessages}
statusMessage={statusMessage}
oldUsernames={oldUsernames}
/>
<WorkExperience realWork={realWork} />
<Uptime />
<Activity />
<div className="space-y-20 pb-16 pt-28 sm:space-y-24 sm:pb-20 sm:pt-32">
<Hero />
<TopBlogs />
<WorkExperience experiences={experiences} />
<FeaturedProjects />
<Luna />
<Affiliates />
<TechStack />
<section className="w-full max-w-6xl mx-auto space-y-12 px-4">
<div className="text-center space-y-4">
<h2 className="text-4xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-blue-400 to-purple-500">
Featured Projects
</h2>
<p className="text-gray-400 max-w-2xl mx-auto">
A selection of my personal favorites. Many more on my GitHub.
</p>
<Activity />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{projects.map((project, index) => (
<ProjectCard key={index} project={project} />
))}
</div>
</section>
<section className="w-full max-w-6xl mx-auto space-y-12 px-4">
<div className="text-center space-y-4">
<h2 className="text-3xl font-bold">More Projects</h2>
<p className="text-gray-400">Smaller projects or tools I've built.</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{miniProjects.map((project, index) => (
<MiniProjectCard
key={index}
project={project}
onClick={() => setSelectedMiniProject(project)}
/>
))}
</div>
</section>
<section className="w-full max-w-6xl mx-auto space-y-12 px-4 pb-20">
<div className="text-center space-y-4">
<h2 className="text-3xl font-bold">Skills & Experience</h2>
<p className="text-gray-400">Things I've worked with over the years.</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-12">
{Object.entries(groupedExperiences).map(([type, items]) => (
<div key={type} className="space-y-6">
<h3 className="text-xl font-semibold border-l-4 border-blue-500 pl-4 capitalize">
{type}
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{items.map((exp, index) => (
<ExperienceCard
key={index}
experience={exp}
onClick={() => setSelectedExperience(exp)}
/>
))}
</div>
</div>
))}
</div>
</section>
</div>
<Footer />
{selectedMiniProject && (
<MiniProjectModal
project={selectedMiniProject}
onClose={() => setSelectedMiniProject(null)}
/>
)}
{selectedExperience && (
<ExperienceModal
experience={selectedExperience}
onClose={() => setSelectedExperience(null)}
/>
)}
</>
)}
</>
);
}
+39
View File
@@ -0,0 +1,39 @@
"use client";
import { useProfile } from "../../context/ProfileContext";
import { Navbar } from "../../components/Navbar";
import { Footer } from "../../sections/Footer";
import { ProjectCard } from "../../components/ProjectCard";
export default function ProjectsPage() {
const { projects, loading } = useProfile();
return (
<>
<Navbar />
<div className="mx-auto w-full max-w-6xl px-4 pb-16 pt-28 sm:pb-20 sm:pt-32">
<div className="mb-10 space-y-4 text-center sm:mb-14">
<h1 className="text-4xl font-semibold text-white sm:text-5xl">Projects</h1>
<p className="mx-auto max-w-2xl text-base text-gray-500 sm:text-lg">
Everything I&apos;ve built and shipped big and small.
</p>
</div>
{loading ? (
<p className="text-center text-gray-500">Loading projects</p>
) : projects.length === 0 ? (
<div className="rounded-2xl border border-white/10 bg-white/5 px-5 py-12 text-center text-gray-400">
No projects published yet.
</div>
) : (
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3 sm:gap-8">
{projects.map((project) => (
<ProjectCard key={project.id} project={project} />
))}
</div>
)}
</div>
<Footer />
</>
);
}
+102
View File
@@ -0,0 +1,102 @@
type DisplayAuthor = {
id: string;
type: string;
name: string;
website?: string | null;
imageUrl?: string | null;
};
const ROLE_LABELS: Record<string, string> = {
author: "Author",
"co-author": "Co-author",
relating: "Relating",
};
function avatarFallback(name: string) {
return name
.split(/\s+/)
.map((part) => part[0] ?? "")
.join("")
.slice(0, 2)
.toUpperCase();
}
function AuthorAvatar({ author }: { author: DisplayAuthor }) {
if (author.imageUrl) {
return (
<img
src={author.imageUrl}
alt={author.name}
className="h-10 w-10 rounded-full border border-white/10 object-cover"
/>
);
}
return (
<div className="flex h-10 w-10 items-center justify-center rounded-full border border-white/10 bg-white/5 text-xs font-semibold text-gray-300">
{avatarFallback(author.name)}
</div>
);
}
export function BlogAuthorList({ authors }: { authors: DisplayAuthor[] }) {
return (
<div className="grid gap-3 sm:grid-cols-2">
{authors.map((author) => {
const content = (
<div className="flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
<AuthorAvatar author={author} />
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-gray-500">
{ROLE_LABELS[author.type] ?? author.type}
</p>
<p className="truncate text-sm font-medium text-white">{author.name}</p>
</div>
</div>
);
if (!author.website) {
return <div key={author.id}>{content}</div>;
}
return (
<a
key={author.id}
href={author.website}
target="_blank"
rel="noreferrer"
className="transition-transform duration-200 hover:-translate-y-0.5"
>
{content}
</a>
);
})}
</div>
);
}
export function BlogAuthorChips({ authors }: { authors: DisplayAuthor[] }) {
return (
<div className="flex flex-wrap gap-2">
{authors.map((author) => (
<div
key={author.id}
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-black/30 px-3 py-1.5"
>
{author.imageUrl ? (
<img
src={author.imageUrl}
alt={author.name}
className="h-5 w-5 rounded-full border border-white/10 object-cover"
/>
) : (
<div className="flex h-5 w-5 items-center justify-center rounded-full border border-white/10 bg-white/5 text-[10px] font-semibold text-gray-300">
{avatarFallback(author.name)}
</div>
)}
<span className="text-xs font-medium text-gray-200">{author.name}</span>
</div>
))}
</div>
);
}
+63
View File
@@ -0,0 +1,63 @@
import Link from "next/link";
import type { BlogPostSummary } from "@/types";
import { BlogAuthorChips } from "@/components/BlogAuthorList";
function formatDate(value: string | null | undefined) {
if (!value) return "Draft";
return new Intl.DateTimeFormat("en", {
day: "2-digit",
month: "short",
year: "numeric",
}).format(new Date(value));
}
export function BlogCard({ blog, index }: { blog: BlogPostSummary; index?: number }) {
return (
<Link
href={`/blogs/${blog.slug}`}
className="group flex h-full flex-col overflow-hidden rounded-[1.75rem] border border-white/10 bg-white/[0.03] p-5 transition-all duration-300 hover:-translate-y-1 hover:border-white/20 hover:bg-white/[0.05]"
>
{blog.coverImageUrl ? (
<div className="mb-5 overflow-hidden rounded-[1.25rem] border border-white/10 bg-black/30">
{/* External CMS image URLs are stored directly, so a plain img is the least brittle option here. */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={blog.coverImageUrl}
alt={blog.title}
className="h-48 w-full object-cover transition-transform duration-500 group-hover:scale-[1.03]"
/>
</div>
) : null}
<div className="mb-5 flex items-start justify-between gap-4">
<div className="space-y-2">
{index !== undefined ? (
<p className="text-[11px] font-semibold uppercase tracking-[0.28em] text-gray-500">
Top {index + 1}
</p>
) : null}
<h3 className="text-2xl font-semibold text-white transition-colors group-hover:text-gray-100">
{blog.title}
</h3>
</div>
<div className="rounded-full border border-white/10 bg-black/40 px-3 py-1 text-xs font-medium text-gray-300">
{blog.views} views
</div>
</div>
<p className="flex-1 text-sm leading-7 text-gray-400">{blog.excerpt}</p>
{blog.authors.length > 0 ? (
<div className="mt-5">
<BlogAuthorChips authors={blog.authors} />
</div>
) : null}
<div className="mt-6 flex items-center justify-between gap-4 text-xs uppercase tracking-[0.18em] text-gray-500">
<span>{formatDate(blog.publishedAt)}</span>
<span>{blog.readingTimeMinutes} min read</span>
</div>
</Link>
);
}
-79
View File
@@ -1,79 +0,0 @@
"use client";
import type { Experience } from "../types";
import { getTypeColor, getTypeIcon } from "./utils";
export function ExperienceCard({
experience,
onClick,
}: {
experience: Experience;
onClick?: () => void;
}) {
const isClickable = !!(
experience.learned_at ||
experience.learned_from ||
experience.learned_because
);
return (
<div
onClick={isClickable ? onClick : undefined}
className={`group flex items-center gap-3 p-3 rounded-lg bg-gradient-to-br from-white/5 to-white/2 backdrop-blur-sm border border-white/10 transition-all duration-200 ${
isClickable
? "cursor-pointer hover:border-purple-500/50 hover:bg-white/10 hover:-translate-y-0.5"
: ""
}`}
>
<div
className={`text-2xl p-2 rounded-lg ${getTypeColor(experience.type)} flex items-center justify-center shrink-0 relative ${
experience.image ? "w-10 h-10" : ""
}`}
>
{experience.image ? (
<img
src={experience.image}
alt={experience.name}
className="w-full h-full object-contain"
/>
) : (
getTypeIcon(experience.type)
)}
{isClickable && (
<div className="absolute -top-1 -right-1 w-3 h-3 bg-purple-500 rounded-full border-2 border-black animate-pulse" />
)}
</div>
<div className="flex-1 min-w-0">
<h4
className={`text-base font-semibold text-white transition-colors ${
isClickable ? "group-hover:text-purple-400" : ""
}`}
>
{experience.name}
</h4>
{experience.description && (
<p className="text-xs text-gray-400 mt-0.5 line-clamp-1">
{experience.description}
</p>
)}
</div>
{isClickable && (
<div className="text-purple-400 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M9 5l7 7-7 7"
/>
</svg>
</div>
)}
</div>
);
}
-119
View File
@@ -1,119 +0,0 @@
"use client";
import type { Experience } from "../types";
import { getTypeColor, getTypeIcon } from "./utils";
export function ExperienceModal({
experience,
onClose,
}: {
experience: Experience;
onClose: () => void;
}) {
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm animate-fade-in"
onClick={onClose}
>
<div
className="relative max-w-2xl w-full max-h-[90vh] overflow-y-auto bg-gradient-to-br from-gray-900 to-black border border-purple-500/30 rounded-2xl shadow-2xl animate-scale-in"
onClick={(e) => e.stopPropagation()}
>
{/* Close Button */}
<button
onClick={onClose}
className="absolute top-4 right-4 p-2 rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors z-10"
>
<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="p-8 space-y-6">
<div className="flex items-center gap-4">
<div
className={`text-4xl p-3 rounded-lg ${getTypeColor(experience.type)} flex items-center justify-center shrink-0 ${experience.image ? "w-16 h-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-3xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-purple-400 to-pink-500">
{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>
);
}
+69
View File
@@ -0,0 +1,69 @@
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
export function MarkdownContent({ content }: { content: string }) {
return (
<div className="space-y-4 text-sm leading-7 text-gray-300 sm:text-base">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
h1: ({ children }) => <h1 className="mt-8 text-3xl font-semibold text-white first:mt-0">{children}</h1>,
h2: ({ children }) => <h2 className="mt-8 text-2xl font-semibold text-white">{children}</h2>,
h3: ({ children }) => <h3 className="mt-6 text-xl font-semibold text-white">{children}</h3>,
p: ({ children }) => <p>{children}</p>,
ul: ({ children }) => <ul className="list-disc space-y-2 pl-6">{children}</ul>,
ol: ({ children }) => <ol className="list-decimal space-y-2 pl-6">{children}</ol>,
li: ({ children }) => <li>{children}</li>,
a: ({ href, children }) => (
<a
href={href}
target="_blank"
rel="noreferrer"
className="text-white underline decoration-white/30 underline-offset-4 hover:decoration-white"
>
{children}
</a>
),
blockquote: ({ children }) => (
<blockquote className="border-l-2 border-white/20 pl-4 italic text-gray-400">{children}</blockquote>
),
img: ({ src, alt }) => (
// Markdown images should feel deliberate in the article layout.
<img
src={src || ""}
alt={alt || ""}
className="my-6 w-full rounded-2xl border border-white/10 bg-black/40 object-cover"
/>
),
table: ({ children }) => (
<div className="my-6 overflow-x-auto rounded-2xl border border-white/10">
<table className="min-w-full border-collapse text-left text-sm">{children}</table>
</div>
),
thead: ({ children }) => <thead className="bg-white/6 text-white">{children}</thead>,
tbody: ({ children }) => <tbody className="divide-y divide-white/10">{children}</tbody>,
tr: ({ children }) => <tr className="divide-x divide-white/10">{children}</tr>,
th: ({ children }) => <th className="px-4 py-3 font-semibold">{children}</th>,
td: ({ children }) => <td className="px-4 py-3 align-top text-gray-300">{children}</td>,
code: ({ className, children }) => {
const inline = !className;
if (inline) {
return <code className="rounded bg-white/8 px-1.5 py-0.5 font-mono text-[0.95em] text-white">{children}</code>;
}
return (
<code className="block overflow-x-auto rounded-2xl border border-white/10 bg-black/50 p-4 font-mono text-sm text-gray-200">
{children}
</code>
);
},
pre: ({ children }) => <pre>{children}</pre>,
hr: () => <hr className="border-white/10" />,
strong: ({ children }) => <strong className="font-semibold text-white">{children}</strong>,
}}
>
{content}
</ReactMarkdown>
</div>
);
}
-57
View File
@@ -1,57 +0,0 @@
"use client";
import type { MiniProject } from "../types";
export function MiniProjectCard({
project,
onClick,
}: {
project: MiniProject;
onClick: () => void;
}) {
return (
<div
onClick={onClick}
className="group cursor-pointer relative flex flex-col p-6 rounded-2xl bg-gradient-to-br from-white/5 to-white/2 backdrop-blur-sm border border-white/10 hover:border-purple-500/50 transition-all duration-300 hover:-translate-y-2 hover:shadow-2xl hover:shadow-purple-500/20"
>
{project.image && (
<div className="mb-4 overflow-hidden rounded-xl bg-black/20 aspect-video flex items-center justify-center">
<img
src={project.image}
alt={project.title}
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500"
/>
</div>
)}
<h3 className="text-xl font-bold text-white mb-2 group-hover:text-purple-400 transition-colors">
{project.title}
</h3>
<p className="text-gray-400 text-sm line-clamp-2 mb-4">
{project.description}
</p>
{project.last_commit && (
<p className="text-gray-400 text-xs mb-2">
Last commit: {new Date(project.last_commit).toLocaleDateString()}
</p>
)}
<div className="flex items-center text-purple-400 text-sm font-medium mt-auto">
Click to learn more
<svg
className="w-4 h-4 ml-1 group-hover:translate-x-1 transition-transform"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M9 5l7 7-7 7"
/>
</svg>
</div>
</div>
);
}
-125
View File
@@ -1,125 +0,0 @@
"use client";
import type { MiniProject } from "../types";
export function MiniProjectModal({
project,
onClose,
}: {
project: MiniProject;
onClose: () => void;
}) {
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm animate-fade-in"
onClick={onClose}
>
<div
className="relative max-w-3xl w-full max-h-[90vh] overflow-y-auto bg-gradient-to-br from-gray-900 to-black border border-purple-500/30 rounded-2xl shadow-2xl animate-scale-in"
onClick={(e) => e.stopPropagation()}
>
{/* Close Button */}
<button
onClick={onClose}
className="absolute top-4 right-4 p-2 rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors z-10"
>
<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="w-full h-64 overflow-hidden bg-black/40 rounded-t-2xl">
<img
src={project.image}
alt={project.title}
className="w-full h-full object-cover"
/>
</div>
)}
{/* Content */}
<div className="p-8 space-y-6">
<h2 className="text-3xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-purple-400 to-pink-500">
{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>
);
}
+5 -4
View File
@@ -10,6 +10,8 @@ export function Navbar() {
const navItems = [
{ label: "Home", path: "/" },
{ label: "Projects", path: "/projects" },
{ label: "Blog", path: "/blogs" },
{ label: "Connect", path: "/contact" },
];
@@ -18,14 +20,14 @@ export function Navbar() {
};
return (
<nav className="fixed top-8 left-1/2 -translate-x-1/2 z-[100] w-[min(90%,400px)]">
<div className="bg-black/20 backdrop-blur-xl border border-white/10 rounded-full px-6 py-3 flex items-center justify-between gap-4 shadow-2xl">
<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
key={item.path}
href={item.path}
onClick={() => handleSwitch(item.path)}
className={`text-sm font-semibold transition-all duration-300 px-4 py-2 rounded-full
className={`flex-1 text-center text-xs sm:text-sm font-semibold transition-all duration-300 px-3 py-2 rounded-full
${
pathname === item.path
? "bg-white text-black scale-105 shadow-xl shadow-white/10"
@@ -39,4 +41,3 @@ export function Navbar() {
</nav>
);
}
+71 -39
View File
@@ -1,68 +1,100 @@
"use client";
import type { Project } from "../types";
import { SIZE_LABELS } from "../types";
const SIZE_STYLES: Record<Project["size"], string> = {
Big: "border-white/25 bg-white/10 text-white",
MediumSized: "border-white/15 bg-white/[0.06] text-gray-200",
Small: "border-white/10 bg-white/[0.03] text-gray-400",
};
export function ProjectCard({ project }: { project: Project }) {
const loc = project.resolvedLoc ?? project.loc ?? null;
return (
<div className="group relative flex flex-col p-6 rounded-2xl bg-gradient-to-br from-white/10 to-white/5 backdrop-blur-sm border border-white/10 hover:border-purple-500/30 transition-all duration-300 hover:-translate-y-1 hover:shadow-2xl hover:shadow-purple-500/10">
{project.image && (
<div className="mb-6 overflow-hidden rounded-xl bg-black/20 aspect-video flex items-center justify-center relative">
<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 ${project.rounded === false ? "w-full h-auto max-h-24 rounded-lg" : "h-24 w-24 rounded-full"} shadow-lg group-hover:scale-110 transition-transform duration-500`}
className="max-h-40 w-auto max-w-full object-contain"
/>
</div>
)}
) : null}
<h3 className="text-xl font-bold text-white mb-2 group-hover:text-purple-400 transition-colors">
{project.name}
</h3>
<p className="text-gray-400 text-sm flex-grow mb-6 leading-relaxed">
{project.description}
</p>
{project.last_commit && (
<p className="text-gray-400 text-xs mb-2">
Last commit: {new Date(project.last_commit).toLocaleDateString()}
</p>
)}
<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>
<div className="flex gap-3 mt-auto">
<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-1 py-2 px-4 rounded-lg bg-white/10 hover:bg-white/20 text-white text-sm font-medium text-center transition-colors flex items-center justify-center gap-2"
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="p-2 rounded-lg bg-white/5 hover:bg-white/10 text-gray-400 hover:text-white transition-colors border border-white/5"
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>
);
-220
View File
@@ -1,220 +0,0 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { useEffect, useRef, useState } from "react";
interface TypingRoomIntroProps {
active: boolean;
onFinish: () => void;
}
export function TypingRoomIntro({ active, onFinish }: TypingRoomIntroProps) {
const [step, setStep] = useState(0);
const [garble, setGarble] = useState("");
const garbleRef = useRef<number | null>(null);
useEffect(() => {
if (!active) {
setStep(0);
return;
}
const timers = [
window.setTimeout(() => setStep(1), 280),
window.setTimeout(() => setStep(2), 900),
window.setTimeout(() => setStep(3), 1700),
window.setTimeout(() => setStep(4), 2800),
window.setTimeout(onFinish, 3600),
];
return () => {
timers.forEach((timer) => window.clearTimeout(timer));
};
}, [active, onFinish]);
// Garbled typing generator while Space is "typing"
useEffect(() => {
if (step >= 3 && step < 4) {
setGarble("");
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()[]{}<>~-=_+";
garbleRef.current = window.setInterval(() => {
// create a short random string to simulate garble
const len = 20;
let s = "";
for (let i = 0; i < len; i++) {
s += charset[Math.floor(Math.random() * charset.length)];
}
setGarble(s);
}, 120);
} else {
if (garbleRef.current) {
window.clearInterval(garbleRef.current);
garbleRef.current = null;
}
setGarble("");
}
return () => {
if (garbleRef.current) {
window.clearInterval(garbleRef.current);
garbleRef.current = null;
}
};
}, [step]);
useEffect(() => {
if (!active) {
return;
}
const originalOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = originalOverflow;
};
}, [active]);
useEffect(() => {
if (!active) {
return;
}
const handleEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
onFinish();
}
};
window.addEventListener("keydown", handleEscape);
return () => {
window.removeEventListener("keydown", handleEscape);
};
}, [active, onFinish]);
return (
<AnimatePresence>
{active && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, transition: { duration: 0.45, ease: "easeInOut" } }}
className="fixed inset-0 z-[220] flex items-center justify-center bg-[#020205] px-4"
aria-live="polite"
role="dialog"
aria-modal="true"
>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_20%_20%,rgba(56,189,248,0.25),transparent_45%),radial-gradient(circle_at_80%_70%,rgba(59,130,246,0.16),transparent_45%),#020205]" />
<div className="absolute inset-0 bg-[linear-gradient(transparent_95%,rgba(255,255,255,0.03)_100%)] bg-[length:100%_6px] opacity-60" />
<motion.div
initial={{ y: 32, scale: 0.96, opacity: 0 }}
animate={{ y: 0, scale: 1, opacity: 1 }}
exit={{ y: -24, scale: 0.98, opacity: 0 }}
transition={{ duration: 0.55, ease: [0.22, 1, 0.36, 1] }}
className="relative w-full max-w-2xl 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)]"
>
<div className="flex items-center justify-between border-b border-white/10 bg-white/5 px-5 py-3">
<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-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-[80%] rounded-2xl rounded-bl-md border border-white/10 bg-white/10 px-4 py-3 text-sm text-gray-100"
>
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-[85%] rounded-2xl rounded-br-md border border-cyan-300/30 bg-cyan-400/15 px-4 py-3 text-sm text-cyan-50"
>
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-[85%] rounded-2xl rounded-br-md border border-cyan-300/35 bg-cyan-400/20 px-4 py-3 text-sm text-cyan-50"
>
Ok done, just one more animation
</motion.div>
)}
</div>
<div className="relative border-t border-white/10 bg-black/35 px-6 pb-5 pt-4">
{/* Small badge left-above the input showing typing status (hidden once final message shows) */}
{step < 4 && (
<div className="absolute left-6 -top-6 flex items-center gap-2">
<span className="text-sm font-medium text-cyan-100">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 rounded-xl border border-white/10 bg-white/5 px-4 py-2.5 min-h-[44px] flex items-center">
{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-sm text-cyan-100">
<span className="select-all">{garble || "…"}</span>
</motion.div>
) : (
<p className="text-sm text-gray-500">Message Space</p>
)}
</div>
</div>
<motion.div
initial={{ scaleX: 0 }}
animate={{ scaleX: step >= 4 ? 1 : step / 4 }}
transition={{ duration: 0.45, ease: "easeOut" }}
className="h-1 origin-left bg-gradient-to-r from-cyan-300 via-blue-400 to-cyan-300"
/>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}
-35
View File
@@ -1,35 +0,0 @@
import type { Experience } from "../types";
export function getTypeIcon(type: Experience["type"]) {
switch (type) {
case "languages":
return "💻";
case "software":
return "☁️";
case "plattforms":
return "🚀";
case "experience":
return "🌍";
case "other":
return "📦";
default:
return "📦";
}
}
export function getTypeColor(type: Experience["type"]) {
switch (type) {
case "languages":
return "bg-blue-500/20";
case "software":
return "bg-purple-500/20";
case "plattforms":
return "bg-green-500/20";
case "experience":
return "bg-orange-500/20";
case "other":
return "bg-gray-500/20";
default:
return "bg-gray-500/20";
}
}
+34 -131
View File
@@ -1,152 +1,55 @@
"use client";
import React, { createContext, useContext, useEffect, useState, useMemo } from "react";
import type { Experience, MiniProject, Project, RealWork } 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[];
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 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([]); }
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 */
}
}
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;
};
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([]));
fetchRealWork();
}, []);
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,
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() {
+37
View File
@@ -0,0 +1,37 @@
import { NextResponse } from "next/server";
import { isAdmin } from "@/lib/auth";
import { ValidationError } from "@/lib/dto";
import { Prisma } from "@/generated/prisma/client";
/** 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 });
}
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002") {
return NextResponse.json({ error: "A unique field already uses that value" }, { status: 409 });
}
console.error(err);
return NextResponse.json({ error: "Internal error" }, { status: 500 });
}
}
export async function readBody(req: Request): Promise<Record<string, unknown>> {
try {
const body = await req.json();
return body && typeof body === "object" ? (body as Record<string, unknown>) : {};
} catch {
return {};
}
}
+50
View File
@@ -0,0 +1,50 @@
import crypto from "node:crypto";
import { cookies } from "next/headers";
export const SESSION_COOKIE = "admin_session";
const SESSION_TTL_MS = 1000 * 60 * 60 * 24 * 7; // 7 days
function getSecret() {
return process.env.SESSION_SECRET || "insecure-dev-secret-change-me";
}
function sign(value: string) {
return crypto.createHmac("sha256", getSecret()).update(value).digest("base64url");
}
/** Build a signed `<expiry>.<signature>` session token. */
export function createSessionToken(): string {
const expiry = String(Date.now() + SESSION_TTL_MS);
return `${expiry}.${sign(expiry)}`;
}
/** Verify a token's signature (constant-time) and expiry. */
export function verifySessionToken(token: string | undefined | null): boolean {
if (!token) return false;
const [expiry, signature] = token.split(".");
if (!expiry || !signature) return false;
const expected = sign(expiry);
const a = Buffer.from(signature);
const b = Buffer.from(expected);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return false;
const expiryMs = Number(expiry);
return Number.isFinite(expiryMs) && expiryMs > Date.now();
}
/** Constant-time password comparison against ADMIN_PASSWORD. */
export function verifyPassword(candidate: string): boolean {
const expected = process.env.ADMIN_PASSWORD;
if (!expected) return false;
const a = Buffer.from(candidate);
const b = Buffer.from(expected);
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}
/** True when the incoming request carries a valid admin session cookie. */
export async function isAdmin(): Promise<boolean> {
const store = await cookies();
return verifySessionToken(store.get(SESSION_COOKIE)?.value);
}
+63
View File
@@ -0,0 +1,63 @@
import type {
BlogAuthor as PrismaBlogAuthor,
BlogPost as PrismaBlogPost,
Prisma,
} from "@/generated/prisma/client";
import type { BlogAuthor, BlogPost, BlogPostSummary } from "@/types";
export const blogPostInclude = {
authors: {
orderBy: {
sortOrder: "asc",
},
},
} satisfies Prisma.BlogPostInclude;
type PrismaBlogPostWithAuthors = PrismaBlogPost & {
authors: PrismaBlogAuthor[];
};
function countWords(content: string): number {
return content
.trim()
.split(/\s+/)
.filter(Boolean).length;
}
export function getReadingTimeMinutes(content: string): number {
return Math.max(1, Math.ceil(countWords(content) / 220));
}
function toBlogAuthor(author: PrismaBlogAuthor): BlogAuthor {
return {
id: author.id,
type: author.type as BlogAuthor["type"],
name: author.name,
website: author.website,
imageUrl: author.imageUrl,
};
}
export function toBlogSummary(post: PrismaBlogPostWithAuthors): BlogPostSummary {
return {
id: post.id,
title: post.title,
slug: post.slug,
excerpt: post.excerpt,
coverImageUrl: post.coverImageUrl,
authors: post.authors.map(toBlogAuthor),
isPublished: post.isPublished,
views: post.views,
readingTimeMinutes: getReadingTimeMinutes(post.content),
publishedAt: post.publishedAt?.toISOString() ?? null,
createdAt: post.createdAt.toISOString(),
updatedAt: post.updatedAt.toISOString(),
};
}
export function toBlogPost(post: PrismaBlogPostWithAuthors): BlogPost {
return {
...toBlogSummary(post),
content: post.content,
};
}
+184
View File
@@ -0,0 +1,184 @@
// 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";
import { BLOG_AUTHOR_TYPES, type BlogAuthorType } from "@/types";
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;
}
function parseBlogAuthorType(value: unknown, field: string): BlogAuthorType {
if (typeof value !== "string" || !BLOG_AUTHOR_TYPES.includes(value as BlogAuthorType)) {
throw new ValidationError(`"${field}" must be one of: ${BLOG_AUTHOR_TYPES.join(", ")}`);
}
return value as BlogAuthorType;
}
function parseBlogAuthors(value: unknown) {
if (!Array.isArray(value)) return [];
const authors = value
.map((entry, index) => {
if (!entry || typeof entry !== "object") return null;
const body = entry as Record<string, unknown>;
const name = optStr(body.name);
const website = optStr(body.website);
const imageUrl = optStr(body.imageUrl);
const hasValues = Boolean(name || website || imageUrl || body.type);
if (!hasValues) return null;
if (!name) throw new ValidationError(`"authors[${index}].name" is required`);
return {
type: parseBlogAuthorType(body.type ?? "author", `authors[${index}].type`),
name,
website,
imageUrl,
sortOrder: index,
};
})
.filter((author): author is NonNullable<typeof author> => author !== null);
if (authors.length > 5) {
throw new ValidationError("Too many authors (max 5)");
}
return authors;
}
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),
};
}
function slug(value: unknown): string {
const normalized = str(value, "slug")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
if (!normalized) {
throw new ValidationError('"slug" is invalid');
}
return normalized;
}
export function parseBlogPost(body: Record<string, unknown>) {
const isPublished = bool(body.isPublished);
const publishedAt = optDate(body.publishedAt);
return {
title: str(body.title, "title"),
slug: slug(body.slug),
excerpt: str(body.excerpt, "excerpt"),
coverImageUrl: optStr(body.coverImageUrl),
content: str(body.content, "content"),
authors: parseBlogAuthors(body.authors),
isPublished,
publishedAt: isPublished ? publishedAt ?? new Date() : publishedAt,
};
}
+40
View File
@@ -0,0 +1,40 @@
// In-memory, TTL-based cache for lines-of-code counts fetched from a project's
// `locEndpoint`. The endpoint is expected to return a plaintext integer.
type CacheEntry = { value: number | null; expiresAt: number };
const CACHE_TTL_MS = 1000 * 60 * 10; // 10 minutes
const cache = new Map<string, CacheEntry>();
async function fetchLocFromEndpoint(endpoint: string): Promise<number | null> {
const cached = cache.get(endpoint);
if (cached && cached.expiresAt > Date.now()) return cached.value;
let value: number | null = null;
try {
const res = await fetch(endpoint, { signal: AbortSignal.timeout(5000) });
if (res.ok) {
const text = (await res.text()).trim();
const parsed = Number.parseInt(text.replace(/[^0-9]/g, ""), 10);
value = Number.isFinite(parsed) ? parsed : null;
}
} catch {
value = null;
}
cache.set(endpoint, { value, expiresAt: Date.now() + CACHE_TTL_MS });
return value;
}
/**
* Resolve a project's line-of-code count: prefer the manually provided `loc`,
* otherwise hit `locEndpoint` (cached in memory). Returns null when unknown.
*/
export async function resolveLoc(
loc: number | null | undefined,
locEndpoint: string | null | undefined,
): Promise<number | null> {
if (typeof loc === "number") return loc;
if (locEndpoint) return fetchLocFromEndpoint(locEndpoint);
return null;
}
+21
View File
@@ -0,0 +1,21 @@
import { PrismaClient } from "@/generated/prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
function createClient() {
const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
throw new Error("DATABASE_URL is not set");
}
const adapter = new PrismaPg({ connectionString });
return new PrismaClient({ adapter });
}
export const prisma = globalForPrisma.prisma ?? createClient();
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}
+63 -8
View File
@@ -1,22 +1,45 @@
"use client";
import { useEffect, useState } from "react";
const activitySvg =
"https://gitact.spaceistyping.com/activity.svg?days=365&source=all&theme=dark";
export function Activity() {
const [dailyActivity, setDailyActivity] = useState<string | null>(null);
const [projectCount, setProjectCount] = useState<number | null>(null);
useEffect(() => {
// Fetch a string of how long i have coded today, e.g. "2h 35m"
fetch(
"https://shsf-api.reversed.dev/api/exec/6/842aa52f-1a9e-43e1-b630-00286b44897a",
)
.then((response) => response.json())
.then((hourText) => {
setDailyActivity(hourText.text);
setProjectCount(hourText.across);
});
}, []);
return (
<section className="w-full max-w-6xl mx-auto space-y-8 px-4">
<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-3xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-blue-400 to-purple-500">
Activity
</h2>
<p className="text-gray-400 max-w-2xl mx-auto">
A live snapshot of my recent Git activity.
<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&apos;ve already been coding for
<span className="font-mono text-base text-green-400 ml-1">
{dailyActivity || "..."}
</span>
, across approximately
<span className="font-mono text-base text-green-400 ml-1">
{projectCount !== null ? projectCount : "..."}
</span>{" "}
projects .
</p>
</div>
<div className="max-w-5xl mx-auto rounded-3xl border border-white/10 bg-white/5 backdrop-blur-sm p-4 md:p-6 shadow-[0_20px_80px_rgba(0,0,0,0.35)]">
<div className="overflow-hidden rounded-2xl border border-white/10 bg-[#0a0a12]">
<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 bg-[#0d1117]">
<img
src={activitySvg}
alt="Git activity graph"
@@ -26,6 +49,38 @@ export function Activity() {
/>
</div>
</div>
<div>
<p className="text-center text-gray-500 text-sm">
Data from my & my agents <strong>public</strong>{" "}
<a
href="https://github.com/Space-Banane"
className="text-blue-400 hover:underline"
>
Github
</a>{" "}
&{" "}
<a
href="https://gitea.reversed.dev/space"
className="text-blue-400 hover:underline"
>
Gitea
</a>{" "}
contributions. Via{" "}
<a
href="https://gitea.reversed.dev/space/git-activity-merger"
className="text-blue-400 hover:underline"
>
Git Activity Merger
</a>
. Possibly cached & delayed.
</p>
<p className="text-center text-gray-500 text-sm max-w-2xl justify-center mx-auto mt-1">
Coding time is estimated based on Wakapi data, which tracks my coding
activity across a lot of projects, but also gets things wrong. So take
it with a grain of salt. An <a href="https://github.com/Space-Banane/shsf" className="text-blue-400 hover:underline">SHSF Function</a> fetches the data and makes it available via an API.
</p>
</div>
</section>
);
}
+43 -35
View File
@@ -1,60 +1,65 @@
"use client";
type Affiliate = {
name: string;
good: string[];
bad: string[];
link: string;
icon: string;
};
const affiliates: Affiliate[] = [
{
name: "SparkedHost",
good: ["Decent Support", "Good Bot Hosting", "Generous Webhosting"],
bad: ["Staff"],
link: "https://billing.sparkedhost.com/aff.php?aff=1843",
icon: "https://sparkedhost.com/_next/static/media/logo-text.fce7e4c5.svg",
},
{
name: "Datalix",
good: ["Uptime", "Hardware", "Prices", "Support"],
bad: ["nothin"],
link: "https://datalix.de/a/space",
icon: "https://cdn.datalix.de/images/header.png",
},
];
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-8 px-4">
<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-3xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-blue-400 to-purple-500">
Affiliates
</h2>
<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 md:grid-cols-2 gap-5">
<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="rounded-xl border border-white/10 bg-white/5 backdrop-blur-sm p-4 hover:bg-white/10 hover:border-purple-500/40 transition-all duration-300"
className="w-full rounded-xl border border-white/10 bg-white/[0.03] p-4 transition-all duration-300 hover:border-white/25 hover:bg-white/[0.06]"
>
<div className="flex flex-col items-center text-center gap-2 mb-3">
<div className="mb-3 flex flex-col items-center gap-2 text-center">
{affiliate.icon ? (
<img
src={affiliate.icon}
alt={affiliate.name}
className="h-7 max-w-[130px] w-auto object-contain rounded"
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="mt-1 text-[10px] font-medium uppercase leading-none tracking-widest text-gray-500">
{affiliate.location}
</p>
</div>
</div>
<div className="space-y-3">
{affiliate.provides.length > 0 ? (
<div>
<p className="text-xs font-semibold text-emerald-300 mb-1.5">Good</p>
<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>
) : 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
@@ -67,8 +72,10 @@ export function Affiliates() {
))}
</div>
</div>
) : null}
{affiliate.bad.length > 0 ? (
<div>
<p className="text-xs font-semibold text-rose-300 mb-1.5">Bad</p>
<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
@@ -81,6 +88,7 @@ export function Affiliates() {
))}
</div>
</div>
) : null}
</div>
</a>
))}
+10 -10
View File
@@ -1,19 +1,19 @@
"use client";
import { CONTACT_EMAIL } from "../types";
export function Contact() {
return (
<section className="w-full max-w-2xl text-center space-y-4 pb-8 mx-auto">
<h2 className="text-3xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-green-400 via-blue-500 to-purple-500">
Want to talk?
</h2>
<div className="p-8 rounded-3xl bg-gradient-to-br from-blue-500/10 via-purple-500/10 to-pink-500/10 border border-blue-500/20 backdrop-blur-md">
<p className="text-gray-300 mb-8 text-lg">
<section className="mx-auto w-full max-w-2xl space-y-4 pb-8 text-center">
<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 sm:flex-row gap-4 justify-center">
<div className="flex flex-col justify-center gap-3 sm:flex-row sm:gap-4">
<a
href="mailto:space@reversed.dev"
className="px-8 py-3 rounded-full bg-gradient-to-r from-white to-gray-100 text-black font-bold hover:from-gray-100 hover:to-white transition-all shadow-lg shadow-white/20 hover:shadow-white/30"
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
</a>
@@ -21,7 +21,7 @@ export function Contact() {
href="https://discord.com/users/456443941169004545"
target="_blank"
rel="noreferrer"
className="px-8 py-3 rounded-full bg-gradient-to-r from-[#5865F2] to-[#4752C4] text-white font-bold hover:from-[#4752C4] hover:to-[#5865F2] transition-all shadow-lg shadow-[#5865F2]/30 hover:shadow-[#5865F2]/50"
className="w-full rounded-full bg-gradient-to-r from-[#5865F2] to-[#4752C4] px-6 py-3 font-bold text-white transition-all shadow-lg shadow-[#5865F2]/30 hover:from-[#4752C4] hover:to-[#5865F2] hover:shadow-[#5865F2]/50 sm:w-auto"
>
Chat on Discord
</a>
+49
View File
@@ -0,0 +1,49 @@
"use client";
import Link from "next/link";
import { useProfile } from "../context/ProfileContext";
import { ProjectCard } from "../components/ProjectCard";
// Homepage teaser: the first 3 projects (ordered by size then name, per the API)
// plus a button through to the full /projects page.
export function FeaturedProjects() {
const { projects, loading } = useProfile();
const top = projects.slice(0, 3);
return (
<section className="mx-auto w-full max-w-6xl space-y-8 px-4 sm:space-y-10">
<div className="space-y-3 text-center">
<h2 className="text-3xl font-semibold text-white sm:text-4xl">Featured Projects</h2>
<p className="mx-auto max-w-2xl text-sm text-gray-500 sm:text-base">
A few things I&apos;ve been building lately.
</p>
</div>
{loading ? (
<p className="text-center text-gray-500">Loading projects</p>
) : top.length === 0 ? (
<div className="rounded-2xl border border-white/10 bg-white/5 px-5 py-8 text-center text-gray-400">
No projects published yet.
</div>
) : (
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3 sm:gap-8">
{top.map((project) => (
<ProjectCard key={project.id} project={project} />
))}
</div>
)}
<div className="flex justify-center">
<Link
href="/projects"
className="inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/5 px-6 py-3 text-sm font-semibold text-white transition-all duration-300 hover:border-white/30 hover:bg-white/10"
>
View all projects
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M14 5l7 7m0 0l-7 7m7-7H3" />
</svg>
</Link>
</div>
</section>
);
}
+4 -5
View File
@@ -2,6 +2,7 @@
import { useUmami } from "@danielgtmn/umami-react";
import { Github, Mail, MessageSquare } from "lucide-react";
import { CONTACT_EMAIL } from "../types";
export function Footer() {
const currentYear = new Date().getFullYear();
@@ -29,12 +30,10 @@ export function Footer() {
<Github className="w-5 h-5" />
</a>
<a
href="mailto:space@reversed.dev"
href={`mailto:${CONTACT_EMAIL}`}
className="hover:text-purple-400 transition-colors"
aria-label="Email"
onClick={() =>
handleLinkClick("Email", "mailto:space@reversed.dev")
}
onClick={() => handleLinkClick("Email", "mailto")}
>
<Mail className="w-5 h-5" />
</a>
@@ -58,7 +57,7 @@ export function Footer() {
{/* Branding & Attribution */}
<div className="flex flex-col items-center gap-2 text-center">
<p className="text-gray-500 text-xs tracking-wide">
&copy; {currentYear} &bull; Space-Banane
&copy; {currentYear} &bull; Paul W.
</p>
<div className="flex items-center gap-3 text-[10px] text-gray-600 uppercase tracking-[0.2em]">
<a
-93
View File
@@ -1,93 +0,0 @@
"use client";
export function Goals() {
return (
<section className="w-full space-y-8">
<div className="text-center space-y-2">
<h2 className="text-3xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-blue-400 to-purple-500">
My Goals
</h2>
<p className="text-gray-400 max-w-2xl mx-auto">Next 4 Years are going to look fun</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="p-6 rounded-2xl bg-gradient-to-br from-blue-500/10 to-purple-500/5 backdrop-blur-sm border border-blue-500/20">
<div className="flex items-start gap-4">
<div className="p-3 rounded-lg bg-blue-500/20 text-3xl">🚀</div>
<div>
<h3 className="text-xl font-bold text-white mb-2">
Not Just Semi-Fullstack
</h3>
<p className="text-gray-400 leading-relaxed">
I want to work more with Serverless Architectures and Cloud
Services to build scalable applications.
</p>
</div>
</div>
</div>
<div className="p-6 rounded-2xl bg-gradient-to-br from-purple-500/10 to-pink-500/5 backdrop-blur-sm border border-purple-500/20">
<div className="flex items-start gap-4">
<div className="p-3 rounded-lg bg-purple-500/20 text-3xl">
💻
</div>
<div>
<h3 className="text-xl font-bold text-white mb-2">
Contribute More to Open Source
</h3>
<p className="text-gray-400 leading-relaxed">
I want to commit more to Open-Source Projects. That's it...
</p>
</div>
</div>
</div>
<div className="p-6 rounded-2xl bg-gradient-to-br from-green-500/10 to-blue-500/5 backdrop-blur-sm border border-green-500/20">
<div className="flex items-start gap-4">
<div className="p-3 rounded-lg bg-green-500/20 text-3xl">
</div>
<div>
<h3 className="text-xl font-bold text-white mb-2">
Expand on Existing Projects
</h3>
<p className="text-gray-400 leading-relaxed">
I want to make SHSF more stable and usable.
</p>
</div>
</div>
</div>
<div className="p-6 rounded-2xl bg-gradient-to-br from-yellow-500/10 to-orange-500/5 backdrop-blur-sm border border-yellow-500/20">
<div className="flex items-start gap-4">
<div className="p-3 rounded-lg bg-yellow-500/20 text-3xl">
🧪
</div>
<div>
<h3 className="text-xl font-bold text-white mb-2">
Testing before Breaking
</h3>
<p className="text-gray-400 leading-relaxed">In the future i want to write more tests and improve my code quality.</p>
</div>
</div>
</div>
<div className="md:col-span-2 p-6 rounded-2xl bg-gradient-to-br from-yellow-500/10 to-orange-500/5 backdrop-blur-sm border border-yellow-500/20">
<div className="flex items-start gap-4">
<div className="p-3 rounded-lg bg-yellow-500/20 text-3xl">
🏠
</div>
<div>
<h3 className="text-xl font-bold text-white mb-2">
Embrace Self-Hosting
</h3>
<p className="text-gray-400 leading-relaxed">
I want to learn more about self-hosting my own services and reduce reliance on cloud providers for personal projects.
</p>
</div>
</div>
</div>
</div>
</section>
);
}
+126 -115
View File
@@ -1,29 +1,62 @@
import { useState, useRef, useEffect } from "react";
import { useRouter } from "next/navigation";
"use client";
export function Hero({
glowColor,
borderStatus,
displayMessage,
rotatingMessages,
statusMessage,
oldUsernames,
}: {
glowColor: string;
borderStatus: string;
displayMessage: string;
rotatingMessages: string[];
statusMessage: string;
oldUsernames: string[];
}) {
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();
@@ -32,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);
}
}
@@ -55,119 +85,100 @@ 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-20 flex flex-col items-center text-center 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 w-48 h-48 rounded-full border-4 transition-colors duration-500 overflow-hidden ${borderStatus} bg-black`}
>
<img
src="https://cdn.reversed.dev/pictures/20250405_120402.png"
alt="Space"
className="w-full h-full object-cover scale-110 transition-transform duration-700 group-hover:scale-125"
/>
</div>
{/* Rotating Message Bubble (Left Side) */}
<div className="absolute -left-4 top-4 -translate-x-full">
<div className="relative bg-gradient-to-br from-purple-500/20 to-pink-500/10 backdrop-blur-sm border border-purple-500/30 rounded-2xl px-4 py-3 shadow-lg">
{/* Arrow pointing to profile */}
<div className="absolute right-0 top-1/2 translate-x-2 -translate-y-1/2 w-0 h-0 border-t-8 border-t-transparent border-b-8 border-b-transparent border-l-8 border-l-purple-500/30"></div>
<div className="absolute right-0 top-1/2 translate-x-1.5 -translate-y-1/2 w-0 h-0 border-t-8 border-t-transparent border-b-8 border-b-transparent border-l-8 border-l-purple-500/20"></div>
<p className="text-sm text-gray-200 whitespace-nowrap font-medium font-mono">
{displayMessage || rotatingMessages[0]}
</p>
</div>
</div>
{/* Status Bubble (Right Side) */}
<div className="absolute -right-4 top-8 translate-x-full">
<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>
</div>
</div>
{/* Name & Description */}
<div className="space-y-4 max-w-2xl">
<h1 className="text-6xl md:text-7xl font-extrabold">
Hey, I'm{" "}
<section className="mx-auto w-full max-w-5xl px-4">
<div className="flex flex-col-reverse items-center gap-8 sm:flex-row sm:items-start sm:justify-between sm:gap-10">
{/* Left: identity */}
<div className="flex-1 space-y-5 text-center sm:text-left">
<div className="space-y-2">
<h1 className="flex items-center justify-center gap-3 text-4xl font-extrabold text-white sm:justify-start sm:text-5xl">
Paul&nbsp;W.
<span
className="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>
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="text-2xl text-gray-400 font-light">
A{" "}
<span className="line-through decoration-purple-500/50 decoration-2">
Self-proclaimed
</span>{" "}
Developer breaking things to see how they&nbsp;work.
<p className="font-mono text-sm text-gray-500">{HANDLE}</p>
</div>
<p className="mx-auto max-w-xl text-base leading-relaxed text-gray-400 sm:mx-0 sm:text-lg">
Full-stack developer &amp; open-source author. Building server
infrastructure, developer tools, and web applications.
</p>
{/* Luna AI Profile Link */}
<div className="pt-4 flex justify-center">
<div className="flex items-center gap-3">
<div className="flex flex-wrap items-center justify-center gap-3 sm:justify-start">
<a
href="https://luna.reversed.dev"
target="_blank"
rel="noopener noreferrer"
className="group relative flex items-center gap-3 px-6 py-2.5 rounded-full bg-white/5 border border-white/10 hover:bg-white/10 hover:border-purple-500/50 transition-all duration-300 shadow-lg hover:shadow-purple-500/10"
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 px-4 py-2 rounded-full bg-white/5 border border-white/10 hover:bg-white/10 hover:border-gray-400 transition-all duration-300 shadow-sm"
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 px-4 py-2 rounded-full bg-white/5 border border-white/10 hover:bg-white/10 hover:border-yellow-400 transition-all duration-300 shadow-sm"
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>
{/* Right: avatar with live status ring */}
<div className="relative shrink-0" onMouseMove={handleAvatarMouseMove}>
<div className={`absolute -inset-1 rounded-full blur-md transition-colors duration-500 ${style.glow}`} />
<div
className={`relative h-32 w-32 overflow-hidden rounded-full border-4 bg-black transition-colors duration-500 sm:h-44 sm:w-44 ${style.ring}`}
>
<img
src="/api/profile-image"
alt="Paul W."
className="h-full w-full scale-105 object-cover"
/>
</div>
</div>
</div>
</section>
+53
View File
@@ -0,0 +1,53 @@
"use client";
export function Luna() {
return (
<section className="mx-auto w-full max-w-4xl px-4">
<div className="rounded-3xl border border-white/10 bg-white/[0.03] p-6 sm:p-8 md:p-10">
<div className="space-y-5">
<div className="flex items-center gap-3">
<span className="h-2.5 w-2.5 rounded-full bg-white/70" />
<p className="text-xs font-medium uppercase tracking-[0.25em] text-gray-500">
AI Agent
</p>
</div>
<h2 className="text-2xl font-semibold text-white sm:text-3xl">Meet Luna</h2>
<p className="max-w-2xl text-base leading-relaxed text-gray-400 sm:text-lg">
Luna is a personal agent running in{" "}
<span className="font-medium text-gray-200">Openclaw</span> on my homelab. Luna
codes, reviews PRs, and handles a bunch of other tasks throughout the day.
</p>
<div className="flex flex-wrap gap-2">
{["Writes code", "Reviews PRs", "Runs on Openclaw", "OpenAI (ChatGPT Plus)"].map(
(tag) => (
<span
key={tag}
className="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs font-medium text-gray-300"
>
{tag}
</span>
),
)}
</div>
<div className="pt-1">
<a
href="https://luna.spaceistyping.com"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/5 px-5 py-2.5 text-sm font-semibold text-white transition-all duration-300 hover:border-white/30 hover:bg-white/10"
>
Visit Luna
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M14 5l7 7m0 0l-7 7m7-7H3" />
</svg>
</a>
</div>
</div>
</div>
</section>
);
}
-92
View File
@@ -1,92 +0,0 @@
"use client";
import { cn } from "../components/cn";
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 server provider" },
{ name: "Home server", description: "Self-hosted option" },
],
},
];
export function TechStack() {
return (
<section className="w-full max-w-4xl mx-auto px-4 space-y-8 mt-8">
<div className="text-center space-y-2">
<h2 className="text-3xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-cyan-400 to-blue-500">
Tech Stack
</h2>
<p className="text-gray-400 max-w-2xl mx-auto">
My current infrastructure and software stack, from server to
monitoring.
</p>
</div>
<div className="space-y-4">
{techStack.map((layer) => (
<div
key={layer.title}
className="p-6 md:p-8 rounded-2xl bg-gradient-to-br from-cyan-500/10 to-blue-500/5 backdrop-blur-sm border border-cyan-500/20 space-y-5"
>
<h3 className="text-2xl font-semibold text-white">{layer.title}</h3>
<ul className="flex flex-wrap gap-2">
{layer.items.map((item) => (
<li
key={item.name}
className="px-3 py-1 rounded-full text-xs font-semibold bg-cyan-500/20 text-cyan-200 border border-cyan-500/30"
>
<span className="font-medium">{item.name}</span>
<span className="ml-2 text-[10px] text-cyan-100/70 font-normal border-l border-cyan-500/30 pl-2">
{item.description}
</span>
</li>
))}
</ul>
</div>
))}
</div>
</section>
);
}
+69
View File
@@ -0,0 +1,69 @@
"use client";
import { useEffect, useState } from "react";
import type { BlogPostSummary } from "@/types";
import { BlogCard } from "@/components/BlogCard";
export function TopBlogs() {
const [blogs, setBlogs] = useState<BlogPostSummary[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
fetch("/api/blogs/top")
.then((res) => (res.ok ? res.json() : []))
.then((json) => {
if (!cancelled) {
setBlogs(Array.isArray(json) ? json : []);
}
})
.catch(() => {
if (!cancelled) setBlogs([]);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
return (
<section className="mx-auto w-full max-w-6xl space-y-8 px-4">
<div className="space-y-3 text-center">
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-gray-500">
blog
</p>
<h2 className="text-3xl font-semibold text-white sm:text-4xl">
recent writing
</h2>
<p className="mx-auto max-w-2xl text-sm text-gray-500 sm:text-base">
Notes on infrastructure, software, and the things I am building.
</p>
</div>
{loading ? (
<div className="grid grid-cols-1 gap-5 md:grid-cols-3">
{Array.from({ length: 3 }).map((_, index) => (
<div
key={index}
className="h-56 animate-pulse rounded-[1.75rem] border border-white/10 bg-white/[0.03]"
/>
))}
</div>
) : blogs.length === 0 ? (
<div className="rounded-[1.75rem] border border-white/10 bg-white/[0.03] px-6 py-10 text-center text-gray-400">
No blog posts published yet.
</div>
) : (
<div className="grid grid-cols-1 gap-5 md:grid-cols-3">
{blogs.map((blog, index) => (
<BlogCard key={blog.id} blog={blog} index={index} />
))}
</div>
)}
</section>
);
}
-36
View File
@@ -1,36 +0,0 @@
"use client";
export function Uptime() {
return (
<section className="w-full max-w-6xl mx-auto space-y-10 px-4">
<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">
Downtime
</h2>
</div>
<div className="max-w-3xl mx-auto">
<div className="p-6 rounded-2xl bg-gradient-to-br from-gray-800/20 to-transparent backdrop-blur-sm border border-white/6">
<h3 className="text-2xl font-semibold text-white mb-4 text-center">
I love my homelab
</h3>
<div className="flex gap-6 items-center justify-between">
<div className="flex-1 text-center">
<img src="/gh_down.png" alt="GitHub downtime" className="mx-auto h-28 object-contain" />
<h4 className="mt-3 font-medium text-white">GitHub</h4>
<p className="text-gray-400 mt-1">Goes down more.</p>
</div>
<div className="w-px h-24 bg-white/6" />
<div className="flex-1 text-center">
<img src="/homelab_down.png" alt="HomeLab" className="mx-auto h-28 object-contain" />
<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>
);
}
+60 -79
View File
@@ -1,108 +1,89 @@
"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",
});
}
}
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),
);
export function WorkExperience({ experiences }: WorkExperienceProps) {
return (
<section className="w-full max-w-4xl mx-auto px-4 space-y-8">
<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-3xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-cyan-400 to-blue-500">
Work Experience
</h2>
<p className="text-gray-400 max-w-2xl mx-auto">
Professional collaborations and product work I have contributed to.
<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>
{realWork.length === 0 ? (
<div className="p-6 md:p-8 rounded-2xl bg-gradient-to-br from-cyan-500/10 to-blue-500/5 backdrop-blur-sm border border-cyan-500/20 text-center">
<p className="text-gray-300">No work experience entries available yet.</p>
{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">
{sortedRealWork.map((entry, index) => (
{experiences.map((entry) => (
<div
key={`${entry.company}-${index}`}
className="p-6 md:p-8 rounded-2xl bg-gradient-to-br from-cyan-500/10 to-blue-500/5 backdrop-blur-sm border border-cyan-500/20 space-y-5"
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 sm:flex-row sm:items-center sm:justify-between gap-2">
<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-2xl font-semibold text-white">{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"}
<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>
) : null}
</div>
</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"
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="true"
className="h-4 w-4"
>
<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>
@@ -110,14 +91,14 @@ export function WorkExperience({ realWork }: WorkExperienceProps) {
) : null}
</div>
<p className="text-gray-300 leading-relaxed">{entry.summary}</p>
<p className="leading-relaxed text-gray-300">{entry.summary}</p>
{entry.tags && entry.tags.length > 0 ? (
{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"
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>
+80 -33
View File
@@ -1,43 +1,90 @@
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 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 BLOG_AUTHOR_TYPES = ["author", "co-author", "relating"] as const;
export type BlogAuthorType = (typeof BLOG_AUTHOR_TYPES)[number];
export interface BlogAuthor {
id: string;
type: BlogAuthorType;
name: string;
website?: string | null;
imageUrl?: string | null;
}
export interface BlogPostSummary {
id: string;
title: string;
slug: string;
excerpt: string;
coverImageUrl?: string | null;
authors: BlogAuthor[];
isPublished: boolean;
views: number;
readingTimeMinutes: number;
publishedAt?: string | null;
createdAt: string;
updatedAt: string;
}
export interface BlogPost extends BlogPostSummary {
content: string;
}
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";