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:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
DATABASE_URL: "postgresql://fake:fake@localhost:5432/fake?schema=public"
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
|
|||||||
@@ -42,3 +42,6 @@ next-env.d.ts
|
|||||||
|
|
||||||
old/
|
old/
|
||||||
.next
|
.next
|
||||||
|
|
||||||
|
# prisma generated client
|
||||||
|
/src/generated
|
||||||
Binary file not shown.
@@ -1,6 +1,7 @@
|
|||||||
FROM node:24-alpine AS base
|
FROM node:24-alpine AS base
|
||||||
ENV PNPM_HOME="/pnpm"
|
ENV PNPM_HOME="/pnpm"
|
||||||
ENV PATH="$PNPM_HOME:$PATH"
|
ENV PATH="$PNPM_HOME:$PATH"
|
||||||
|
ENV DATABASE_URL="postgresql://postgres:postgres@db:5432/postgres"
|
||||||
RUN corepack enable
|
RUN corepack enable
|
||||||
|
|
||||||
FROM base AS deps
|
FROM base AS deps
|
||||||
|
|||||||
@@ -4,18 +4,48 @@ Personal portfolio built with Next.js, React, TypeScript, and Tailwind CSS.
|
|||||||
|
|
||||||
## What It Includes
|
## What It Includes
|
||||||
|
|
||||||
- Hero section with typing intro
|
- Hero dev-card, live-count stat, and work-experience timeline
|
||||||
- Work experience and skills sections
|
- Projects (served from our DB) with a `/projects` page and homepage teaser
|
||||||
- Uptime and activity panels
|
- Blog system with DB-backed markdown posts, admin editing, and view tracking
|
||||||
- Project and mini-project showcases
|
- Luna section, DB-driven affiliates, and the SHSF Code Activity graph
|
||||||
- Contact page and admin page
|
- 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
|
## Development
|
||||||
|
|
||||||
Install dependencies:
|
Install dependencies and generate the Prisma client:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm install
|
pnpm install
|
||||||
|
pnpm prisma generate
|
||||||
|
```
|
||||||
|
|
||||||
|
Apply the schema to your database:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm prisma migrate dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Run the development server:
|
Run the development server:
|
||||||
@@ -24,7 +54,7 @@ Run the development server:
|
|||||||
pnpm dev
|
pnpm dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000) in your browser.
|
Open [http://localhost:5173](http://localhost:5173) in your browser.
|
||||||
|
|
||||||
## Production Build
|
## Production Build
|
||||||
|
|
||||||
@@ -61,4 +91,6 @@ The app will be available on port `6756` via `docker-compose.yml`.
|
|||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- `next.config.ts` uses `output: "standalone"` so the Docker image can ship a minimal runtime.
|
- `next.config.ts` uses `output: "standalone"` so the Docker image can ship a minimal runtime.
|
||||||
|
- `pnpm build` runs `prisma generate` before `next build`.
|
||||||
- The activity graph on the home page is loaded from a remote SVG source.
|
- The activity graph on the home page is loaded from a remote SVG source.
|
||||||
|
- The container needs the same env vars (`DATABASE_URL`, `ADMIN_PASSWORD`, `SESSION_SECRET`) at runtime.
|
||||||
|
|||||||
+8
-2
@@ -4,20 +4,25 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "pnpm@10.30.3",
|
"packageManager": "pnpm@10.30.3",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev -p 5173",
|
||||||
"build": "next build",
|
"build": "prisma generate && next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@danielgtmn/umami-react": "^1.1.6",
|
"@danielgtmn/umami-react": "^1.1.6",
|
||||||
|
"@prisma/adapter-pg": "^7.8.0",
|
||||||
|
"@prisma/client": "^7.8.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"dotenv": "^17.4.2",
|
||||||
"framer-motion": "^12.36.0",
|
"framer-motion": "^12.36.0",
|
||||||
"lucide-react": "^0.577.0",
|
"lucide-react": "^0.577.0",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^7.13.1",
|
"react-router-dom": "^7.13.1",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
"tailwind-merge": "^3.5.0"
|
"tailwind-merge": "^3.5.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -27,6 +32,7 @@
|
|||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.1.6",
|
"eslint-config-next": "16.1.6",
|
||||||
|
"prisma": "^7.8.0",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
|
|||||||
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 { Contact as ContactSection } from "../../sections/Contact";
|
||||||
import { Navbar } from "../../components/Navbar";
|
import { Navbar } from "../../components/Navbar";
|
||||||
import { Footer } from "../../sections/Footer";
|
import { Footer } from "../../sections/Footer";
|
||||||
|
import { CONTACT_EMAIL } from "../../types";
|
||||||
|
|
||||||
export default function ContactPage() {
|
export default function ContactPage() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Navbar />
|
<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">
|
<div className="text-center space-y-4 w-full">
|
||||||
<h1 className="text-5xl md:text-6xl font-extrabold text-white">
|
<h1 className="text-4xl font-semibold text-white sm:text-5xl md:text-6xl">
|
||||||
Let's <span className="text-transparent bg-clip-text bg-gradient-to-r from-blue-400 to-purple-500">Connect</span>
|
Let's Connect
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-gray-400 text-xl max-w-xl mx-auto">
|
<p className="mx-auto max-w-xl text-base text-gray-500 sm:text-lg md:text-xl">
|
||||||
Whatever you've got in mind, I'm just a few clicks away.
|
Whatever you've got in mind, I'm just a few clicks away.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full max-w-3xl">
|
<div className="w-full max-w-2xl">
|
||||||
<ContactSection />
|
<ContactSection />
|
||||||
</div>
|
</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="grid w-full max-w-2xl grid-cols-1 gap-4 text-center sm:grid-cols-2 sm:gap-6">
|
||||||
<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">
|
<a
|
||||||
|
href={`mailto:${CONTACT_EMAIL}`}
|
||||||
|
className="group w-full rounded-3xl border border-white/10 bg-white/[0.03] p-5 transition-all duration-300 hover:border-white/25 sm:p-6"
|
||||||
|
>
|
||||||
<div className="text-3xl mb-4 group-hover:scale-110 transition-transform">📧</div>
|
<div className="text-3xl mb-4 group-hover:scale-110 transition-transform">📧</div>
|
||||||
<h3 className="font-bold mb-1">Email</h3>
|
<h3 className="font-bold mb-1">Email</h3>
|
||||||
<p className="text-sm text-gray-500 underline underline-offset-4 decoration-white/20 hover:decoration-blue-500 transition-colors">
|
<p className="text-sm text-gray-500 underline underline-offset-4 decoration-white/20 group-hover:decoration-white/60 transition-colors">
|
||||||
space@reversed.dev
|
Send me an email
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</a>
|
||||||
<div className="bg-white/5 border border-white/10 p-6 rounded-3xl backdrop-blur-sm group hover:border-purple-500/30 transition-all duration-300">
|
<div className="group w-full rounded-3xl border border-white/10 bg-white/[0.03] p-5 transition-all duration-300 hover:border-white/25 sm:p-6">
|
||||||
<div className="text-3xl mb-4 group-hover:scale-110 transition-transform">👾</div>
|
<div className="text-3xl mb-4 group-hover:scale-110 transition-transform">👾</div>
|
||||||
<h3 className="font-bold mb-1">Discord</h3>
|
<h3 className="font-bold mb-1">Discord</h3>
|
||||||
<p className="text-sm text-gray-500">@getspaced</p>
|
<p className="text-sm text-gray-500">@getspaced</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="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>
|
||||||
</div>
|
</div>
|
||||||
<Footer />
|
<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; }
|
.animate-scale-in { animation: scale-in 0.3s ease-out; }
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: #020205;
|
--background: #0a0a0a;
|
||||||
--foreground: #ffffff;
|
--foreground: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
|
--font-sans: var(--font-satoshi), ui-sans-serif, system-ui, sans-serif;
|
||||||
|
--font-display: var(--font-clash), var(--font-satoshi), ui-sans-serif, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: var(--background);
|
background: var(--background);
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
|
/* Reference the next/font variables directly — `@theme inline` above does not
|
||||||
|
emit real --font-sans/--font-display custom properties. */
|
||||||
|
font-family: var(--font-satoshi), ui-sans-serif, system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Headings use Clash Display by default for a sharper, editorial feel. */
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3 {
|
||||||
|
font-family: var(--font-clash), var(--font-satoshi), ui-sans-serif, sans-serif;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+22
-8
@@ -1,10 +1,28 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import localFont from "next/font/local";
|
||||||
import "@/app/globals.css";
|
import "@/app/globals.css";
|
||||||
import { ClientProviders } from "../components/ClientProviders";
|
import { ClientProviders } from "../components/ClientProviders";
|
||||||
|
|
||||||
|
// Satoshi = body text, Clash Display = headings (both variable fonts).
|
||||||
|
const satoshi = localFont({
|
||||||
|
variable: "--font-satoshi",
|
||||||
|
display: "swap",
|
||||||
|
src: [
|
||||||
|
{ path: "./fonts/Satoshi-Variable.ttf", weight: "300 900", style: "normal" },
|
||||||
|
{ path: "./fonts/Satoshi-VariableItalic.ttf", weight: "300 900", style: "italic" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const clashDisplay = localFont({
|
||||||
|
variable: "--font-clash",
|
||||||
|
display: "swap",
|
||||||
|
src: [{ path: "./fonts/ClashDisplay-Variable.ttf", weight: "200 700", style: "normal" }],
|
||||||
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Space's portfolio",
|
title: "Paul W. — Portfolio",
|
||||||
description: "A developer from Germany building things.",
|
description:
|
||||||
|
"Full-stack developer & open-source author. Building server infrastructure, developer tools, and web applications.",
|
||||||
icons: {
|
icons: {
|
||||||
icon: "/favicon.png",
|
icon: "/favicon.png",
|
||||||
},
|
},
|
||||||
@@ -16,14 +34,10 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en" className={`${satoshi.variable} ${clashDisplay.variable}`}>
|
||||||
<body className="antialiased">
|
<body className="antialiased">
|
||||||
<ClientProviders>
|
<ClientProviders>
|
||||||
<div className="relative min-h-screen bg-[#020205] text-white selection:bg-blue-500/30 overflow-x-hidden">
|
<div className="relative min-h-screen bg-[#0a0a0a] text-white selection:bg-white/20 overflow-x-hidden">
|
||||||
<div className="fixed inset-0 z-0 pointer-events-none">
|
|
||||||
<div className="absolute top-[-10%] left-[-10%] w-[40%] h-[40%] bg-blue-500/10 blur-[120px] rounded-full animate-pulse" />
|
|
||||||
<div className="absolute bottom-[-10%] right-[-10%] w-[40%] h-[40%] bg-purple-500/10 blur-[120px] rounded-full animate-pulse" />
|
|
||||||
</div>
|
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</ClientProviders>
|
</ClientProviders>
|
||||||
|
|||||||
+15
-141
@@ -1,158 +1,32 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useProfile } from "../context/ProfileContext";
|
import { useProfile } from "../context/ProfileContext";
|
||||||
import { Hero } from "../sections/Hero";
|
|
||||||
import { WorkExperience } from "../sections/WorkExperience";
|
|
||||||
import { Uptime } from "../sections/Uptime";
|
|
||||||
import { Activity } from "../sections/Activity";
|
|
||||||
import { Affiliates } from "../sections/Affiliates";
|
|
||||||
import { 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 { 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 { 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() {
|
export default function Home() {
|
||||||
const {
|
const { experiences } = useProfile();
|
||||||
glowColor, borderStatus, displayMessage, statusMessage,
|
|
||||||
rotatingMessages,
|
|
||||||
projects, miniProjects, setSelectedMiniProject,
|
|
||||||
experiences, setSelectedExperience, realWork,
|
|
||||||
selectedMiniProject, selectedExperience
|
|
||||||
} = useProfile();
|
|
||||||
|
|
||||||
const [showTypingIntro, setShowTypingIntro] = useState(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[]>,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
|
||||||
<TypingRoomIntro active={showTypingIntro} onFinish={handleIntroFinish} />
|
|
||||||
{!showTypingIntro && (
|
|
||||||
<>
|
<>
|
||||||
<Navbar />
|
<Navbar />
|
||||||
<div className="space-y-24 pb-20 pt-20">
|
<div className="space-y-20 pb-16 pt-28 sm:space-y-24 sm:pb-20 sm:pt-32">
|
||||||
<Hero
|
<Hero />
|
||||||
glowColor={glowColor}
|
<TopBlogs />
|
||||||
borderStatus={borderStatus}
|
<WorkExperience experiences={experiences} />
|
||||||
displayMessage={displayMessage}
|
<FeaturedProjects />
|
||||||
rotatingMessages={rotatingMessages}
|
<Luna />
|
||||||
statusMessage={statusMessage}
|
|
||||||
oldUsernames={oldUsernames}
|
|
||||||
/>
|
|
||||||
|
|
||||||
|
|
||||||
<WorkExperience realWork={realWork} />
|
|
||||||
|
|
||||||
<Uptime />
|
|
||||||
|
|
||||||
<Activity />
|
|
||||||
|
|
||||||
<Affiliates />
|
<Affiliates />
|
||||||
|
<Activity />
|
||||||
<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>
|
|
||||||
</div>
|
</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 />
|
<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 = [
|
const navItems = [
|
||||||
{ label: "Home", path: "/" },
|
{ label: "Home", path: "/" },
|
||||||
|
{ label: "Projects", path: "/projects" },
|
||||||
|
{ label: "Blog", path: "/blogs" },
|
||||||
{ label: "Connect", path: "/contact" },
|
{ label: "Connect", path: "/contact" },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -18,14 +20,14 @@ export function Navbar() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="fixed top-8 left-1/2 -translate-x-1/2 z-[100] w-[min(90%,400px)]">
|
<nav className="fixed top-4 left-1/2 z-[100] w-[min(100vw-1rem,460px)] -translate-x-1/2 sm:top-8 sm:w-[min(90%,460px)]">
|
||||||
<div className="bg-black/20 backdrop-blur-xl border border-white/10 rounded-full px-6 py-3 flex items-center justify-between gap-4 shadow-2xl">
|
<div className="bg-black/20 backdrop-blur-xl border border-white/10 rounded-full px-3 py-2 sm:px-6 sm:py-3 flex items-center justify-between gap-2 sm:gap-4 shadow-2xl">
|
||||||
{navItems.map((item) => (
|
{navItems.map((item) => (
|
||||||
<Link
|
<Link
|
||||||
key={item.path}
|
key={item.path}
|
||||||
href={item.path}
|
href={item.path}
|
||||||
onClick={() => handleSwitch(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
|
pathname === item.path
|
||||||
? "bg-white text-black scale-105 shadow-xl shadow-white/10"
|
? "bg-white text-black scale-105 shadow-xl shadow-white/10"
|
||||||
@@ -39,4 +41,3 @@ export function Navbar() {
|
|||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,68 +1,100 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { Project } from "../types";
|
import type { Project } from "../types";
|
||||||
|
import { SIZE_LABELS } from "../types";
|
||||||
|
|
||||||
|
const SIZE_STYLES: Record<Project["size"], string> = {
|
||||||
|
Big: "border-white/25 bg-white/10 text-white",
|
||||||
|
MediumSized: "border-white/15 bg-white/[0.06] text-gray-200",
|
||||||
|
Small: "border-white/10 bg-white/[0.03] text-gray-400",
|
||||||
|
};
|
||||||
|
|
||||||
export function ProjectCard({ project }: { project: Project }) {
|
export function ProjectCard({ project }: { project: Project }) {
|
||||||
|
const loc = project.resolvedLoc ?? project.loc ?? null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="group relative flex 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">
|
<div className="group flex w-full flex-col rounded-2xl border border-white/10 bg-white/[0.03] p-4 transition-all duration-300 hover:-translate-y-1 hover:border-white/25 sm:p-6">
|
||||||
{project.image && (
|
{project.imageUrl ? (
|
||||||
<div className="mb-6 overflow-hidden rounded-xl bg-black/20 aspect-video flex items-center justify-center relative">
|
// Image shown in full — no overlays over it.
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
<div className="mb-4 flex items-center justify-center overflow-hidden rounded-xl bg-black/30 p-3">
|
||||||
<img
|
<img
|
||||||
src={project.image}
|
src={project.imageUrl}
|
||||||
alt={project.name}
|
alt={project.name}
|
||||||
className={`object-contain ${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>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
|
|
||||||
<h3 className="text-xl font-bold text-white mb-2 group-hover:text-purple-400 transition-colors">
|
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||||
{project.name}
|
<h3 className="text-lg font-semibold text-white sm:text-xl">{project.name}</h3>
|
||||||
</h3>
|
<span className={`rounded-full border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide ${SIZE_STYLES[project.size]}`}>
|
||||||
<p className="text-gray-400 text-sm flex-grow mb-6 leading-relaxed">
|
{SIZE_LABELS[project.size]}
|
||||||
{project.description}
|
</span>
|
||||||
</p>
|
{project.label ? (
|
||||||
{project.last_commit && (
|
<span className="rounded-full border border-white/10 bg-white/5 px-2 py-0.5 text-[10px] font-medium text-gray-300">
|
||||||
<p className="text-gray-400 text-xs mb-2">
|
{project.label}
|
||||||
Last commit: {new Date(project.last_commit).toLocaleDateString()}
|
</span>
|
||||||
</p>
|
) : 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
|
<a
|
||||||
href={project.link + "?utm_source=portfolio&ref=space"}
|
href={project.link}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
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
|
{project.linkIsDemo ? "Live Demo" : "Visit"}
|
||||||
<svg
|
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
className="w-3 h-3"
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth="2"
|
|
||||||
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
|
|
||||||
/>
|
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
{project.open_source && (
|
{project.sourceCode ? (
|
||||||
<a
|
<a
|
||||||
href={project.open_source.link}
|
href={project.sourceCode}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="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"
|
title="View Source"
|
||||||
>
|
>
|
||||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
|
||||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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";
|
"use client";
|
||||||
|
|
||||||
import React, { createContext, useContext, useEffect, useState, useMemo } from "react";
|
import React, { createContext, useContext, useEffect, useState } from "react";
|
||||||
import type { Experience, MiniProject, Project, RealWork } from "../types";
|
import type { Affiliate, Project, WorkExperience } from "../types";
|
||||||
|
|
||||||
interface ProfileContextType {
|
interface ProfileContextType {
|
||||||
status: string;
|
|
||||||
statusMessage: string;
|
|
||||||
borderStatus: string;
|
|
||||||
glowColor: string;
|
|
||||||
displayMessage: string;
|
|
||||||
isScrambling: boolean;
|
|
||||||
rotatingMessages: string[];
|
|
||||||
projects: Project[];
|
projects: Project[];
|
||||||
miniProjects: MiniProject[];
|
experiences: WorkExperience[];
|
||||||
experiences: Experience[];
|
affiliates: Affiliate[];
|
||||||
realWork: RealWork[];
|
loading: boolean;
|
||||||
selectedMiniProject: MiniProject | null;
|
|
||||||
setSelectedMiniProject: (p: MiniProject | null) => void;
|
|
||||||
selectedExperience: Experience | null;
|
|
||||||
setSelectedExperience: (e: Experience | null) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ProfileContext = createContext<ProfileContextType | undefined>(undefined);
|
const ProfileContext = createContext<ProfileContextType | undefined>(undefined);
|
||||||
|
|
||||||
export function ProfileProvider({ children }: { children: React.ReactNode }) {
|
export function ProfileProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [status, setStatus] = useState("");
|
|
||||||
const [borderStatus, setBorderStatus] = useState("border-gray-700");
|
|
||||||
const [glowColor, setGlowColor] = useState("rgba(55, 65, 81, 0.5)");
|
|
||||||
const [statusMessage, setStatusMessage] = useState("[??????]");
|
|
||||||
const [messageIndex, setMessageIndex] = useState(0);
|
|
||||||
const [displayMessage, setDisplayMessage] = useState("");
|
|
||||||
const [isScrambling, setIsScrambling] = useState(false);
|
|
||||||
|
|
||||||
const [selectedMiniProject, setSelectedMiniProject] = useState<MiniProject | null>(null);
|
|
||||||
const [selectedExperience, setSelectedExperience] = useState<Experience | null>(null);
|
|
||||||
|
|
||||||
const [projects, setProjects] = useState<Project[]>([]);
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
const [miniProjects, setMiniProjects] = useState<MiniProject[]>([]);
|
const [experiences, setExperiences] = useState<WorkExperience[]>([]);
|
||||||
const [experiences, setExperiences] = useState<Experience[]>([]);
|
const [affiliates, setAffiliates] = useState<Affiliate[]>([]);
|
||||||
const [realWork, setRealWork] = useState<RealWork[]>([]);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
const rotatingMessages = useMemo(
|
|
||||||
() => [
|
|
||||||
"Yelling at Luna",
|
|
||||||
"Assigning more Issues to myself and letting Luna do them",
|
|
||||||
"NOT coding in Rust 😂✌️",
|
|
||||||
"Rewriting the same helper 3 times",
|
|
||||||
"Micro-service-maxxing",
|
|
||||||
"Blaming SHSF for my bad code",
|
|
||||||
"Shipping fast SHSF code",
|
|
||||||
"Writing code with 1.4k+ Lines",
|
|
||||||
"Love-Hate relationship with TypeScript",
|
|
||||||
"Blaming Openai",
|
|
||||||
"Undescribable Music Taste"
|
|
||||||
],
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const characters =
|
|
||||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()-_=+[]{}|;:',.<>?/`~" +
|
|
||||||
"?????????????????????????";
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (status === "online") {
|
let cancelled = false;
|
||||||
setBorderStatus("border-green-500");
|
|
||||||
setGlowColor("rgba(34, 197, 94, 0.5)");
|
|
||||||
setStatusMessage("Reachable");
|
|
||||||
} else if (status === "offline") {
|
|
||||||
setBorderStatus("border-gray-500");
|
|
||||||
setGlowColor("rgba(107, 114, 128, 0.5)");
|
|
||||||
setStatusMessage("Probably Away");
|
|
||||||
} else if (status === "dnd") {
|
|
||||||
setBorderStatus("border-red-600");
|
|
||||||
setGlowColor("rgba(220, 38, 38, 0.5)");
|
|
||||||
setStatusMessage("Not Reachable");
|
|
||||||
} else if (status === "idle") {
|
|
||||||
setBorderStatus("border-yellow-500");
|
|
||||||
setGlowColor("rgba(234, 179, 8, 0.5)");
|
|
||||||
setStatusMessage("Doing anything but work");
|
|
||||||
} else {
|
|
||||||
setBorderStatus("border-gray-700");
|
|
||||||
setGlowColor("rgba(55, 65, 81, 0.5)");
|
|
||||||
setStatusMessage("[??????]");
|
|
||||||
}
|
|
||||||
}, [status]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
async function load<T>(url: string, set: (v: T[]) => void) {
|
||||||
fetch("https://shsf-api.reversed.dev/api/exec/6/c084ec4a-1b20-491e-ab2e-67c5fa8881e6")
|
|
||||||
.then((res) => res.json())
|
|
||||||
.then((data) => setStatus(data.status))
|
|
||||||
.catch(() => setStatus("offline"));
|
|
||||||
|
|
||||||
const fetchRealWork = async () => {
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch("https://shsf-api.reversed.dev/api/exec/4/e942538c-caa1-49d1-8953-dfab1e62f8cb/real_work");
|
const res = await fetch(url);
|
||||||
if (res.ok) setRealWork(await res.json());
|
if (!res.ok) return;
|
||||||
} catch { setRealWork([]); }
|
const json = await res.json();
|
||||||
|
if (!cancelled) set(Array.isArray(json) ? json : []);
|
||||||
|
} catch {
|
||||||
|
/* 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(() => {
|
return (
|
||||||
const targetMessage = rotatingMessages[messageIndex];
|
<ProfileContext.Provider value={{ projects, experiences, affiliates, loading }}>
|
||||||
if (isScrambling) {
|
{children}
|
||||||
let iteration = 0;
|
</ProfileContext.Provider>
|
||||||
const interval = setInterval(() => {
|
);
|
||||||
setDisplayMessage(targetMessage.split("").map((char, i) => {
|
|
||||||
if (i < iteration) return targetMessage[i];
|
|
||||||
if (char === " " || /[\u{1F000}-\u{1F9FF}]/u.test(char)) return char;
|
|
||||||
return characters[Math.floor(Math.random() * characters.length)];
|
|
||||||
}).join(""));
|
|
||||||
if (iteration >= targetMessage.length) {
|
|
||||||
clearInterval(interval);
|
|
||||||
setIsScrambling(false);
|
|
||||||
}
|
|
||||||
iteration += 1;
|
|
||||||
}, 15);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
} else {
|
|
||||||
setDisplayMessage(targetMessage);
|
|
||||||
}
|
|
||||||
}, [messageIndex, isScrambling, characters, rotatingMessages]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
setIsScrambling(true);
|
|
||||||
setMessageIndex((prev) => (prev + 1) % rotatingMessages.length);
|
|
||||||
}, 7000);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [rotatingMessages.length]);
|
|
||||||
|
|
||||||
const value = {
|
|
||||||
status, statusMessage, borderStatus, glowColor, displayMessage, isScrambling,
|
|
||||||
rotatingMessages,
|
|
||||||
projects, miniProjects, experiences, realWork,
|
|
||||||
selectedMiniProject, setSelectedMiniProject,
|
|
||||||
selectedExperience, setSelectedExperience
|
|
||||||
};
|
|
||||||
|
|
||||||
return <ProfileContext.Provider value={value}>{children}</ProfileContext.Provider>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useProfile() {
|
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";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
const activitySvg =
|
const activitySvg =
|
||||||
"https://gitact.spaceistyping.com/activity.svg?days=365&source=all&theme=dark";
|
"https://gitact.spaceistyping.com/activity.svg?days=365&source=all&theme=dark";
|
||||||
|
|
||||||
export function Activity() {
|
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 (
|
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">
|
<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">
|
<h2 className="text-2xl font-semibold text-white sm:text-3xl">Code Activity</h2>
|
||||||
Activity
|
<p className="mx-auto max-w-2xl text-sm text-gray-400 sm:text-base">
|
||||||
</h2>
|
Today, i've already been coding for
|
||||||
<p className="text-gray-400 max-w-2xl mx-auto">
|
<span className="font-mono text-base text-green-400 ml-1">
|
||||||
A live snapshot of my recent Git activity.
|
{dailyActivity || "..."}
|
||||||
|
</span>
|
||||||
|
, across approximately
|
||||||
|
<span className="font-mono text-base text-green-400 ml-1">
|
||||||
|
{projectCount !== null ? projectCount : "..."}
|
||||||
|
</span>{" "}
|
||||||
|
projects .
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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="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-hidden rounded-2xl border border-white/10 bg-[#0a0a12]">
|
<div className="overflow-x-auto rounded-2xl bg-[#0d1117]">
|
||||||
<img
|
<img
|
||||||
src={activitySvg}
|
src={activitySvg}
|
||||||
alt="Git activity graph"
|
alt="Git activity graph"
|
||||||
@@ -26,6 +49,38 @@ export function Activity() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+43
-35
@@ -1,60 +1,65 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
type Affiliate = {
|
import { useProfile } from "../context/ProfileContext";
|
||||||
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",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export function Affiliates() {
|
export function Affiliates() {
|
||||||
|
const { affiliates } = useProfile();
|
||||||
|
|
||||||
|
if (affiliates.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="w-full max-w-5xl mx-auto space-y-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">
|
<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">
|
<h2 className="text-2xl font-semibold text-white sm:text-3xl">Affiliates</h2>
|
||||||
Affiliates
|
<p className="mx-auto max-w-2xl text-sm text-gray-500 sm:text-base">
|
||||||
</h2>
|
Hosting and services I actually use and recommend.
|
||||||
|
</p>
|
||||||
</div>
|
</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) => (
|
{affiliates.map((affiliate) => (
|
||||||
<a
|
<a
|
||||||
key={affiliate.name}
|
key={affiliate.id}
|
||||||
href={affiliate.link}
|
href={affiliate.link}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="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
|
<img
|
||||||
src={affiliate.icon}
|
src={affiliate.icon}
|
||||||
alt={affiliate.name}
|
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>
|
<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>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
{affiliate.provides.length > 0 ? (
|
||||||
<div>
|
<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">
|
<div className="flex flex-wrap gap-2">
|
||||||
{affiliate.good.map((item) => (
|
{affiliate.good.map((item) => (
|
||||||
<span
|
<span
|
||||||
@@ -67,8 +72,10 @@ export function Affiliates() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
|
{affiliate.bad.length > 0 ? (
|
||||||
<div>
|
<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">
|
<div className="flex flex-wrap gap-2">
|
||||||
{affiliate.bad.map((item) => (
|
{affiliate.bad.map((item) => (
|
||||||
<span
|
<span
|
||||||
@@ -81,6 +88,7 @@ export function Affiliates() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
|
|||||||
+10
-10
@@ -1,19 +1,19 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { CONTACT_EMAIL } from "../types";
|
||||||
|
|
||||||
export function Contact() {
|
export function Contact() {
|
||||||
return (
|
return (
|
||||||
<section className="w-full max-w-2xl text-center space-y-4 pb-8 mx-auto">
|
<section className="mx-auto w-full max-w-2xl space-y-4 pb-8 text-center">
|
||||||
<h2 className="text-3xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-green-400 via-blue-500 to-purple-500">
|
<h2 className="text-2xl font-semibold text-white sm:text-3xl">Want to talk?</h2>
|
||||||
Want to talk?
|
<div className="rounded-3xl border border-white/10 bg-white/[0.03] p-6 sm:p-8">
|
||||||
</h2>
|
<p className="mb-6 text-base text-gray-300 sm:mb-8 sm:text-lg">
|
||||||
<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">
|
|
||||||
Im almost always down to talk about anything. Tech, life and whatever. Feel free to reach out to me on Discord or via Email, i usually respond pretty quickly.
|
Im almost always down to talk about anything. Tech, life and whatever. Feel free to reach out to me on Discord or via Email, i usually respond pretty quickly.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
<div className="flex flex-col justify-center gap-3 sm:flex-row sm:gap-4">
|
||||||
<a
|
<a
|
||||||
href="mailto:space@reversed.dev"
|
href={`mailto:${CONTACT_EMAIL}`}
|
||||||
className="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"
|
className="w-full rounded-full bg-gradient-to-r from-white to-gray-100 px-6 py-3 font-bold text-black transition-all shadow-lg shadow-white/20 hover:from-gray-100 hover:to-white hover:shadow-white/30 sm:w-auto"
|
||||||
>
|
>
|
||||||
Shoot me an Email
|
Shoot me an Email
|
||||||
</a>
|
</a>
|
||||||
@@ -21,7 +21,7 @@ export function Contact() {
|
|||||||
href="https://discord.com/users/456443941169004545"
|
href="https://discord.com/users/456443941169004545"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
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
|
Chat on Discord
|
||||||
</a>
|
</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 { useUmami } from "@danielgtmn/umami-react";
|
||||||
import { Github, Mail, MessageSquare } from "lucide-react";
|
import { Github, Mail, MessageSquare } from "lucide-react";
|
||||||
|
import { CONTACT_EMAIL } from "../types";
|
||||||
|
|
||||||
export function Footer() {
|
export function Footer() {
|
||||||
const currentYear = new Date().getFullYear();
|
const currentYear = new Date().getFullYear();
|
||||||
@@ -29,12 +30,10 @@ export function Footer() {
|
|||||||
<Github className="w-5 h-5" />
|
<Github className="w-5 h-5" />
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href="mailto:space@reversed.dev"
|
href={`mailto:${CONTACT_EMAIL}`}
|
||||||
className="hover:text-purple-400 transition-colors"
|
className="hover:text-purple-400 transition-colors"
|
||||||
aria-label="Email"
|
aria-label="Email"
|
||||||
onClick={() =>
|
onClick={() => handleLinkClick("Email", "mailto")}
|
||||||
handleLinkClick("Email", "mailto:space@reversed.dev")
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Mail className="w-5 h-5" />
|
<Mail className="w-5 h-5" />
|
||||||
</a>
|
</a>
|
||||||
@@ -58,7 +57,7 @@ export function Footer() {
|
|||||||
{/* Branding & Attribution */}
|
{/* Branding & Attribution */}
|
||||||
<div className="flex flex-col items-center gap-2 text-center">
|
<div className="flex flex-col items-center gap-2 text-center">
|
||||||
<p className="text-gray-500 text-xs tracking-wide">
|
<p className="text-gray-500 text-xs tracking-wide">
|
||||||
© {currentYear} • Space-Banane
|
© {currentYear} • Paul W.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-3 text-[10px] text-gray-600 uppercase tracking-[0.2em]">
|
<div className="flex items-center gap-3 text-[10px] text-gray-600 uppercase tracking-[0.2em]">
|
||||||
<a
|
<a
|
||||||
|
|||||||
@@ -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";
|
"use client";
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
|
|
||||||
export function Hero({
|
import { useEffect, useRef, useState } from "react";
|
||||||
glowColor,
|
import { useRouter } from "next/navigation";
|
||||||
borderStatus,
|
import { Mail } from "lucide-react";
|
||||||
displayMessage,
|
import { CONTACT_EMAIL } from "../types";
|
||||||
rotatingMessages,
|
|
||||||
statusMessage,
|
const HANDLE = "getspaced";
|
||||||
oldUsernames,
|
|
||||||
}: {
|
// Discord presence via SHSF (drives the avatar ring + name dot colour).
|
||||||
glowColor: string;
|
const STATUS_URL =
|
||||||
borderStatus: string;
|
"https://shsf.reversed.dev/api/exec/6/c084ec4a-1b20-491e-ab2e-67c5fa8881e6";
|
||||||
displayMessage: string;
|
|
||||||
rotatingMessages: string[];
|
type Presence = "online" | "idle" | "dnd" | "offline";
|
||||||
statusMessage: string;
|
|
||||||
oldUsernames: string[];
|
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 router = useRouter();
|
||||||
const [movementCount, setMovementCount] = useState(0);
|
const [presence, setPresence] = useState<Presence>("offline");
|
||||||
|
|
||||||
|
// Hidden easter egg: rapidly shake the mouse back and forth over the avatar
|
||||||
|
// (10 quick direction changes) to jump to the admin panel.
|
||||||
const lastX = useRef<number | null>(null);
|
const lastX = useRef<number | null>(null);
|
||||||
const lastDir = useRef<"left" | "right" | null>(null);
|
const lastDir = useRef<"left" | "right" | null>(null);
|
||||||
const lastTime = useRef<number>(0);
|
const lastTime = useRef<number>(0);
|
||||||
const resetTimeout = useRef<NodeJS.Timeout | null>(null);
|
const shakeCount = useRef<number>(0);
|
||||||
|
const resetTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
const handleMouseMove = (e: React.MouseEvent) => {
|
const handleAvatarMouseMove = (e: React.MouseEvent) => {
|
||||||
const currentX = e.clientX;
|
const currentX = e.clientX;
|
||||||
const currentTime = Date.now();
|
const currentTime = Date.now();
|
||||||
|
|
||||||
@@ -32,22 +65,19 @@ export function Hero({
|
|||||||
const velocity = Math.abs(deltaX) / (currentTime - lastTime.current || 1);
|
const velocity = Math.abs(deltaX) / (currentTime - lastTime.current || 1);
|
||||||
const direction = deltaX > 0 ? "right" : "left";
|
const direction = deltaX > 0 ? "right" : "left";
|
||||||
|
|
||||||
// Thresholds: velocity > 2 (approx 2px/ms) and changed direction
|
|
||||||
if (velocity > 2 && direction !== lastDir.current) {
|
if (velocity > 2 && direction !== lastDir.current) {
|
||||||
setMovementCount((prev) => {
|
shakeCount.current += 1;
|
||||||
const newCount = prev + 1;
|
|
||||||
if (newCount >= 10) {
|
|
||||||
// Trigger navigation outside of the state update to avoid React warning
|
|
||||||
setTimeout(() => router.push("/admin"), 0);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return newCount;
|
|
||||||
});
|
|
||||||
lastDir.current = direction;
|
lastDir.current = direction;
|
||||||
|
|
||||||
// Reset if no movement for a while
|
if (shakeCount.current >= 10) {
|
||||||
|
shakeCount.current = 0;
|
||||||
|
setTimeout(() => router.push("/admin"), 0);
|
||||||
|
}
|
||||||
|
|
||||||
if (resetTimeout.current) clearTimeout(resetTimeout.current);
|
if (resetTimeout.current) clearTimeout(resetTimeout.current);
|
||||||
resetTimeout.current = setTimeout(() => setMovementCount(0), 1000);
|
resetTimeout.current = setTimeout(() => {
|
||||||
|
shakeCount.current = 0;
|
||||||
|
}, 1000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,119 +85,100 @@ export function Hero({
|
|||||||
lastTime.current = currentTime;
|
lastTime.current = currentTime;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
fetch(STATUS_URL)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((d) => {
|
||||||
|
if (!cancelled && isPresence(d?.status)) setPresence(d.status);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* keep offline on failure */
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const style = PRESENCE[presence];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section className="mx-auto w-full max-w-5xl px-4">
|
||||||
className="mt-20 flex flex-col items-center text-center space-y-8 animate-fade-in"
|
<div className="flex flex-col-reverse items-center gap-8 sm:flex-row sm:items-start sm:justify-between sm:gap-10">
|
||||||
data-old-usernames={oldUsernames.join(",")}
|
{/* Left: identity */}
|
||||||
>
|
<div className="flex-1 space-y-5 text-center sm:text-left">
|
||||||
<div className="relative group" onMouseMove={handleMouseMove}>
|
<div className="space-y-2">
|
||||||
<div
|
<h1 className="flex items-center justify-center gap-3 text-4xl font-extrabold text-white sm:justify-start sm:text-5xl">
|
||||||
className={`absolute -inset-1 rounded-full blur opacity-75 transition duration-500`}
|
Paul W.
|
||||||
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{" "}
|
|
||||||
<span
|
<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"
|
className={`inline-block h-3 w-3 rounded-full transition-colors duration-500 ${style.dot}`}
|
||||||
style={{ marginLeft: "0.15em", marginRight: "0.15em" }}
|
title={style.label}
|
||||||
>
|
aria-label={style.label}
|
||||||
Space²
|
/>
|
||||||
</span>
|
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-2xl text-gray-400 font-light">
|
<p className="font-mono text-sm text-gray-500">{HANDLE}</p>
|
||||||
A{" "}
|
</div>
|
||||||
<span className="line-through decoration-purple-500/50 decoration-2">
|
|
||||||
Self-proclaimed
|
<p className="mx-auto max-w-xl text-base leading-relaxed text-gray-400 sm:mx-0 sm:text-lg">
|
||||||
</span>{" "}
|
Full-stack developer & open-source author. Building server
|
||||||
Developer breaking things to see how they work.
|
infrastructure, developer tools, and web applications.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Luna AI Profile Link */}
|
<div className="flex flex-wrap items-center justify-center gap-3 sm:justify-start">
|
||||||
<div className="pt-4 flex justify-center">
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<a
|
<a
|
||||||
href="https://luna.reversed.dev"
|
href={`mailto:${CONTACT_EMAIL}`}
|
||||||
target="_blank"
|
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-5 py-2.5 text-sm font-medium text-gray-200 transition-all duration-300 hover:border-white/25 hover:bg-white/10"
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="group relative flex 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"
|
|
||||||
>
|
>
|
||||||
<div className="w-3 h-3 rounded-full bg-purple-500" />
|
<Mail className="h-4 w-4" />
|
||||||
<span className="text-sm font-medium text-gray-300 group-hover:text-white transition-colors">
|
Contact
|
||||||
<span className="text-purple-400 font-semibold">Luna</span>
|
|
||||||
</span>
|
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a
|
<a
|
||||||
href="https://github.com/Space-Banane"
|
href="https://github.com/Space-Banane"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="group relative flex items-center gap-2 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"
|
aria-label="GitHub"
|
||||||
>
|
>
|
||||||
<svg className="w-6 h-6 text-gray-300 group-hover:text-white" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
|
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
|
||||||
<path d="M12 2C6.48 2 2 6.48 2 12c0 4.42 2.87 8.17 6.84 9.49.5.09.68-.22.68-.48 0-.24-.01-.87-.01-1.71-2.78.6-3.37-1.34-3.37-1.34-.45-1.16-1.11-1.47-1.11-1.47-.91-.62.07-.61.07-.61 1 .07 1.53 1.03 1.53 1.03.89 1.52 2.34 1.08 2.91.83.09-.65.35-1.08.64-1.33-2.22-.25-4.56-1.11-4.56-4.95 0-1.09.39-1.98 1.03-2.68-.1-.25-.45-1.27.1-2.65 0 0 .84-.27 2.75 1.02A9.56 9.56 0 0112 6.8c.85.004 1.71.115 2.51.338 1.91-1.29 2.75-1.02 2.75-1.02.55 1.38.2 2.4.10 2.65.64.7 1.03 1.59 1.03 2.68 0 3.85-2.34 4.7-4.57 4.95.36.31.68.92.68 1.86 0 1.34-.01 2.42-.01 2.75 0 .26.18.58.69.48A10.01 10.01 0 0022 12c0-5.52-4.48-10-10-10z" />
|
<path d="M12 2C6.48 2 2 6.48 2 12c0 4.42 2.87 8.17 6.84 9.49.5.09.68-.22.68-.48 0-.24-.01-.87-.01-1.71-2.78.6-3.37-1.34-3.37-1.34-.45-1.16-1.11-1.47-1.11-1.47-.91-.62.07-.61.07-.61 1 .07 1.53 1.03 1.53 1.03.89 1.52 2.34 1.08 2.91.83.09-.65.35-1.08.64-1.33-2.22-.25-4.56-1.11-4.56-4.95 0-1.09.39-1.98 1.03-2.68-.1-.25-.45-1.27.1-2.65 0 0 .84-.27 2.75 1.02A9.56 9.56 0 0112 6.8c.85.004 1.71.115 2.51.338 1.91-1.29 2.75-1.02 2.75-1.02.55 1.38.2 2.4.10 2.65.64.7 1.03 1.59 1.03 2.68 0 3.85-2.34 4.7-4.57 4.95.36.31.68.92.68 1.86 0 1.34-.01 2.42-.01 2.75 0 .26.18.58.69.48A10.01 10.01 0 0022 12c0-5.52-4.48-10-10-10z" />
|
||||||
</svg>
|
</svg>
|
||||||
|
GitHub
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a
|
<a
|
||||||
href="https://gitea.reversed.dev/space"
|
href="https://gitea.reversed.dev/space"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="group relative flex items-center gap-2 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"
|
aria-label="Gitea"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src="https://gitea.reversed.dev/assets/img/logo.svg"
|
src="https://gitea.reversed.dev/assets/img/logo.svg"
|
||||||
alt="Gitea"
|
alt=""
|
||||||
className="w-6 h-6"
|
className="h-5 w-5"
|
||||||
/>
|
/>
|
||||||
|
Gitea
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs uppercase tracking-[0.2em] text-gray-600">
|
||||||
|
<span className="text-gray-500">◷</span> Europe/Berlin
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: avatar with live status ring */}
|
||||||
|
<div className="relative shrink-0" onMouseMove={handleAvatarMouseMove}>
|
||||||
|
<div className={`absolute -inset-1 rounded-full blur-md transition-colors duration-500 ${style.glow}`} />
|
||||||
|
<div
|
||||||
|
className={`relative h-32 w-32 overflow-hidden rounded-full border-4 bg-black transition-colors duration-500 sm:h-44 sm:w-44 ${style.ring}`}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/api/profile-image"
|
||||||
|
alt="Paul W."
|
||||||
|
className="h-full w-full scale-105 object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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";
|
"use client";
|
||||||
|
|
||||||
import type { RealWork } from "../types";
|
import type { WorkExperience as WorkExperienceType } from "../types";
|
||||||
|
|
||||||
interface WorkExperienceProps {
|
interface WorkExperienceProps {
|
||||||
realWork: RealWork[];
|
experiences: WorkExperienceType[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function getWorkDateTimestamp(dateValue?: string) {
|
function formatMonth(iso: string) {
|
||||||
if (!dateValue) return Number.NEGATIVE_INFINITY;
|
const d = new Date(iso);
|
||||||
const timestamp = Date.parse(dateValue);
|
if (Number.isNaN(d.getTime())) return iso;
|
||||||
return Number.isNaN(timestamp) ? Number.NEGATIVE_INFINITY : timestamp;
|
return d.toLocaleDateString("en-US", { year: "numeric", month: "short", timeZone: "UTC" });
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSortTimestamp(entry: RealWork) {
|
/** "X yr Y mo" duration between two dates (toDate null = now). */
|
||||||
if (!entry.until || entry.until.trim().toLowerCase() === "present") {
|
function formatDuration(fromIso: string, toIso?: string | null) {
|
||||||
return Number.POSITIVE_INFINITY;
|
const from = new Date(fromIso);
|
||||||
}
|
const to = toIso ? new Date(toIso) : new Date();
|
||||||
const untilTimestamp = getWorkDateTimestamp(entry.until);
|
if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime())) return "";
|
||||||
if (untilTimestamp !== Number.NEGATIVE_INFINITY) return untilTimestamp;
|
|
||||||
return getWorkDateTimestamp(entry.from);
|
let months =
|
||||||
|
(to.getFullYear() - from.getFullYear()) * 12 + (to.getMonth() - from.getMonth());
|
||||||
|
if (to.getDate() >= from.getDate()) months += 1; // count the current/partial month
|
||||||
|
months = Math.max(months, 1);
|
||||||
|
|
||||||
|
const years = Math.floor(months / 12);
|
||||||
|
const rem = months % 12;
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (years > 0) parts.push(`${years} yr`);
|
||||||
|
if (rem > 0) parts.push(`${rem} mo`);
|
||||||
|
return parts.join(" ");
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatWorkDate(dateValue: string) {
|
export function WorkExperience({ experiences }: WorkExperienceProps) {
|
||||||
const yearMonthMatch = /^(\d{4})-(\d{2})$/.exec(dateValue.trim());
|
|
||||||
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),
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
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">
|
<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">
|
<h2 className="text-2xl font-semibold text-white sm:text-3xl">Work Experience</h2>
|
||||||
Work Experience
|
<p className="mx-auto max-w-2xl text-sm text-gray-500 sm:text-base">
|
||||||
</h2>
|
Actual work that i did at actual companies.
|
||||||
<p className="text-gray-400 max-w-2xl mx-auto">
|
|
||||||
Professional collaborations and product work I have contributed to.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{realWork.length === 0 ? (
|
{experiences.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">
|
<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-300">No work experience entries available yet.</p>
|
<p className="text-gray-400">No work experience entries available yet.</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{sortedRealWork.map((entry, index) => (
|
{experiences.map((entry) => (
|
||||||
<div
|
<div
|
||||||
key={`${entry.company}-${index}`}
|
key={entry.id}
|
||||||
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"
|
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>
|
<div>
|
||||||
<h3 className="text-2xl font-semibold text-white">{entry.company}</h3>
|
<h3 className="text-xl font-semibold text-white sm:text-2xl">
|
||||||
{entry.role ? (
|
{entry.company}
|
||||||
<p className="text-sm font-medium text-cyan-200/90 mt-1">{entry.role}</p>
|
</h3>
|
||||||
) : null}
|
<p className="mt-1 text-sm font-medium text-gray-300">{entry.role}</p>
|
||||||
{entry.from || entry.until ? (
|
<p className="mt-1 text-xs font-medium uppercase tracking-wide text-gray-500">
|
||||||
<p className="text-xs font-medium uppercase tracking-wide text-cyan-300/80 mt-1">
|
{formatMonth(entry.fromDate)} —{" "}
|
||||||
{entry.from ? formatWorkDate(entry.from) : "Unknown"} -{" "}
|
{entry.toDate ? formatMonth(entry.toDate) : "Present"}
|
||||||
{entry.until
|
<span className="ml-2 text-gray-600">
|
||||||
? entry.until.trim().toLowerCase() === "present"
|
· {formatDuration(entry.fromDate, entry.toDate)}
|
||||||
? "Present"
|
</span>
|
||||||
: formatWorkDate(entry.until)
|
|
||||||
: "Present"}
|
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{entry.url ? (
|
{entry.url ? (
|
||||||
<a
|
<a
|
||||||
href={entry.url}
|
href={entry.url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
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>
|
<span>{new URL(entry.url).hostname.replace(/^www\./, "")}</span>
|
||||||
<svg
|
<svg viewBox="0 0 20 20" fill="currentColor" aria-hidden className="h-4 w-4">
|
||||||
viewBox="0 0 20 20"
|
|
||||||
fill="currentColor"
|
|
||||||
aria-hidden="true"
|
|
||||||
className="h-4 w-4"
|
|
||||||
>
|
|
||||||
<path d="M5 6.75A1.75 1.75 0 0 1 6.75 5h2a.75.75 0 0 0 0-1.5h-2A3.25 3.25 0 0 0 3.5 6.75v6.5A3.25 3.25 0 0 0 6.75 16.5h6.5a3.25 3.25 0 0 0 3.25-3.25v-2a.75.75 0 0 0-1.5 0v2A1.75 1.75 0 0 1 13.25 15h-6.5A1.75 1.75 0 0 1 5 13.25v-6.5Z" />
|
<path d="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" />
|
<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>
|
</svg>
|
||||||
@@ -110,14 +91,14 @@ export function WorkExperience({ realWork }: WorkExperienceProps) {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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">
|
<div className="flex flex-wrap gap-2">
|
||||||
{entry.tags.map((tag) => (
|
{entry.tags.map((tag) => (
|
||||||
<span
|
<span
|
||||||
key={`${entry.company}-${tag}`}
|
key={`${entry.id}-${tag}`}
|
||||||
className="px-3 py-1 rounded-full text-xs font-semibold bg-cyan-500/20 text-cyan-200 border border-cyan-500/30"
|
className="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs font-medium text-gray-300"
|
||||||
>
|
>
|
||||||
{tag}
|
{tag}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
+80
-33
@@ -1,43 +1,90 @@
|
|||||||
export interface MiniProject {
|
// Shapes returned by our own API endpoints (dates are ISO strings over JSON).
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
image?: string;
|
|
||||||
github?: string;
|
|
||||||
reproduction?: string;
|
|
||||||
why: string;
|
|
||||||
note?: {
|
|
||||||
color: string;
|
|
||||||
content: string;
|
|
||||||
};
|
|
||||||
last_commit?: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Experience {
|
export type ProjectSize = "Big" | "MediumSized" | "Small";
|
||||||
name: string;
|
|
||||||
type: "languages" | "software" | "plattforms" | "experience" | "other";
|
|
||||||
description: string;
|
|
||||||
image?: string;
|
|
||||||
learned_at?: string;
|
|
||||||
learned_from?: string;
|
|
||||||
learned_because?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Project {
|
export interface Project {
|
||||||
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
label: string;
|
||||||
image?: string;
|
size: ProjectSize;
|
||||||
|
imageUrl?: string | null;
|
||||||
link: string;
|
link: string;
|
||||||
open_source: false | { link: string };
|
linkIsDemo: boolean;
|
||||||
rounded?: boolean;
|
sourceCode?: string | null;
|
||||||
last_commit?: Date;
|
description: string;
|
||||||
|
why: string;
|
||||||
|
note?: string | null;
|
||||||
|
tags: string[];
|
||||||
|
loc?: number | null;
|
||||||
|
/** Resolved line-of-code count (manual `loc` or fetched from `locEndpoint`). */
|
||||||
|
resolvedLoc?: number | null;
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RealWork {
|
export interface WorkExperience {
|
||||||
|
id: string;
|
||||||
company: string;
|
company: string;
|
||||||
role?: string;
|
role: string;
|
||||||
from?: string;
|
fromDate: string;
|
||||||
until?: string;
|
toDate?: string | null;
|
||||||
url?: string;
|
url?: string | null;
|
||||||
|
iconUrl?: string | null;
|
||||||
summary: string;
|
summary: string;
|
||||||
tags?: string[];
|
tags: string[];
|
||||||
|
sortIndex: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Affiliate {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
link: string;
|
||||||
|
icon: string;
|
||||||
|
location: string;
|
||||||
|
provides: string[];
|
||||||
|
good: string[];
|
||||||
|
bad: string[];
|
||||||
|
sortIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const 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