Compare commits
19 Commits
da9103faaa
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6552273ec0 | |||
| c0c3658628 | |||
| 589656e09b | |||
| 7074f8d1d6 | |||
| 7e38f89e37 | |||
| 3a4dc01b90 | |||
| 6df4983f74 | |||
| 7ae6aba990 | |||
| ffb57a9f34 | |||
| 76b1c79104 | |||
| 912e5edb81 | |||
| ef63164d58 | |||
| 5fea1541f8 | |||
| dfbe14d284 | |||
| 2045e95929 | |||
| 1148f6f22a | |||
| ffdf9dabdc | |||
| aebeed3964 | |||
| 40526e115e |
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "portfolio-dev",
|
||||
"runtimeExecutable": "pnpm",
|
||||
"runtimeArgs": ["run", "dev"],
|
||||
"port": 5173
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -42,3 +42,6 @@ next-env.d.ts
|
||||
|
||||
old/
|
||||
.next
|
||||
|
||||
# prisma generated client
|
||||
/src/generated
|
||||
Binary file not shown.
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
Generated
+1733
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
||||
import "dotenv/config";
|
||||
import { defineConfig, env } from "prisma/config";
|
||||
|
||||
export default defineConfig({
|
||||
schema: "prisma/schema.prisma",
|
||||
migrations: {
|
||||
path: "prisma/migrations",
|
||||
},
|
||||
datasource: {
|
||||
url: env("DATABASE_URL"),
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ProjectSize" AS ENUM ('Big', 'MediumSized', 'Small');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Project" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"label" TEXT NOT NULL,
|
||||
"size" "ProjectSize" NOT NULL DEFAULT 'MediumSized',
|
||||
"imageUrl" TEXT,
|
||||
"link" TEXT NOT NULL,
|
||||
"linkIsDemo" BOOLEAN NOT NULL DEFAULT false,
|
||||
"sourceCode" TEXT,
|
||||
"description" TEXT NOT NULL,
|
||||
"why" TEXT NOT NULL,
|
||||
"note" TEXT,
|
||||
"tags" TEXT[],
|
||||
"loc" INTEGER,
|
||||
"locEndpoint" TEXT,
|
||||
"featured" BOOLEAN NOT NULL DEFAULT false,
|
||||
"sortIndex" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Project_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "WorkExperience" (
|
||||
"id" TEXT NOT NULL,
|
||||
"company" TEXT NOT NULL,
|
||||
"role" TEXT NOT NULL,
|
||||
"fromDate" TIMESTAMP(3) NOT NULL,
|
||||
"toDate" TIMESTAMP(3),
|
||||
"url" TEXT,
|
||||
"iconUrl" TEXT,
|
||||
"summary" TEXT NOT NULL,
|
||||
"tags" TEXT[],
|
||||
"sortIndex" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "WorkExperience_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Affiliate" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"link" TEXT NOT NULL,
|
||||
"icon" TEXT NOT NULL,
|
||||
"location" TEXT NOT NULL,
|
||||
"provides" TEXT[],
|
||||
"good" TEXT[],
|
||||
"bad" TEXT[],
|
||||
"sortIndex" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Affiliate_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
@@ -0,0 +1,10 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `featured` on the `Project` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `sortIndex` on the `Project` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "Project" DROP COLUMN "featured",
|
||||
DROP COLUMN "sortIndex";
|
||||
@@ -0,0 +1,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;
|
||||
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "postgresql"
|
||||
@@ -0,0 +1,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
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { parseAffiliate } from "@/lib/dto";
|
||||
import { guard, run, readBody } from "@/lib/adminRoute";
|
||||
|
||||
type Params = { params: Promise<{ id: string }> };
|
||||
|
||||
export async function PUT(req: Request, { params }: Params) {
|
||||
const denied = await guard();
|
||||
if (denied) return denied;
|
||||
return run(async () => {
|
||||
const { id } = await params;
|
||||
const data = parseAffiliate(await readBody(req));
|
||||
const updated = await prisma.affiliate.update({ where: { id }, data });
|
||||
return NextResponse.json(updated);
|
||||
});
|
||||
}
|
||||
|
||||
export async function DELETE(_req: Request, { params }: Params) {
|
||||
const denied = await guard();
|
||||
if (denied) return denied;
|
||||
return run(async () => {
|
||||
const { id } = await params;
|
||||
await prisma.affiliate.delete({ where: { id } });
|
||||
return NextResponse.json({ ok: true });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { parseAffiliate } from "@/lib/dto";
|
||||
import { guard, run, readBody } from "@/lib/adminRoute";
|
||||
|
||||
export async function GET() {
|
||||
const denied = await guard();
|
||||
if (denied) return denied;
|
||||
return run(async () => {
|
||||
const items = await prisma.affiliate.findMany({
|
||||
orderBy: [{ sortIndex: "asc" }, { createdAt: "asc" }],
|
||||
});
|
||||
return NextResponse.json(items);
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const denied = await guard();
|
||||
if (denied) return denied;
|
||||
return run(async () => {
|
||||
const data = parseAffiliate(await readBody(req));
|
||||
const created = await prisma.affiliate.create({ data });
|
||||
return NextResponse.json(created, { status: 201 });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,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 });
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { parseExperience } from "@/lib/dto";
|
||||
import { guard, run, readBody } from "@/lib/adminRoute";
|
||||
|
||||
export async function GET() {
|
||||
const denied = await guard();
|
||||
if (denied) return denied;
|
||||
return run(async () => {
|
||||
const items = await prisma.workExperience.findMany({
|
||||
orderBy: [{ sortIndex: "asc" }, { fromDate: "desc" }],
|
||||
});
|
||||
return NextResponse.json(items);
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const denied = await guard();
|
||||
if (denied) return denied;
|
||||
return run(async () => {
|
||||
const data = parseExperience(await readBody(req));
|
||||
const created = await prisma.workExperience.create({ data });
|
||||
return NextResponse.json(created, { status: 201 });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSessionToken, verifyPassword, SESSION_COOKIE } from "@/lib/auth";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
let password = "";
|
||||
try {
|
||||
const body = await req.json();
|
||||
password = typeof body?.password === "string" ? body.password : "";
|
||||
} catch {
|
||||
// ignore malformed body -> treated as invalid
|
||||
}
|
||||
|
||||
if (!verifyPassword(password)) {
|
||||
return NextResponse.json({ error: "Invalid password" }, { status: 401 });
|
||||
}
|
||||
|
||||
const res = NextResponse.json({ ok: true });
|
||||
res.cookies.set(SESSION_COOKIE, createSessionToken(), {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
path: "/",
|
||||
maxAge: 60 * 60 * 24 * 7,
|
||||
});
|
||||
return res;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { SESSION_COOKIE } from "@/lib/auth";
|
||||
|
||||
export async function POST() {
|
||||
const res = NextResponse.json({ ok: true });
|
||||
res.cookies.set(SESSION_COOKIE, "", { path: "/", maxAge: 0 });
|
||||
return res;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { parseProject } from "@/lib/dto";
|
||||
import { guard, run, readBody } from "@/lib/adminRoute";
|
||||
|
||||
type Params = { params: Promise<{ id: string }> };
|
||||
|
||||
export async function PUT(req: Request, { params }: Params) {
|
||||
const denied = await guard();
|
||||
if (denied) return denied;
|
||||
return run(async () => {
|
||||
const { id } = await params;
|
||||
const data = parseProject(await readBody(req));
|
||||
const updated = await prisma.project.update({ where: { id }, data });
|
||||
return NextResponse.json(updated);
|
||||
});
|
||||
}
|
||||
|
||||
export async function DELETE(_req: Request, { params }: Params) {
|
||||
const denied = await guard();
|
||||
if (denied) return denied;
|
||||
return run(async () => {
|
||||
const { id } = await params;
|
||||
await prisma.project.delete({ where: { id } });
|
||||
return NextResponse.json({ ok: true });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { parseProject } from "@/lib/dto";
|
||||
import { guard, run, readBody } from "@/lib/adminRoute";
|
||||
|
||||
export async function GET() {
|
||||
const denied = await guard();
|
||||
if (denied) return denied;
|
||||
return run(async () => {
|
||||
const projects = await prisma.project.findMany({
|
||||
orderBy: [{ size: "asc" }, { name: "asc" }],
|
||||
});
|
||||
return NextResponse.json(projects);
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const denied = await guard();
|
||||
if (denied) return denied;
|
||||
return run(async () => {
|
||||
const data = parseProject(await readBody(req));
|
||||
const created = await prisma.project.create({ data });
|
||||
return NextResponse.json(created, { status: 201 });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isAdmin } from "@/lib/auth";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({ authenticated: await isAdmin() });
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
// Public: list affiliates.
|
||||
export async function GET() {
|
||||
const affiliates = await prisma.affiliate.findMany({
|
||||
orderBy: [{ sortIndex: "asc" }, { createdAt: "asc" }],
|
||||
});
|
||||
return NextResponse.json(affiliates);
|
||||
}
|
||||
@@ -0,0 +1,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));
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
// Public: list work experience, most recent first.
|
||||
export async function GET() {
|
||||
const experience = await prisma.workExperience.findMany({
|
||||
orderBy: [{ sortIndex: "asc" }, { fromDate: "desc" }],
|
||||
});
|
||||
return NextResponse.json(experience);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
// Serves the profile picture through our own origin, caching the bytes in
|
||||
// process memory so we don't hit the upstream CDN on every request.
|
||||
|
||||
const DEFAULT_IMAGE = "https://cdn.reversed.dev/pictures/20250405_120402.png";
|
||||
const CACHE_TTL_MS = 1000 * 60 * 60; // 1 hour
|
||||
|
||||
type ImageCache = { body: ArrayBuffer; contentType: string; expiresAt: number };
|
||||
let cached: ImageCache | null = null;
|
||||
|
||||
async function loadImage(): Promise<ImageCache | null> {
|
||||
if (cached && cached.expiresAt > Date.now()) return cached;
|
||||
|
||||
const source = process.env.PROFILE_IMAGE_URL || DEFAULT_IMAGE;
|
||||
try {
|
||||
const res = await fetch(source, { signal: AbortSignal.timeout(8000) });
|
||||
if (!res.ok) return cached; // fall back to stale copy if present
|
||||
const body = await res.arrayBuffer();
|
||||
cached = {
|
||||
body,
|
||||
contentType: res.headers.get("content-type") || "image/png",
|
||||
expiresAt: Date.now() + CACHE_TTL_MS,
|
||||
};
|
||||
return cached;
|
||||
} catch {
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const image = await loadImage();
|
||||
if (!image) {
|
||||
return NextResponse.json({ error: "Image unavailable" }, { status: 502 });
|
||||
}
|
||||
|
||||
return new NextResponse(image.body, {
|
||||
headers: {
|
||||
"Content-Type": image.contentType,
|
||||
"Cache-Control": "public, max-age=3600, stale-while-revalidate=86400",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { resolveLoc } from "@/lib/loc";
|
||||
|
||||
// Public: list all projects (served from our DB), each with a resolved
|
||||
// line-of-code count (manual `loc` or fetched from `locEndpoint`, cached).
|
||||
export async function GET() {
|
||||
// Ordered by size (Big -> MediumSized -> Small via the enum's declared order),
|
||||
// then alphabetically by name.
|
||||
const projects = await prisma.project.findMany({
|
||||
orderBy: [{ size: "asc" }, { name: "asc" }],
|
||||
});
|
||||
|
||||
const withLoc = await Promise.all(
|
||||
projects.map(async (p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
label: p.label,
|
||||
size: p.size,
|
||||
imageUrl: p.imageUrl,
|
||||
link: p.link,
|
||||
linkIsDemo: p.linkIsDemo,
|
||||
sourceCode: p.sourceCode,
|
||||
description: p.description,
|
||||
why: p.why,
|
||||
note: p.note,
|
||||
tags: p.tags,
|
||||
loc: p.loc,
|
||||
resolvedLoc: await resolveLoc(p.loc, p.locEndpoint),
|
||||
createdAt: p.createdAt,
|
||||
updatedAt: p.updatedAt,
|
||||
})),
|
||||
);
|
||||
|
||||
return NextResponse.json(withLoc);
|
||||
}
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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'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've got in mind, I'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
@@ -14,17 +14,30 @@
|
||||
.animate-scale-in { animation: scale-in 0.3s ease-out; }
|
||||
|
||||
:root {
|
||||
--background: #020205;
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ffffff;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-satoshi), ui-sans-serif, system-ui, sans-serif;
|
||||
--font-display: var(--font-clash), var(--font-satoshi), ui-sans-serif, sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
/* Reference the next/font variables directly — `@theme inline` above does not
|
||||
emit real --font-sans/--font-display custom properties. */
|
||||
font-family: var(--font-satoshi), ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
/* Headings use Clash Display by default for a sharper, editorial feel. */
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
font-family: var(--font-clash), var(--font-satoshi), ui-sans-serif, sans-serif;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
|
||||
+22
-8
@@ -1,10 +1,28 @@
|
||||
import type { Metadata } from "next";
|
||||
import localFont from "next/font/local";
|
||||
import "@/app/globals.css";
|
||||
import { ClientProviders } from "../components/ClientProviders";
|
||||
|
||||
// Satoshi = body text, Clash Display = headings (both variable fonts).
|
||||
const satoshi = localFont({
|
||||
variable: "--font-satoshi",
|
||||
display: "swap",
|
||||
src: [
|
||||
{ path: "./fonts/Satoshi-Variable.ttf", weight: "300 900", style: "normal" },
|
||||
{ path: "./fonts/Satoshi-VariableItalic.ttf", weight: "300 900", style: "italic" },
|
||||
],
|
||||
});
|
||||
|
||||
const clashDisplay = localFont({
|
||||
variable: "--font-clash",
|
||||
display: "swap",
|
||||
src: [{ path: "./fonts/ClashDisplay-Variable.ttf", weight: "200 700", style: "normal" }],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Space's portfolio",
|
||||
description: "A developer from Germany building things.",
|
||||
title: "Paul W. — Portfolio",
|
||||
description:
|
||||
"Full-stack developer & open-source author. Building server infrastructure, developer tools, and web applications.",
|
||||
icons: {
|
||||
icon: "/favicon.png",
|
||||
},
|
||||
@@ -16,14 +34,10 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<html lang="en" className={`${satoshi.variable} ${clashDisplay.variable}`}>
|
||||
<body className="antialiased">
|
||||
<ClientProviders>
|
||||
<div className="relative min-h-screen bg-[#020205] text-white selection:bg-blue-500/30 overflow-x-hidden">
|
||||
<div className="fixed inset-0 z-0 pointer-events-none">
|
||||
<div className="absolute top-[-10%] left-[-10%] w-[40%] h-[40%] bg-blue-500/10 blur-[120px] rounded-full animate-pulse" />
|
||||
<div className="absolute bottom-[-10%] right-[-10%] w-[40%] h-[40%] bg-purple-500/10 blur-[120px] rounded-full animate-pulse" />
|
||||
</div>
|
||||
<div className="relative min-h-screen bg-[#0a0a0a] text-white selection:bg-white/20 overflow-x-hidden">
|
||||
{children}
|
||||
</div>
|
||||
</ClientProviders>
|
||||
|
||||
+15
-141
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { useProfile } from "../../context/ProfileContext";
|
||||
import { Navbar } from "../../components/Navbar";
|
||||
import { Footer } from "../../sections/Footer";
|
||||
import { ProjectCard } from "../../components/ProjectCard";
|
||||
|
||||
export default function ProjectsPage() {
|
||||
const { projects, loading } = useProfile();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<div className="mx-auto w-full max-w-6xl px-4 pb-16 pt-28 sm:pb-20 sm:pt-32">
|
||||
<div className="mb-10 space-y-4 text-center sm:mb-14">
|
||||
<h1 className="text-4xl font-semibold text-white sm:text-5xl">Projects</h1>
|
||||
<p className="mx-auto max-w-2xl text-base text-gray-500 sm:text-lg">
|
||||
Everything I've built and shipped — big and small.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="text-center text-gray-500">Loading projects…</p>
|
||||
) : projects.length === 0 ? (
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 px-5 py-12 text-center text-gray-400">
|
||||
No projects published yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3 sm:gap-8">
|
||||
{projects.map((project) => (
|
||||
<ProjectCard key={project.id} project={project} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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 {};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// In-memory, TTL-based cache for lines-of-code counts fetched from a project's
|
||||
// `locEndpoint`. The endpoint is expected to return a plaintext integer.
|
||||
|
||||
type CacheEntry = { value: number | null; expiresAt: number };
|
||||
|
||||
const CACHE_TTL_MS = 1000 * 60 * 10; // 10 minutes
|
||||
const cache = new Map<string, CacheEntry>();
|
||||
|
||||
async function fetchLocFromEndpoint(endpoint: string): Promise<number | null> {
|
||||
const cached = cache.get(endpoint);
|
||||
if (cached && cached.expiresAt > Date.now()) return cached.value;
|
||||
|
||||
let value: number | null = null;
|
||||
try {
|
||||
const res = await fetch(endpoint, { signal: AbortSignal.timeout(5000) });
|
||||
if (res.ok) {
|
||||
const text = (await res.text()).trim();
|
||||
const parsed = Number.parseInt(text.replace(/[^0-9]/g, ""), 10);
|
||||
value = Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
} catch {
|
||||
value = null;
|
||||
}
|
||||
|
||||
cache.set(endpoint, { value, expiresAt: Date.now() + CACHE_TTL_MS });
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a project's line-of-code count: prefer the manually provided `loc`,
|
||||
* otherwise hit `locEndpoint` (cached in memory). Returns null when unknown.
|
||||
*/
|
||||
export async function resolveLoc(
|
||||
loc: number | null | undefined,
|
||||
locEndpoint: string | null | undefined,
|
||||
): Promise<number | null> {
|
||||
if (typeof loc === "number") return loc;
|
||||
if (locEndpoint) return fetchLocFromEndpoint(locEndpoint);
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { PrismaClient } from "@/generated/prisma/client";
|
||||
import { PrismaPg } from "@prisma/adapter-pg";
|
||||
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma: PrismaClient | undefined;
|
||||
};
|
||||
|
||||
function createClient() {
|
||||
const connectionString = process.env.DATABASE_URL;
|
||||
if (!connectionString) {
|
||||
throw new Error("DATABASE_URL is not set");
|
||||
}
|
||||
const adapter = new PrismaPg({ connectionString });
|
||||
return new PrismaClient({ adapter });
|
||||
}
|
||||
|
||||
export const prisma = globalForPrisma.prisma ?? createClient();
|
||||
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
globalForPrisma.prisma = prisma;
|
||||
}
|
||||
@@ -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'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
@@ -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
@@ -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>
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useProfile } from "../context/ProfileContext";
|
||||
import { ProjectCard } from "../components/ProjectCard";
|
||||
|
||||
// Homepage teaser: the first 3 projects (ordered by size then name, per the API)
|
||||
// plus a button through to the full /projects page.
|
||||
export function FeaturedProjects() {
|
||||
const { projects, loading } = useProfile();
|
||||
const top = projects.slice(0, 3);
|
||||
|
||||
return (
|
||||
<section className="mx-auto w-full max-w-6xl space-y-8 px-4 sm:space-y-10">
|
||||
<div className="space-y-3 text-center">
|
||||
<h2 className="text-3xl font-semibold text-white sm:text-4xl">Featured Projects</h2>
|
||||
<p className="mx-auto max-w-2xl text-sm text-gray-500 sm:text-base">
|
||||
A few things I've been building lately.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="text-center text-gray-500">Loading projects…</p>
|
||||
) : top.length === 0 ? (
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 px-5 py-8 text-center text-gray-400">
|
||||
No projects published yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3 sm:gap-8">
|
||||
{top.map((project) => (
|
||||
<ProjectCard key={project.id} project={project} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Link
|
||||
href="/projects"
|
||||
className="inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/5 px-6 py-3 text-sm font-semibold text-white transition-all duration-300 hover:border-white/30 hover:bg-white/10"
|
||||
>
|
||||
View all projects
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M14 5l7 7m0 0l-7 7m7-7H3" />
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useUmami } from "@danielgtmn/umami-react";
|
||||
import { Github, Mail, MessageSquare } from "lucide-react";
|
||||
import { CONTACT_EMAIL } from "../types";
|
||||
|
||||
export function Footer() {
|
||||
const currentYear = new Date().getFullYear();
|
||||
@@ -29,12 +30,10 @@ export function Footer() {
|
||||
<Github className="w-5 h-5" />
|
||||
</a>
|
||||
<a
|
||||
href="mailto:space@reversed.dev"
|
||||
href={`mailto:${CONTACT_EMAIL}`}
|
||||
className="hover:text-purple-400 transition-colors"
|
||||
aria-label="Email"
|
||||
onClick={() =>
|
||||
handleLinkClick("Email", "mailto:space@reversed.dev")
|
||||
}
|
||||
onClick={() => handleLinkClick("Email", "mailto")}
|
||||
>
|
||||
<Mail className="w-5 h-5" />
|
||||
</a>
|
||||
@@ -58,7 +57,7 @@ export function Footer() {
|
||||
{/* Branding & Attribution */}
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<p className="text-gray-500 text-xs tracking-wide">
|
||||
© {currentYear} • Space-Banane
|
||||
© {currentYear} • Paul W.
|
||||
</p>
|
||||
<div className="flex items-center gap-3 text-[10px] text-gray-600 uppercase tracking-[0.2em]">
|
||||
<a
|
||||
|
||||
@@ -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
@@ -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 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 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 & 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>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
export function Luna() {
|
||||
return (
|
||||
<section className="mx-auto w-full max-w-4xl px-4">
|
||||
<div className="rounded-3xl border border-white/10 bg-white/[0.03] p-6 sm:p-8 md:p-10">
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-white/70" />
|
||||
<p className="text-xs font-medium uppercase tracking-[0.25em] text-gray-500">
|
||||
AI Agent
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-semibold text-white sm:text-3xl">Meet Luna</h2>
|
||||
|
||||
<p className="max-w-2xl text-base leading-relaxed text-gray-400 sm:text-lg">
|
||||
Luna is a personal agent running in{" "}
|
||||
<span className="font-medium text-gray-200">Openclaw</span> on my homelab. Luna
|
||||
codes, reviews PRs, and handles a bunch of other tasks throughout the day.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{["Writes code", "Reviews PRs", "Runs on Openclaw", "OpenAI (ChatGPT Plus)"].map(
|
||||
(tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs font-medium text-gray-300"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pt-1">
|
||||
<a
|
||||
href="https://luna.spaceistyping.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/5 px-5 py-2.5 text-sm font-semibold text-white transition-all duration-300 hover:border-white/30 hover:bg-white/10"
|
||||
>
|
||||
Visit Luna
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M14 5l7 7m0 0l-7 7m7-7H3" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user