Little rework
This commit is contained in:
+4
-1
@@ -41,4 +41,7 @@ yarn-error.log*
|
|||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
old/
|
old/
|
||||||
.next
|
.next
|
||||||
|
|
||||||
|
# prisma generated client
|
||||||
|
/src/generated
|
||||||
Binary file not shown.
@@ -4,18 +4,46 @@ Personal portfolio built with Next.js, React, TypeScript, and Tailwind CSS.
|
|||||||
|
|
||||||
## What It Includes
|
## 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
|
- Luna section, DB-driven affiliates, and the SHSF Code Activity graph
|
||||||
- Project and mini-project showcases
|
- Admin CMS at `/admin` for projects, work experience, and affiliates
|
||||||
- Contact page and admin page
|
|
||||||
|
## Data layer
|
||||||
|
|
||||||
|
Content (projects, work experience, affiliates) lives in **Postgres** and is
|
||||||
|
managed through Prisma + our own Next.js API routes:
|
||||||
|
|
||||||
|
- Public reads: `GET /api/projects`, `/api/experience`, `/api/affiliates`
|
||||||
|
- Admin writes: `POST/PUT/DELETE /api/admin/{projects,experience,affiliates}/…`
|
||||||
|
(cookie session issued by `POST /api/admin/login`)
|
||||||
|
- `/api/profile-image` serves the avatar (cached in memory)
|
||||||
|
- Only the **Code Activity** widget still uses the external SHSF API.
|
||||||
|
|
||||||
|
### Environment
|
||||||
|
|
||||||
|
Copy `.env.example` to `.env` and set:
|
||||||
|
|
||||||
|
| Var | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `DATABASE_URL` | Postgres connection string |
|
||||||
|
| `ADMIN_PASSWORD` | Password for the `/admin` login |
|
||||||
|
| `SESSION_SECRET` | Long random string used to sign the admin session cookie |
|
||||||
|
| `PROFILE_IMAGE_URL` | (optional) override for the avatar source |
|
||||||
|
|
||||||
## Development
|
## 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:
|
||||||
@@ -61,4 +89,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.
|
||||||
|
|||||||
+5
-1
@@ -5,13 +5,16 @@
|
|||||||
"packageManager": "pnpm@10.30.3",
|
"packageManager": "pnpm@10.30.3",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 5173",
|
"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",
|
||||||
@@ -27,6 +30,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
+859
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
|||||||
|
import "dotenv/config";
|
||||||
|
import { defineConfig, env } from "prisma/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: "prisma/schema.prisma",
|
||||||
|
migrations: {
|
||||||
|
path: "prisma/migrations",
|
||||||
|
},
|
||||||
|
datasource: {
|
||||||
|
url: env("DATABASE_URL"),
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "ProjectSize" AS ENUM ('Big', 'MediumSized', 'Small');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Project" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"label" TEXT NOT NULL,
|
||||||
|
"size" "ProjectSize" NOT NULL DEFAULT 'MediumSized',
|
||||||
|
"imageUrl" TEXT,
|
||||||
|
"link" TEXT NOT NULL,
|
||||||
|
"linkIsDemo" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"sourceCode" TEXT,
|
||||||
|
"description" TEXT NOT NULL,
|
||||||
|
"why" TEXT NOT NULL,
|
||||||
|
"note" TEXT,
|
||||||
|
"tags" TEXT[],
|
||||||
|
"loc" INTEGER,
|
||||||
|
"locEndpoint" TEXT,
|
||||||
|
"featured" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"sortIndex" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Project_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "WorkExperience" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"company" TEXT NOT NULL,
|
||||||
|
"role" TEXT NOT NULL,
|
||||||
|
"fromDate" TIMESTAMP(3) NOT NULL,
|
||||||
|
"toDate" TIMESTAMP(3),
|
||||||
|
"url" TEXT,
|
||||||
|
"iconUrl" TEXT,
|
||||||
|
"summary" TEXT NOT NULL,
|
||||||
|
"tags" TEXT[],
|
||||||
|
"sortIndex" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "WorkExperience_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Affiliate" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"link" TEXT NOT NULL,
|
||||||
|
"icon" TEXT NOT NULL,
|
||||||
|
"location" TEXT NOT NULL,
|
||||||
|
"provides" TEXT[],
|
||||||
|
"good" TEXT[],
|
||||||
|
"bad" TEXT[],
|
||||||
|
"sortIndex" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Affiliate_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the column `featured` on the `Project` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the column `sortIndex` on the `Project` table. All the data in the column will be lost.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Project" DROP COLUMN "featured",
|
||||||
|
DROP COLUMN "sortIndex";
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (e.g., Git)
|
||||||
|
provider = "postgresql"
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// Prisma 7 (rust-free `prisma-client` generator + driver adapter).
|
||||||
|
// Client is generated into src/generated/prisma (gitignored) and imported
|
||||||
|
// from "../generated/prisma/client".
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client"
|
||||||
|
output = "../src/generated/prisma"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ProjectSize {
|
||||||
|
Big
|
||||||
|
MediumSized
|
||||||
|
Small
|
||||||
|
}
|
||||||
|
|
||||||
|
model Project {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
label String // free-type string, e.g. "Dev Tool", "SaaS"
|
||||||
|
size ProjectSize @default(MediumSized)
|
||||||
|
imageUrl String? // shown fully, nothing overlapping
|
||||||
|
link String
|
||||||
|
linkIsDemo Boolean @default(false)
|
||||||
|
sourceCode String? // github or gitea link
|
||||||
|
description String
|
||||||
|
why String
|
||||||
|
note String? // free note, replaces old {color, content}
|
||||||
|
tags String[] // max 3 enforced in the API
|
||||||
|
loc Int? // manually provided line-of-code count
|
||||||
|
locEndpoint String? // URL the backend hits for a plaintext LoC number
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model WorkExperience {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
company String
|
||||||
|
role String
|
||||||
|
fromDate DateTime
|
||||||
|
toDate DateTime? // null = Present
|
||||||
|
url String?
|
||||||
|
iconUrl String?
|
||||||
|
summary String
|
||||||
|
tags String[] // max 6 enforced in the API
|
||||||
|
sortIndex Int @default(0)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model Affiliate {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
link String
|
||||||
|
icon String
|
||||||
|
location String
|
||||||
|
provides String[]
|
||||||
|
good String[]
|
||||||
|
bad String[]
|
||||||
|
sortIndex Int @default(0)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
+462
-585
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { parseAffiliate } from "@/lib/dto";
|
||||||
|
import { guard, run, readBody } from "@/lib/adminRoute";
|
||||||
|
|
||||||
|
type Params = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
export async function PUT(req: Request, { params }: Params) {
|
||||||
|
const denied = await guard();
|
||||||
|
if (denied) return denied;
|
||||||
|
return run(async () => {
|
||||||
|
const { id } = await params;
|
||||||
|
const data = parseAffiliate(await readBody(req));
|
||||||
|
const updated = await prisma.affiliate.update({ where: { id }, data });
|
||||||
|
return NextResponse.json(updated);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(_req: Request, { params }: Params) {
|
||||||
|
const denied = await guard();
|
||||||
|
if (denied) return denied;
|
||||||
|
return run(async () => {
|
||||||
|
const { id } = await params;
|
||||||
|
await prisma.affiliate.delete({ where: { id } });
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { parseAffiliate } from "@/lib/dto";
|
||||||
|
import { guard, run, readBody } from "@/lib/adminRoute";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const denied = await guard();
|
||||||
|
if (denied) return denied;
|
||||||
|
return run(async () => {
|
||||||
|
const items = await prisma.affiliate.findMany({
|
||||||
|
orderBy: [{ sortIndex: "asc" }, { createdAt: "asc" }],
|
||||||
|
});
|
||||||
|
return NextResponse.json(items);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const denied = await guard();
|
||||||
|
if (denied) return denied;
|
||||||
|
return run(async () => {
|
||||||
|
const data = parseAffiliate(await readBody(req));
|
||||||
|
const created = await prisma.affiliate.create({ data });
|
||||||
|
return NextResponse.json(created, { status: 201 });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { parseExperience } from "@/lib/dto";
|
||||||
|
import { guard, run, readBody } from "@/lib/adminRoute";
|
||||||
|
|
||||||
|
type Params = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
export async function PUT(req: Request, { params }: Params) {
|
||||||
|
const denied = await guard();
|
||||||
|
if (denied) return denied;
|
||||||
|
return run(async () => {
|
||||||
|
const { id } = await params;
|
||||||
|
const data = parseExperience(await readBody(req));
|
||||||
|
const updated = await prisma.workExperience.update({ where: { id }, data });
|
||||||
|
return NextResponse.json(updated);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(_req: Request, { params }: Params) {
|
||||||
|
const denied = await guard();
|
||||||
|
if (denied) return denied;
|
||||||
|
return run(async () => {
|
||||||
|
const { id } = await params;
|
||||||
|
await prisma.workExperience.delete({ where: { id } });
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { parseExperience } from "@/lib/dto";
|
||||||
|
import { guard, run, readBody } from "@/lib/adminRoute";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const denied = await guard();
|
||||||
|
if (denied) return denied;
|
||||||
|
return run(async () => {
|
||||||
|
const items = await prisma.workExperience.findMany({
|
||||||
|
orderBy: [{ sortIndex: "asc" }, { fromDate: "desc" }],
|
||||||
|
});
|
||||||
|
return NextResponse.json(items);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const denied = await guard();
|
||||||
|
if (denied) return denied;
|
||||||
|
return run(async () => {
|
||||||
|
const data = parseExperience(await readBody(req));
|
||||||
|
const created = await prisma.workExperience.create({ data });
|
||||||
|
return NextResponse.json(created, { status: 201 });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { createSessionToken, verifyPassword, SESSION_COOKIE } from "@/lib/auth";
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
let password = "";
|
||||||
|
try {
|
||||||
|
const body = await req.json();
|
||||||
|
password = typeof body?.password === "string" ? body.password : "";
|
||||||
|
} catch {
|
||||||
|
// ignore malformed body -> treated as invalid
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!verifyPassword(password)) {
|
||||||
|
return NextResponse.json({ error: "Invalid password" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = NextResponse.json({ ok: true });
|
||||||
|
res.cookies.set(SESSION_COOKIE, createSessionToken(), {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "lax",
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
path: "/",
|
||||||
|
maxAge: 60 * 60 * 24 * 7,
|
||||||
|
});
|
||||||
|
return res;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { SESSION_COOKIE } from "@/lib/auth";
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
const res = NextResponse.json({ ok: true });
|
||||||
|
res.cookies.set(SESSION_COOKIE, "", { path: "/", maxAge: 0 });
|
||||||
|
return res;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { parseProject } from "@/lib/dto";
|
||||||
|
import { guard, run, readBody } from "@/lib/adminRoute";
|
||||||
|
|
||||||
|
type Params = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
export async function PUT(req: Request, { params }: Params) {
|
||||||
|
const denied = await guard();
|
||||||
|
if (denied) return denied;
|
||||||
|
return run(async () => {
|
||||||
|
const { id } = await params;
|
||||||
|
const data = parseProject(await readBody(req));
|
||||||
|
const updated = await prisma.project.update({ where: { id }, data });
|
||||||
|
return NextResponse.json(updated);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(_req: Request, { params }: Params) {
|
||||||
|
const denied = await guard();
|
||||||
|
if (denied) return denied;
|
||||||
|
return run(async () => {
|
||||||
|
const { id } = await params;
|
||||||
|
await prisma.project.delete({ where: { id } });
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { parseProject } from "@/lib/dto";
|
||||||
|
import { guard, run, readBody } from "@/lib/adminRoute";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const denied = await guard();
|
||||||
|
if (denied) return denied;
|
||||||
|
return run(async () => {
|
||||||
|
const projects = await prisma.project.findMany({
|
||||||
|
orderBy: [{ size: "asc" }, { name: "asc" }],
|
||||||
|
});
|
||||||
|
return NextResponse.json(projects);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const denied = await guard();
|
||||||
|
if (denied) return denied;
|
||||||
|
return run(async () => {
|
||||||
|
const data = parseProject(await readBody(req));
|
||||||
|
const created = await prisma.project.create({ data });
|
||||||
|
return NextResponse.json(created, { status: 201 });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { isAdmin } from "@/lib/auth";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return NextResponse.json({ authenticated: await isAdmin() });
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
// Public: list affiliates.
|
||||||
|
export async function GET() {
|
||||||
|
const affiliates = await prisma.affiliate.findMany({
|
||||||
|
orderBy: [{ sortIndex: "asc" }, { createdAt: "asc" }],
|
||||||
|
});
|
||||||
|
return NextResponse.json(affiliates);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
// Public: list work experience, most recent first.
|
||||||
|
export async function GET() {
|
||||||
|
const experience = await prisma.workExperience.findMany({
|
||||||
|
orderBy: [{ sortIndex: "asc" }, { fromDate: "desc" }],
|
||||||
|
});
|
||||||
|
return NextResponse.json(experience);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
// Serves the profile picture through our own origin, caching the bytes in
|
||||||
|
// process memory so we don't hit the upstream CDN on every request.
|
||||||
|
|
||||||
|
const DEFAULT_IMAGE = "https://cdn.reversed.dev/pictures/20250405_120402.png";
|
||||||
|
const CACHE_TTL_MS = 1000 * 60 * 60; // 1 hour
|
||||||
|
|
||||||
|
type ImageCache = { body: ArrayBuffer; contentType: string; expiresAt: number };
|
||||||
|
let cached: ImageCache | null = null;
|
||||||
|
|
||||||
|
async function loadImage(): Promise<ImageCache | null> {
|
||||||
|
if (cached && cached.expiresAt > Date.now()) return cached;
|
||||||
|
|
||||||
|
const source = process.env.PROFILE_IMAGE_URL || DEFAULT_IMAGE;
|
||||||
|
try {
|
||||||
|
const res = await fetch(source, { signal: AbortSignal.timeout(8000) });
|
||||||
|
if (!res.ok) return cached; // fall back to stale copy if present
|
||||||
|
const body = await res.arrayBuffer();
|
||||||
|
cached = {
|
||||||
|
body,
|
||||||
|
contentType: res.headers.get("content-type") || "image/png",
|
||||||
|
expiresAt: Date.now() + CACHE_TTL_MS,
|
||||||
|
};
|
||||||
|
return cached;
|
||||||
|
} catch {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const image = await loadImage();
|
||||||
|
if (!image) {
|
||||||
|
return NextResponse.json({ error: "Image unavailable" }, { status: 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return new NextResponse(image.body, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": image.contentType,
|
||||||
|
"Cache-Control": "public, max-age=3600, stale-while-revalidate=86400",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { resolveLoc } from "@/lib/loc";
|
||||||
|
|
||||||
|
// Public: list all projects (served from our DB), each with a resolved
|
||||||
|
// line-of-code count (manual `loc` or fetched from `locEndpoint`, cached).
|
||||||
|
export async function GET() {
|
||||||
|
// Ordered by size (Big -> MediumSized -> Small via the enum's declared order),
|
||||||
|
// then alphabetically by name.
|
||||||
|
const projects = await prisma.project.findMany({
|
||||||
|
orderBy: [{ size: "asc" }, { name: "asc" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const withLoc = await Promise.all(
|
||||||
|
projects.map(async (p) => ({
|
||||||
|
id: p.id,
|
||||||
|
name: p.name,
|
||||||
|
label: p.label,
|
||||||
|
size: p.size,
|
||||||
|
imageUrl: p.imageUrl,
|
||||||
|
link: p.link,
|
||||||
|
linkIsDemo: p.linkIsDemo,
|
||||||
|
sourceCode: p.sourceCode,
|
||||||
|
description: p.description,
|
||||||
|
why: p.why,
|
||||||
|
note: p.note,
|
||||||
|
tags: p.tags,
|
||||||
|
loc: p.loc,
|
||||||
|
resolvedLoc: await resolveLoc(p.loc, p.locEndpoint),
|
||||||
|
createdAt: p.createdAt,
|
||||||
|
updatedAt: p.updatedAt,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json(withLoc);
|
||||||
|
}
|
||||||
+14
-15
@@ -3,6 +3,7 @@
|
|||||||
import { Contact as ContactSection } from "../../sections/Contact";
|
import { 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 (
|
||||||
@@ -10,11 +11,11 @@ export default function ContactPage() {
|
|||||||
<Navbar />
|
<Navbar />
|
||||||
<div className="mx-auto flex min-h-screen w-full max-w-5xl flex-col items-center justify-start space-y-10 px-4 pb-16 pt-20 text-white sm:justify-center sm:space-y-12 sm:pb-20 sm:pt-24">
|
<div className="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-4xl font-extrabold text-white sm:text-5xl md:text-6xl">
|
<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="mx-auto max-w-xl text-base text-gray-400 sm:text-lg md:text-xl">
|
<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>
|
||||||
|
|
||||||
@@ -22,24 +23,22 @@ export default function ContactPage() {
|
|||||||
<ContactSection />
|
<ContactSection />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid w-full max-w-4xl grid-cols-1 gap-4 text-center sm:grid-cols-2 md:grid-cols-3 sm:gap-6">
|
<div className="grid w-full max-w-2xl grid-cols-1 gap-4 text-center sm:grid-cols-2 sm:gap-6">
|
||||||
<div className="group w-full rounded-3xl border border-white/10 bg-white/5 p-5 backdrop-blur-sm transition-all duration-300 hover:border-blue-500/30 sm:p-6">
|
<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="group w-full rounded-3xl border border-white/10 bg-white/5 p-5 backdrop-blur-sm transition-all duration-300 hover:border-purple-500/30 sm:p-6">
|
<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="group w-full rounded-3xl border border-white/10 bg-white/5 p-5 backdrop-blur-sm transition-all duration-300 hover:border-pink-500/30 sm:p-6">
|
|
||||||
<div className="text-3xl mb-4 group-hover:scale-110 transition-transform">💻</div>
|
|
||||||
<h3 className="font-bold mb-1">GitHub</h3>
|
|
||||||
<p className="text-sm text-gray-500">github.com/Space-Banane</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
|||||||
+19
-121
@@ -1,134 +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 { AgentSkills } from "../sections/AgentSkills";
|
|
||||||
import { ProjectCard } from "../components/ProjectCard";
|
|
||||||
import { MiniProjectCard } from "../components/MiniProjectCard";
|
|
||||||
import { ExperienceModal } from "../components/ExperienceModal";
|
|
||||||
import { MiniProjectModal } from "../components/MiniProjectModal";
|
|
||||||
import { Navbar } from "../components/Navbar";
|
import { Navbar } from "../components/Navbar";
|
||||||
|
import { Hero } from "../sections/Hero";
|
||||||
|
import { Stats } from "../sections/Stats";
|
||||||
|
import { WorkExperience } from "../sections/WorkExperience";
|
||||||
|
import { FeaturedProjects } from "../sections/FeaturedProjects";
|
||||||
|
import { Luna } from "../sections/Luna";
|
||||||
|
import { Affiliates } from "../sections/Affiliates";
|
||||||
|
import { Activity } from "../sections/Activity";
|
||||||
import { Footer } from "../sections/Footer";
|
import { Footer } from "../sections/Footer";
|
||||||
import { TechStack } from "../sections/TechStack";
|
|
||||||
import { TypingRoomIntro } from "../components/TypingRoomIntro";
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
import { SkillsExperience } from "@/sections/SkillsExperience";
|
|
||||||
|
|
||||||
const ENABLE_PAGE_ANIMATION = false;
|
|
||||||
|
|
||||||
export default function Home() {
|
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(ENABLE_PAGE_ANIMATION);
|
|
||||||
const oldUsernames = [
|
|
||||||
"getspaced (ingame)",
|
|
||||||
"Space (alternative)",
|
|
||||||
"Space-Banane (2022-2024)",
|
|
||||||
];
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// keep intro visible on every full page load; no-op here
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleIntroFinish = useCallback(() => {
|
|
||||||
setShowTypingIntro(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{ENABLE_PAGE_ANIMATION && (
|
<Navbar />
|
||||||
<TypingRoomIntro active={showTypingIntro} onFinish={handleIntroFinish} />
|
<div className="space-y-20 pb-16 pt-28 sm:space-y-24 sm:pb-20 sm:pt-32">
|
||||||
)}
|
<Hero />
|
||||||
{!showTypingIntro && (
|
<Stats />
|
||||||
<>
|
<WorkExperience experiences={experiences} />
|
||||||
<Navbar />
|
<FeaturedProjects />
|
||||||
<div className="space-y-20 pb-16 pt-16 sm:space-y-24 sm:pb-20 sm:pt-20">
|
<Luna />
|
||||||
<Hero
|
<Affiliates />
|
||||||
glowColor={glowColor}
|
<Activity />
|
||||||
borderStatus={borderStatus}
|
</div>
|
||||||
displayMessage={displayMessage}
|
<Footer />
|
||||||
rotatingMessages={rotatingMessages}
|
|
||||||
statusMessage={statusMessage}
|
|
||||||
oldUsernames={oldUsernames}
|
|
||||||
/>
|
|
||||||
|
|
||||||
|
|
||||||
<WorkExperience realWork={realWork} />
|
|
||||||
|
|
||||||
<Uptime />
|
|
||||||
|
|
||||||
<Activity />
|
|
||||||
|
|
||||||
<AgentSkills />
|
|
||||||
|
|
||||||
<section className="w-full max-w-6xl mx-auto space-y-8 px-4 sm:space-y-12">
|
|
||||||
<div className="text-center space-y-4">
|
|
||||||
<h2 className="text-3xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-blue-400 to-purple-500 sm:text-4xl">
|
|
||||||
Featured Projects
|
|
||||||
</h2>
|
|
||||||
<p className="mx-auto max-w-2xl text-sm text-gray-400 sm:text-base">
|
|
||||||
A selection of my personal favorites. Many more on my GitHub.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3 sm:gap-8">
|
|
||||||
{projects.map((project, index) => (
|
|
||||||
<ProjectCard key={index} project={project} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="w-full max-w-6xl mx-auto space-y-8 px-4 sm:space-y-12">
|
|
||||||
<div className="text-center space-y-4">
|
|
||||||
<h2 className="text-2xl font-bold sm:text-3xl">More Projects</h2>
|
|
||||||
<p className="text-sm text-gray-400 sm:text-base">Smaller projects or tools I've built.</p>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3 sm:gap-6">
|
|
||||||
{miniProjects.map((project, index) => (
|
|
||||||
<MiniProjectCard
|
|
||||||
key={index}
|
|
||||||
project={project}
|
|
||||||
onClick={() => setSelectedMiniProject(project)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<Affiliates />
|
|
||||||
|
|
||||||
<TechStack />
|
|
||||||
|
|
||||||
<SkillsExperience
|
|
||||||
experiences={experiences}
|
|
||||||
onSelectExperience={setSelectedExperience}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Footer />
|
|
||||||
|
|
||||||
{selectedMiniProject && (
|
|
||||||
<MiniProjectModal
|
|
||||||
project={selectedMiniProject}
|
|
||||||
onClose={() => setSelectedMiniProject(null)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{selectedExperience && (
|
|
||||||
<ExperienceModal
|
|
||||||
experience={selectedExperience}
|
|
||||||
onClose={() => setSelectedExperience(null)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useProfile } from "../../context/ProfileContext";
|
||||||
|
import { Navbar } from "../../components/Navbar";
|
||||||
|
import { Footer } from "../../sections/Footer";
|
||||||
|
import { ProjectCard } from "../../components/ProjectCard";
|
||||||
|
|
||||||
|
export default function ProjectsPage() {
|
||||||
|
const { projects, loading } = useProfile();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Navbar />
|
||||||
|
<div className="mx-auto w-full max-w-6xl px-4 pb-16 pt-28 sm:pb-20 sm:pt-32">
|
||||||
|
<div className="mb-10 space-y-4 text-center sm:mb-14">
|
||||||
|
<h1 className="text-4xl font-semibold text-white sm:text-5xl">Projects</h1>
|
||||||
|
<p className="mx-auto max-w-2xl text-base text-gray-500 sm:text-lg">
|
||||||
|
Everything I've built and shipped — big and small.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-center text-gray-500">Loading projects…</p>
|
||||||
|
) : projects.length === 0 ? (
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-white/5 px-5 py-12 text-center text-gray-400">
|
||||||
|
No projects published yet.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3 sm:gap-8">
|
||||||
|
{projects.map((project) => (
|
||||||
|
<ProjectCard key={project.id} project={project} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Footer />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { Experience } from "../types";
|
|
||||||
import { getTypeColor, getTypeIcon } from "./utils";
|
|
||||||
|
|
||||||
export function ExperienceCard({
|
|
||||||
experience,
|
|
||||||
onClick,
|
|
||||||
}: {
|
|
||||||
experience: Experience;
|
|
||||||
onClick?: () => void;
|
|
||||||
}) {
|
|
||||||
const isClickable = !!(
|
|
||||||
experience.learned_at ||
|
|
||||||
experience.learned_from ||
|
|
||||||
experience.learned_because
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
onClick={isClickable ? onClick : undefined}
|
|
||||||
className={`group flex w-full items-center gap-3 rounded-lg border border-white/10 bg-gradient-to-br from-white/5 to-white/2 p-3 backdrop-blur-sm transition-all duration-200 ${
|
|
||||||
isClickable
|
|
||||||
? "cursor-pointer hover:border-purple-500/50 hover:bg-white/10 hover:-translate-y-0.5"
|
|
||||||
: ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`text-2xl p-2 rounded-lg ${getTypeColor(experience.type)} flex items-center justify-center shrink-0 relative ${
|
|
||||||
experience.image ? "w-10 h-10" : ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{experience.image ? (
|
|
||||||
<img
|
|
||||||
src={experience.image}
|
|
||||||
alt={experience.name}
|
|
||||||
className="w-full h-full object-contain"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
getTypeIcon(experience.type)
|
|
||||||
)}
|
|
||||||
{isClickable && (
|
|
||||||
<div className="absolute -top-1 -right-1 w-3 h-3 bg-purple-500 rounded-full border-2 border-black animate-pulse" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<h4
|
|
||||||
className={`text-base font-semibold text-white transition-colors ${
|
|
||||||
isClickable ? "group-hover:text-purple-400" : ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{experience.name}
|
|
||||||
</h4>
|
|
||||||
{experience.description && (
|
|
||||||
<p className="text-xs text-gray-400 mt-0.5 line-clamp-1">
|
|
||||||
{experience.description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{isClickable && (
|
|
||||||
<div className="text-purple-400 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
|
|
||||||
<svg
|
|
||||||
className="w-4 h-4"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth="2"
|
|
||||||
d="M9 5l7 7-7 7"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { Experience } from "../types";
|
|
||||||
import { getTypeColor, getTypeIcon } from "./utils";
|
|
||||||
|
|
||||||
export function ExperienceModal({
|
|
||||||
experience,
|
|
||||||
onClose,
|
|
||||||
}: {
|
|
||||||
experience: Experience;
|
|
||||||
onClose: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center p-3 bg-black/80 backdrop-blur-sm animate-fade-in sm:p-4"
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="relative max-h-[92vh] w-full max-w-[20rem] overflow-y-auto rounded-2xl border border-purple-500/30 bg-gradient-to-br from-gray-900 to-black shadow-2xl animate-scale-in sm:max-w-2xl"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{/* Close Button */}
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="absolute right-3 top-3 z-10 rounded-full bg-white/10 p-2 text-white transition-colors hover:bg-white/20 sm:right-4 sm:top-4"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className="w-6 h-6"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth="2"
|
|
||||||
d="M6 18L18 6M6 6l12 12"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="space-y-4 p-4 sm:space-y-6 sm:p-8">
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:gap-4">
|
|
||||||
<div
|
|
||||||
className={`flex shrink-0 items-center justify-center rounded-lg p-3 text-2xl ${getTypeColor(experience.type)} ${experience.image ? "h-12 w-12 sm:h-16 sm:w-16" : ""}`}
|
|
||||||
>
|
|
||||||
{experience.image ? (
|
|
||||||
<img
|
|
||||||
src={experience.image}
|
|
||||||
alt={experience.name}
|
|
||||||
className="w-full h-full object-contain"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
getTypeIcon(experience.type)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-purple-400 to-pink-500 sm:text-3xl">
|
|
||||||
{experience.name}
|
|
||||||
</h2>
|
|
||||||
<p className="text-sm text-gray-400 mt-1">
|
|
||||||
{getTypeIcon(experience.type)}{" "}
|
|
||||||
{experience.type.charAt(0).toUpperCase() +
|
|
||||||
experience.type.slice(1)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
{experience.description && (
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-semibold text-purple-400 mb-2">
|
|
||||||
Description
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-300 leading-relaxed">
|
|
||||||
{experience.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{experience.learned_at && (
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-semibold text-purple-400 mb-2">
|
|
||||||
📅 When I Learned It
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-300 leading-relaxed">
|
|
||||||
{experience.learned_at}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{experience.learned_from && (
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-semibold text-purple-400 mb-2">
|
|
||||||
👨🏫 How I Learned It
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-300 leading-relaxed">
|
|
||||||
{experience.learned_from}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{experience.learned_because && (
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-semibold text-purple-400 mb-2">
|
|
||||||
💡 Why I Learned It
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-300 leading-relaxed">
|
|
||||||
{experience.learned_because}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { MiniProject } from "../types";
|
|
||||||
|
|
||||||
export function MiniProjectCard({
|
|
||||||
project,
|
|
||||||
onClick,
|
|
||||||
}: {
|
|
||||||
project: MiniProject;
|
|
||||||
onClick: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
onClick={onClick}
|
|
||||||
className="group relative flex w-full cursor-pointer flex-col rounded-2xl border border-white/10 bg-gradient-to-br from-white/5 to-white/2 p-4 backdrop-blur-sm transition-all duration-300 hover:-translate-y-2 hover:border-purple-500/50 hover:shadow-2xl hover:shadow-purple-500/20 sm:p-6"
|
|
||||||
>
|
|
||||||
{project.image && (
|
|
||||||
<div className="mb-4 flex aspect-video items-center justify-center overflow-hidden rounded-xl bg-black/20">
|
|
||||||
<img
|
|
||||||
src={project.image}
|
|
||||||
alt={project.title}
|
|
||||||
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-110"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<h3 className="mb-2 text-lg font-bold text-white transition-colors group-hover:text-purple-400 sm:text-xl">
|
|
||||||
{project.title}
|
|
||||||
</h3>
|
|
||||||
<p className="mb-4 line-clamp-2 text-sm text-gray-400">
|
|
||||||
{project.description}
|
|
||||||
</p>
|
|
||||||
{project.last_commit && (
|
|
||||||
<p className="mb-2 text-xs text-gray-400">
|
|
||||||
Last commit: {new Date(project.last_commit).toLocaleDateString()}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-auto flex items-center text-sm font-medium text-purple-400">
|
|
||||||
Click to learn more
|
|
||||||
<svg
|
|
||||||
className="w-4 h-4 ml-1 group-hover:translate-x-1 transition-transform"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth="2"
|
|
||||||
d="M9 5l7 7-7 7"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { MiniProject } from "../types";
|
|
||||||
|
|
||||||
export function MiniProjectModal({
|
|
||||||
project,
|
|
||||||
onClose,
|
|
||||||
}: {
|
|
||||||
project: MiniProject;
|
|
||||||
onClose: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center p-3 bg-black/80 backdrop-blur-sm animate-fade-in sm:p-4"
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="relative max-h-[92vh] w-full max-w-[20rem] overflow-y-auto rounded-2xl border border-purple-500/30 bg-gradient-to-br from-gray-900 to-black shadow-2xl animate-scale-in sm:max-w-3xl"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{/* Close Button */}
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="absolute right-3 top-3 z-10 rounded-full bg-white/10 p-2 text-white transition-colors hover:bg-white/20 sm:right-4 sm:top-4"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className="w-6 h-6"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth="2"
|
|
||||||
d="M6 18L18 6M6 6l12 12"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Image */}
|
|
||||||
{project.image && (
|
|
||||||
<div className="h-36 w-full overflow-hidden rounded-t-2xl bg-black/40 sm:h-64">
|
|
||||||
<img
|
|
||||||
src={project.image}
|
|
||||||
alt={project.title}
|
|
||||||
className="w-full h-full object-cover"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="space-y-4 p-4 sm:space-y-6 sm:p-8">
|
|
||||||
<h2 className="text-xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-purple-400 to-pink-500 sm:text-3xl">
|
|
||||||
{project.title}
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
{project.note && (
|
|
||||||
<div
|
|
||||||
className="p-4 rounded-lg border-l-4 bg-black/20 backdrop-blur-sm"
|
|
||||||
style={{
|
|
||||||
borderColor: project.note.color,
|
|
||||||
backgroundColor: `${project.note.color}15`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<p
|
|
||||||
className="text-sm font-medium"
|
|
||||||
style={{ color: project.note.color }}
|
|
||||||
>
|
|
||||||
{project.note.content}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-semibold text-purple-400 mb-2">
|
|
||||||
Description
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-300 leading-relaxed">
|
|
||||||
{project.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-semibold text-purple-400 mb-2">
|
|
||||||
Why I Made This
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-300 leading-relaxed">{project.why}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{project.reproduction && (
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-semibold text-purple-400 mb-2">
|
|
||||||
How to Reproduce
|
|
||||||
</h3>
|
|
||||||
<pre className="text-sm text-gray-300 bg-black/40 p-4 rounded-lg overflow-x-auto border border-white/10">
|
|
||||||
<code>{project.reproduction}</code>
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{project.github && (
|
|
||||||
<a
|
|
||||||
href={project.github}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="inline-flex items-center gap-2 px-6 py-3 rounded-lg bg-gradient-to-r from-purple-500 to-pink-500 text-white font-semibold hover:from-purple-600 hover:to-pink-600 transition-all shadow-lg shadow-purple-500/30 hover:shadow-purple-500/50"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className="w-5 h-5"
|
|
||||||
fill="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
|
||||||
</svg>
|
|
||||||
View on GitHub
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -10,6 +10,7 @@ export function Navbar() {
|
|||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ label: "Home", path: "/" },
|
{ label: "Home", path: "/" },
|
||||||
|
{ label: "Projects", path: "/projects" },
|
||||||
{ label: "Connect", path: "/contact" },
|
{ label: "Connect", path: "/contact" },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -18,7 +19,7 @@ export function Navbar() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="fixed top-4 left-1/2 z-[100] w-[min(100vw-1rem,400px)] -translate-x-1/2 sm:top-8 sm:w-[min(90%,400px)]">
|
<nav className="fixed top-4 left-1/2 z-[100] w-[min(100vw-1rem,460px)] -translate-x-1/2 sm:top-8 sm:w-[min(90%,460px)]">
|
||||||
<div className="bg-black/20 backdrop-blur-xl border border-white/10 rounded-full px-3 py-2 sm:px-6 sm:py-3 flex items-center justify-between gap-2 sm:gap-4 shadow-2xl">
|
<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
|
||||||
|
|||||||
@@ -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 w-full flex-col rounded-2xl border border-white/10 bg-gradient-to-br from-white/10 to-white/5 p-4 backdrop-blur-sm transition-all duration-300 hover:-translate-y-1 hover:border-purple-500/30 hover:shadow-2xl hover:shadow-purple-500/10 sm:p-6">
|
<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="relative mb-4 flex aspect-video items-center justify-center overflow-hidden rounded-xl bg-black/20 sm:mb-6">
|
// 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 shadow-lg transition-transform duration-500 group-hover:scale-110 ${project.rounded === false ? "h-auto w-full max-h-20 rounded-lg sm:max-h-24" : "h-20 w-20 rounded-full sm:h-24 sm:w-24"}`}
|
className="max-h-40 w-auto max-w-full object-contain"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
|
|
||||||
<h3 className="mb-2 text-lg font-bold text-white transition-colors group-hover:text-purple-400 sm:text-xl">
|
<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="mb-5 flex-grow text-sm leading-relaxed text-gray-400 sm:mb-6">
|
{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="mb-2 text-xs text-gray-400">
|
{project.label}
|
||||||
Last commit: {new Date(project.last_commit).toLocaleDateString()}
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mb-3 text-sm leading-relaxed text-gray-400">{project.description}</p>
|
||||||
|
|
||||||
|
{project.why ? (
|
||||||
|
<p className="mb-3 text-sm leading-relaxed text-gray-500">
|
||||||
|
<span className="font-semibold text-gray-400">Why:</span> {project.why}
|
||||||
</p>
|
</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">
|
<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 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"
|
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="self-center rounded-lg border border-white/5 bg-white/5 p-2 text-gray-400 transition-colors hover:bg-white/10 hover:text-white sm:self-auto"
|
className="flex items-center justify-center rounded-lg border border-white/5 bg-white/5 p-2 text-gray-400 transition-colors hover:bg-white/10 hover:text-white"
|
||||||
title="View Source"
|
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,127 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
|
|
||||||
const BERLIN_TIME_ZONE = "Europe/Berlin";
|
|
||||||
const MATCH_COPY = "Your local time matches mine! No issues there.";
|
|
||||||
|
|
||||||
function getTimeZoneOffsetMinutes(date: Date, timeZone: string) {
|
|
||||||
const formatter = new Intl.DateTimeFormat("en-US", {
|
|
||||||
timeZone,
|
|
||||||
hour12: false,
|
|
||||||
hourCycle: "h23",
|
|
||||||
year: "numeric",
|
|
||||||
month: "2-digit",
|
|
||||||
day: "2-digit",
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
second: "2-digit",
|
|
||||||
});
|
|
||||||
|
|
||||||
const parts = formatter.formatToParts(date);
|
|
||||||
const values = parts.reduce<Record<string, string>>((acc, part) => {
|
|
||||||
if (part.type !== "literal") acc[part.type] = part.value;
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
const asUTC = Date.UTC(
|
|
||||||
Number(values.year),
|
|
||||||
Number(values.month) - 1,
|
|
||||||
Number(values.day),
|
|
||||||
Number(values.hour),
|
|
||||||
Number(values.minute),
|
|
||||||
Number(values.second),
|
|
||||||
);
|
|
||||||
|
|
||||||
return (asUTC - date.getTime()) / 60000;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TimezoneClockBlock({ warningThresholdHours = 5 }: { warningThresholdHours?: number }) {
|
|
||||||
const [now, setNow] = useState<Date | null>(null);
|
|
||||||
|
|
||||||
const localFormatter = useMemo(
|
|
||||||
() =>
|
|
||||||
new Intl.DateTimeFormat("en-GB", {
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
second: "2-digit",
|
|
||||||
hour12: false,
|
|
||||||
}),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const berlinFormatter = useMemo(
|
|
||||||
() =>
|
|
||||||
new Intl.DateTimeFormat("en-GB", {
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
second: "2-digit",
|
|
||||||
hour12: false,
|
|
||||||
timeZone: BERLIN_TIME_ZONE,
|
|
||||||
}),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const updateNow = () => setNow(new Date());
|
|
||||||
|
|
||||||
updateNow();
|
|
||||||
const interval = setInterval(updateNow, 1000);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const localTime = now ? localFormatter.format(now) : "";
|
|
||||||
const berlinTime = now ? berlinFormatter.format(now) : "";
|
|
||||||
const clocksMatch = localTime !== "" && localTime === berlinTime;
|
|
||||||
const timeGapHours =
|
|
||||||
now === null
|
|
||||||
? 0
|
|
||||||
: Math.abs(
|
|
||||||
getTimeZoneOffsetMinutes(now, BERLIN_TIME_ZONE) + now.getTimezoneOffset(),
|
|
||||||
) / 60;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="pt-3 flex flex-col items-center gap-3 px-4">
|
|
||||||
<p className="text-[11px] uppercase tracking-[0.3em] text-gray-500">Time</p>
|
|
||||||
|
|
||||||
{clocksMatch && (
|
|
||||||
<div className="rounded-2xl border border-emerald-400/30 bg-emerald-500/10 px-4 py-3 backdrop-blur-sm shadow-lg text-center">
|
|
||||||
<p className="text-sm font-semibold text-emerald-100">{MATCH_COPY}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!clocksMatch && timeGapHours >= warningThresholdHours && (
|
|
||||||
<div className="flex flex-col items-center gap-1.5 rounded-2xl border border-amber-500/30 bg-amber-500/10 px-5 py-4 backdrop-blur-sm shadow-lg max-w-sm text-center">
|
|
||||||
<p className="text-sm font-bold text-amber-400">
|
|
||||||
Large Time Gap ({Math.round(timeGapHours)}h)
|
|
||||||
</p>
|
|
||||||
<p className="text-xs font-medium text-amber-200/80 leading-relaxed">
|
|
||||||
Because we have a {Math.round(timeGapHours)}-hour time difference, our waking hours might barely overlap. Please expect delayed responses!
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex w-full items-stretch justify-center gap-3">
|
|
||||||
<div className="relative flex-1 max-w-[11rem] min-w-0 rounded-2xl border border-white/15 bg-white/5 px-4 py-3 shadow-lg backdrop-blur-sm">
|
|
||||||
<div className="absolute right-0 top-1/2 h-0 w-0 -translate-y-1/2 translate-x-2 border-b-8 border-b-transparent border-l-8 border-l-white/15 border-t-8 border-t-transparent" />
|
|
||||||
<p className="text-[11px] uppercase tracking-[0.24em] text-gray-400 whitespace-nowrap">
|
|
||||||
Local time
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 font-mono text-sm font-semibold text-gray-100">
|
|
||||||
{localTime}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative flex-1 max-w-[11rem] min-w-0 rounded-2xl border border-white/15 bg-white/5 px-4 py-3 shadow-lg backdrop-blur-sm">
|
|
||||||
<div className="absolute left-0 top-1/2 h-0 w-0 -translate-x-2 -translate-y-1/2 border-b-8 border-b-transparent border-r-8 border-r-white/15 border-t-8 border-t-transparent" />
|
|
||||||
<p className="text-[11px] uppercase tracking-[0.24em] text-gray-400">
|
|
||||||
My time
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 font-mono text-sm font-semibold text-gray-100">
|
|
||||||
{berlinTime}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,220 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
|
|
||||||
interface TypingRoomIntroProps {
|
|
||||||
active: boolean;
|
|
||||||
onFinish: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TypingRoomIntro({ active, onFinish }: TypingRoomIntroProps) {
|
|
||||||
const [step, setStep] = useState(0);
|
|
||||||
const [garble, setGarble] = useState("");
|
|
||||||
const garbleRef = useRef<number | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!active) {
|
|
||||||
setStep(0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const timers = [
|
|
||||||
window.setTimeout(() => setStep(1), 280),
|
|
||||||
window.setTimeout(() => setStep(2), 900),
|
|
||||||
window.setTimeout(() => setStep(3), 1700),
|
|
||||||
window.setTimeout(() => setStep(4), 2800),
|
|
||||||
window.setTimeout(onFinish, 3600),
|
|
||||||
];
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
timers.forEach((timer) => window.clearTimeout(timer));
|
|
||||||
};
|
|
||||||
}, [active, onFinish]);
|
|
||||||
|
|
||||||
// Garbled typing generator while Space is "typing"
|
|
||||||
useEffect(() => {
|
|
||||||
if (step >= 3 && step < 4) {
|
|
||||||
setGarble("");
|
|
||||||
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()[]{}<>~-=_+";
|
|
||||||
garbleRef.current = window.setInterval(() => {
|
|
||||||
// create a short random string to simulate garble
|
|
||||||
const len = 20;
|
|
||||||
let s = "";
|
|
||||||
for (let i = 0; i < len; i++) {
|
|
||||||
s += charset[Math.floor(Math.random() * charset.length)];
|
|
||||||
}
|
|
||||||
setGarble(s);
|
|
||||||
}, 120);
|
|
||||||
} else {
|
|
||||||
if (garbleRef.current) {
|
|
||||||
window.clearInterval(garbleRef.current);
|
|
||||||
garbleRef.current = null;
|
|
||||||
}
|
|
||||||
setGarble("");
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (garbleRef.current) {
|
|
||||||
window.clearInterval(garbleRef.current);
|
|
||||||
garbleRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [step]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!active) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const originalOverflow = document.body.style.overflow;
|
|
||||||
document.body.style.overflow = "hidden";
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.body.style.overflow = originalOverflow;
|
|
||||||
};
|
|
||||||
}, [active]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!active) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleEscape = (event: KeyboardEvent) => {
|
|
||||||
if (event.key === "Escape") {
|
|
||||||
onFinish();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("keydown", handleEscape);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("keydown", handleEscape);
|
|
||||||
};
|
|
||||||
}, [active, onFinish]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AnimatePresence>
|
|
||||||
{active && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
exit={{ opacity: 0, transition: { duration: 0.45, ease: "easeInOut" } }}
|
|
||||||
className="fixed inset-0 z-[220] flex items-center justify-center bg-[#020205] px-4"
|
|
||||||
aria-live="polite"
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
>
|
|
||||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_20%_20%,rgba(56,189,248,0.25),transparent_45%),radial-gradient(circle_at_80%_70%,rgba(59,130,246,0.16),transparent_45%),#020205]" />
|
|
||||||
<div className="absolute inset-0 bg-[linear-gradient(transparent_95%,rgba(255,255,255,0.03)_100%)] bg-[length:100%_6px] opacity-60" />
|
|
||||||
|
|
||||||
<motion.div
|
|
||||||
initial={{ y: 32, scale: 0.96, opacity: 0 }}
|
|
||||||
animate={{ y: 0, scale: 1, opacity: 1 }}
|
|
||||||
exit={{ y: -24, scale: 0.98, opacity: 0 }}
|
|
||||||
transition={{ duration: 0.55, ease: [0.22, 1, 0.36, 1] }}
|
|
||||||
className="relative w-full max-w-[20rem] overflow-hidden rounded-3xl border border-cyan-300/20 bg-black/55 backdrop-blur-xl shadow-[0_32px_90px_rgba(14,165,233,0.25)] sm:max-w-2xl"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between border-b border-white/10 bg-white/5 px-4 py-3 sm:px-5">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="h-2.5 w-2.5 rounded-full bg-red-400/80" />
|
|
||||||
<span className="h-2.5 w-2.5 rounded-full bg-amber-300/80" />
|
|
||||||
<span className="h-2.5 w-2.5 rounded-full bg-emerald-400/80" />
|
|
||||||
</div>
|
|
||||||
<p className="text-xs uppercase tracking-[0.3em] text-cyan-100/70">
|
|
||||||
Orbital Chat
|
|
||||||
</p>
|
|
||||||
<span className="rounded-full border border-cyan-300/30 px-2 py-0.5 text-[10px] font-medium text-cyan-200/80">
|
|
||||||
live
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4 p-4 sm:p-6 md:p-8">
|
|
||||||
<motion.p
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
className="text-xs uppercase tracking-[0.25em] text-cyan-100/50"
|
|
||||||
>
|
|
||||||
Breaking Websockets...
|
|
||||||
</motion.p>
|
|
||||||
|
|
||||||
{step >= 1 && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, x: -16 }}
|
|
||||||
animate={{ opacity: 1, x: 0 }}
|
|
||||||
className="max-w-[86%] rounded-2xl rounded-bl-md border border-white/10 bg-white/10 px-3 py-2.5 text-xs text-gray-100 sm:px-4 sm:py-3 sm:text-sm"
|
|
||||||
>
|
|
||||||
Why is this page taking so long to load??
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step >= 2 && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, x: 16 }}
|
|
||||||
animate={{ opacity: 1, x: 0 }}
|
|
||||||
className="ml-auto max-w-[88%] rounded-2xl rounded-br-md border border-cyan-300/30 bg-cyan-400/15 px-3 py-2.5 text-xs text-cyan-50 sm:px-4 sm:py-3 sm:text-sm"
|
|
||||||
>
|
|
||||||
I have to somehow hide the fact that my discord status is taking a while to load... :D
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step >= 4 && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, x: 12 }}
|
|
||||||
animate={{ opacity: 1, x: 0 }}
|
|
||||||
className="ml-auto max-w-[88%] rounded-2xl rounded-br-md border border-cyan-300/35 bg-cyan-400/20 px-3 py-2.5 text-xs text-cyan-50 sm:px-4 sm:py-3 sm:text-sm"
|
|
||||||
>
|
|
||||||
Ok done, just one more animation
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative border-t border-white/10 bg-black/35 px-4 pb-4 pt-4 sm:px-6 sm:pb-5">
|
|
||||||
{/* Small badge left-above the input showing typing status (hidden once final message shows) */}
|
|
||||||
{step < 4 && (
|
|
||||||
<div className="absolute left-4 -top-6 flex items-center gap-2 sm:left-6">
|
|
||||||
<span className="text-xs font-medium text-cyan-100 sm:text-sm">Space is typing...</span>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
{[0, 1, 2].map((dot) => (
|
|
||||||
<motion.span
|
|
||||||
key={dot}
|
|
||||||
animate={{ y: [0, -3, 0], opacity: [0.3, 1, 0.3] }}
|
|
||||||
transition={{ duration: 0.95, repeat: Infinity, delay: dot * 0.14, ease: "easeInOut" }}
|
|
||||||
className="h-1.5 w-1.5 rounded-full bg-cyan-200"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-3 text-xs text-cyan-100/70">
|
|
||||||
<span className="inline-flex items-center gap-2">
|
|
||||||
<span className="h-2 w-2 rounded-full bg-emerald-300 shadow-[0_0_10px_rgba(110,231,183,0.75)]" />
|
|
||||||
You & Space
|
|
||||||
</span>
|
|
||||||
<span className="text-[11px] text-gray-400">Esc to skip</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-3 flex min-h-[44px] items-center rounded-xl border border-white/10 bg-white/5 px-3 py-2.5 sm:px-4">
|
|
||||||
{step >= 3 && step < 4 ? (
|
|
||||||
<motion.div initial={{ opacity: 0, y: 6 }} animate={{ opacity: 1, y: 0 }} className="flex items-center gap-2.5 font-mono text-xs text-cyan-100 sm:text-sm">
|
|
||||||
<span className="select-all">{garble || "…"}</span>
|
|
||||||
</motion.div>
|
|
||||||
) : (
|
|
||||||
<p className="text-xs text-gray-500 sm:text-sm">Message Space</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<motion.div
|
|
||||||
initial={{ scaleX: 0 }}
|
|
||||||
animate={{ scaleX: step >= 4 ? 1 : step / 4 }}
|
|
||||||
transition={{ duration: 0.45, ease: "easeOut" }}
|
|
||||||
className="h-1 origin-left bg-gradient-to-r from-cyan-300 via-blue-400 to-cyan-300"
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import type { Experience } from "../types";
|
|
||||||
|
|
||||||
export function getTypeIcon(type: Experience["type"]) {
|
|
||||||
switch (type) {
|
|
||||||
case "languages":
|
|
||||||
return "💻";
|
|
||||||
case "software":
|
|
||||||
return "☁️";
|
|
||||||
case "plattforms":
|
|
||||||
return "🚀";
|
|
||||||
case "experience":
|
|
||||||
return "🌍";
|
|
||||||
case "other":
|
|
||||||
return "📦";
|
|
||||||
default:
|
|
||||||
return "📦";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getTypeColor(type: Experience["type"]) {
|
|
||||||
switch (type) {
|
|
||||||
case "languages":
|
|
||||||
return "bg-blue-500/20";
|
|
||||||
case "software":
|
|
||||||
return "bg-purple-500/20";
|
|
||||||
case "plattforms":
|
|
||||||
return "bg-green-500/20";
|
|
||||||
case "experience":
|
|
||||||
return "bg-orange-500/20";
|
|
||||||
case "other":
|
|
||||||
return "bg-gray-500/20";
|
|
||||||
default:
|
|
||||||
return "bg-gray-500/20";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+34
-135
@@ -1,156 +1,55 @@
|
|||||||
"use client";
|
"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, Skill } 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;
|
||||||
skills: Skill[];
|
|
||||||
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 [skills, setSkills] = useState<Skill[]>([]);
|
|
||||||
|
|
||||||
const rotatingMessages = useMemo(
|
|
||||||
() => [
|
|
||||||
"Yelling at Luna",
|
|
||||||
"Assigning more Issues to myself and letting Luna do them",
|
|
||||||
"NOT coding in Rust 😂✌️",
|
|
||||||
"Rewriting the same helper 3 times",
|
|
||||||
"Micro-service-maxxing",
|
|
||||||
"Blaming SHSF for my bad code",
|
|
||||||
"Shipping fast SHSF code",
|
|
||||||
"Writing code with 1.4k+ Lines",
|
|
||||||
"Love-Hate relationship with TypeScript",
|
|
||||||
"Blaming Openai",
|
|
||||||
"Undescribable Music Taste"
|
|
||||||
],
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const characters =
|
|
||||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()-_=+[]{}|;:',.<>?/`~" +
|
|
||||||
"?????????????????????????";
|
|
||||||
|
|
||||||
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 {
|
||||||
fetch("https://shsf-api.reversed.dev/api/exec/4/e942538c-caa1-49d1-8953-dfab1e62f8cb/projects")
|
/* leave empty on failure */
|
||||||
.then(res => res.json()).then(setProjects).catch(() => setProjects([]));
|
}
|
||||||
fetch("https://shsf-api.reversed.dev/api/exec/4/e942538c-caa1-49d1-8953-dfab1e62f8cb/mini_projects")
|
}
|
||||||
.then(res => res.json()).then(setMiniProjects).catch(() => setMiniProjects([]));
|
|
||||||
fetch("https://shsf-api.reversed.dev/api/exec/4/e942538c-caa1-49d1-8953-dfab1e62f8cb/experience")
|
|
||||||
.then(res => res.json()).then(setExperiences).catch(() => setExperiences([]));
|
|
||||||
fetch("https://shsf-api.reversed.dev/api/exec/4/e942538c-caa1-49d1-8953-dfab1e62f8cb/skills")
|
|
||||||
.then(res => res.json()).then(setSkills).catch(() => setSkills([]));
|
|
||||||
|
|
||||||
fetchRealWork();
|
Promise.all([
|
||||||
|
load<Project>("/api/projects", setProjects),
|
||||||
|
load<WorkExperience>("/api/experience", setExperiences),
|
||||||
|
load<Affiliate>("/api/affiliates", setAffiliates),
|
||||||
|
]).finally(() => {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
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, skills,
|
|
||||||
selectedMiniProject, setSelectedMiniProject,
|
|
||||||
selectedExperience, setSelectedExperience
|
|
||||||
};
|
|
||||||
|
|
||||||
return <ProfileContext.Provider value={value}>{children}</ProfileContext.Provider>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useProfile() {
|
export function useProfile() {
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { isAdmin } from "@/lib/auth";
|
||||||
|
import { ValidationError } from "@/lib/dto";
|
||||||
|
|
||||||
|
/** Returns a 401 response when the caller isn't an authenticated admin, else null. */
|
||||||
|
export async function guard(): Promise<NextResponse | null> {
|
||||||
|
if (!(await isAdmin())) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs a handler, mapping ValidationError -> 400 and other errors -> 500. */
|
||||||
|
export async function run(handler: () => Promise<NextResponse>): Promise<NextResponse> {
|
||||||
|
try {
|
||||||
|
return await handler();
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ValidationError) {
|
||||||
|
return NextResponse.json({ error: err.message }, { status: 400 });
|
||||||
|
}
|
||||||
|
console.error(err);
|
||||||
|
return NextResponse.json({ error: "Internal error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readBody(req: Request): Promise<Record<string, unknown>> {
|
||||||
|
try {
|
||||||
|
const body = await req.json();
|
||||||
|
return body && typeof body === "object" ? (body as Record<string, unknown>) : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
|
||||||
|
export const SESSION_COOKIE = "admin_session";
|
||||||
|
const SESSION_TTL_MS = 1000 * 60 * 60 * 24 * 7; // 7 days
|
||||||
|
|
||||||
|
function getSecret() {
|
||||||
|
return process.env.SESSION_SECRET || "insecure-dev-secret-change-me";
|
||||||
|
}
|
||||||
|
|
||||||
|
function sign(value: string) {
|
||||||
|
return crypto.createHmac("sha256", getSecret()).update(value).digest("base64url");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a signed `<expiry>.<signature>` session token. */
|
||||||
|
export function createSessionToken(): string {
|
||||||
|
const expiry = String(Date.now() + SESSION_TTL_MS);
|
||||||
|
return `${expiry}.${sign(expiry)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verify a token's signature (constant-time) and expiry. */
|
||||||
|
export function verifySessionToken(token: string | undefined | null): boolean {
|
||||||
|
if (!token) return false;
|
||||||
|
const [expiry, signature] = token.split(".");
|
||||||
|
if (!expiry || !signature) return false;
|
||||||
|
|
||||||
|
const expected = sign(expiry);
|
||||||
|
const a = Buffer.from(signature);
|
||||||
|
const b = Buffer.from(expected);
|
||||||
|
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return false;
|
||||||
|
|
||||||
|
const expiryMs = Number(expiry);
|
||||||
|
return Number.isFinite(expiryMs) && expiryMs > Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Constant-time password comparison against ADMIN_PASSWORD. */
|
||||||
|
export function verifyPassword(candidate: string): boolean {
|
||||||
|
const expected = process.env.ADMIN_PASSWORD;
|
||||||
|
if (!expected) return false;
|
||||||
|
const a = Buffer.from(candidate);
|
||||||
|
const b = Buffer.from(expected);
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
return crypto.timingSafeEqual(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the incoming request carries a valid admin session cookie. */
|
||||||
|
export async function isAdmin(): Promise<boolean> {
|
||||||
|
const store = await cookies();
|
||||||
|
return verifySessionToken(store.get(SESSION_COOKIE)?.value);
|
||||||
|
}
|
||||||
+113
@@ -0,0 +1,113 @@
|
|||||||
|
// Input parsing/validation for admin write endpoints. Each parser returns the
|
||||||
|
// Prisma-ready data object or throws a ValidationError (mapped to HTTP 400).
|
||||||
|
|
||||||
|
import { ProjectSize } from "@/generated/prisma/client";
|
||||||
|
|
||||||
|
export class ValidationError extends Error {}
|
||||||
|
|
||||||
|
function str(value: unknown, field: string): string {
|
||||||
|
if (typeof value !== "string" || value.trim() === "") {
|
||||||
|
throw new ValidationError(`"${field}" is required`);
|
||||||
|
}
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function optStr(value: unknown): string | null {
|
||||||
|
if (value === undefined || value === null || value === "") return null;
|
||||||
|
if (typeof value !== "string") throw new ValidationError("Expected a string");
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed === "" ? null : trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bool(value: unknown): boolean {
|
||||||
|
return value === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function int(value: unknown): number {
|
||||||
|
const n = typeof value === "number" ? value : Number.parseInt(String(value ?? 0), 10);
|
||||||
|
return Number.isFinite(n) ? Math.trunc(n) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function optInt(value: unknown): number | null {
|
||||||
|
if (value === undefined || value === null || value === "") return null;
|
||||||
|
const n = typeof value === "number" ? value : Number.parseInt(String(value), 10);
|
||||||
|
return Number.isFinite(n) ? Math.trunc(n) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tags(value: unknown, max: number): string[] {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
const cleaned = value
|
||||||
|
.map((t) => (typeof t === "string" ? t.trim() : ""))
|
||||||
|
.filter(Boolean);
|
||||||
|
if (cleaned.length > max) {
|
||||||
|
throw new ValidationError(`Too many tags (max ${max})`);
|
||||||
|
}
|
||||||
|
return cleaned;
|
||||||
|
}
|
||||||
|
|
||||||
|
function strList(value: unknown): string[] {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return value.map((t) => (typeof t === "string" ? t.trim() : "")).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function date(value: unknown, field: string): Date {
|
||||||
|
const d = new Date(String(value));
|
||||||
|
if (Number.isNaN(d.getTime())) throw new ValidationError(`"${field}" is not a valid date`);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function optDate(value: unknown): Date | null {
|
||||||
|
if (value === undefined || value === null || value === "") return null;
|
||||||
|
const d = new Date(String(value));
|
||||||
|
return Number.isNaN(d.getTime()) ? null : d;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseProject(body: Record<string, unknown>) {
|
||||||
|
const size = String(body.size);
|
||||||
|
const validSize = (Object.values(ProjectSize) as string[]).includes(size)
|
||||||
|
? (size as ProjectSize)
|
||||||
|
: ProjectSize.MediumSized;
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: str(body.name, "name"),
|
||||||
|
label: str(body.label, "label"),
|
||||||
|
size: validSize,
|
||||||
|
imageUrl: optStr(body.imageUrl),
|
||||||
|
link: str(body.link, "link"),
|
||||||
|
linkIsDemo: bool(body.linkIsDemo),
|
||||||
|
sourceCode: optStr(body.sourceCode),
|
||||||
|
description: str(body.description, "description"),
|
||||||
|
why: str(body.why, "why"),
|
||||||
|
note: optStr(body.note),
|
||||||
|
tags: tags(body.tags, 3),
|
||||||
|
loc: optInt(body.loc),
|
||||||
|
locEndpoint: optStr(body.locEndpoint),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseExperience(body: Record<string, unknown>) {
|
||||||
|
return {
|
||||||
|
company: str(body.company, "company"),
|
||||||
|
role: str(body.role, "role"),
|
||||||
|
fromDate: date(body.fromDate, "fromDate"),
|
||||||
|
toDate: optDate(body.toDate),
|
||||||
|
url: optStr(body.url),
|
||||||
|
iconUrl: optStr(body.iconUrl),
|
||||||
|
summary: str(body.summary, "summary"),
|
||||||
|
tags: tags(body.tags, 6),
|
||||||
|
sortIndex: int(body.sortIndex),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseAffiliate(body: Record<string, unknown>) {
|
||||||
|
return {
|
||||||
|
name: str(body.name, "name"),
|
||||||
|
link: str(body.link, "link"),
|
||||||
|
icon: str(body.icon, "icon"),
|
||||||
|
location: str(body.location, "location"),
|
||||||
|
provides: strList(body.provides),
|
||||||
|
good: strList(body.good),
|
||||||
|
bad: strList(body.bad),
|
||||||
|
sortIndex: int(body.sortIndex),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
// In-memory, TTL-based cache for lines-of-code counts fetched from a project's
|
||||||
|
// `locEndpoint`. The endpoint is expected to return a plaintext integer.
|
||||||
|
|
||||||
|
type CacheEntry = { value: number | null; expiresAt: number };
|
||||||
|
|
||||||
|
const CACHE_TTL_MS = 1000 * 60 * 10; // 10 minutes
|
||||||
|
const cache = new Map<string, CacheEntry>();
|
||||||
|
|
||||||
|
async function fetchLocFromEndpoint(endpoint: string): Promise<number | null> {
|
||||||
|
const cached = cache.get(endpoint);
|
||||||
|
if (cached && cached.expiresAt > Date.now()) return cached.value;
|
||||||
|
|
||||||
|
let value: number | null = null;
|
||||||
|
try {
|
||||||
|
const res = await fetch(endpoint, { signal: AbortSignal.timeout(5000) });
|
||||||
|
if (res.ok) {
|
||||||
|
const text = (await res.text()).trim();
|
||||||
|
const parsed = Number.parseInt(text.replace(/[^0-9]/g, ""), 10);
|
||||||
|
value = Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
cache.set(endpoint, { value, expiresAt: Date.now() + CACHE_TTL_MS });
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a project's line-of-code count: prefer the manually provided `loc`,
|
||||||
|
* otherwise hit `locEndpoint` (cached in memory). Returns null when unknown.
|
||||||
|
*/
|
||||||
|
export async function resolveLoc(
|
||||||
|
loc: number | null | undefined,
|
||||||
|
locEndpoint: string | null | undefined,
|
||||||
|
): Promise<number | null> {
|
||||||
|
if (typeof loc === "number") return loc;
|
||||||
|
if (locEndpoint) return fetchLocFromEndpoint(locEndpoint);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { PrismaClient } from "@/generated/prisma/client";
|
||||||
|
import { PrismaPg } from "@prisma/adapter-pg";
|
||||||
|
|
||||||
|
const globalForPrisma = globalThis as unknown as {
|
||||||
|
prisma: PrismaClient | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
function createClient() {
|
||||||
|
const connectionString = process.env.DATABASE_URL;
|
||||||
|
if (!connectionString) {
|
||||||
|
throw new Error("DATABASE_URL is not set");
|
||||||
|
}
|
||||||
|
const adapter = new PrismaPg({ connectionString });
|
||||||
|
return new PrismaClient({ adapter });
|
||||||
|
}
|
||||||
|
|
||||||
|
export const prisma = globalForPrisma.prisma ?? createClient();
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== "production") {
|
||||||
|
globalForPrisma.prisma = prisma;
|
||||||
|
}
|
||||||
@@ -24,9 +24,7 @@ export function Activity() {
|
|||||||
return (
|
return (
|
||||||
<section className="w-full max-w-6xl mx-auto space-y-6 px-4 sm:space-y-8">
|
<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-2xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-blue-400 to-purple-500 sm:text-3xl">
|
<h2 className="text-2xl font-semibold text-white sm:text-3xl">Code Activity</h2>
|
||||||
Code Activity
|
|
||||||
</h2>
|
|
||||||
<p className="mx-auto max-w-2xl text-sm text-gray-400 sm:text-base">
|
<p className="mx-auto max-w-2xl text-sm text-gray-400 sm:text-base">
|
||||||
Today, i've already been coding for
|
Today, i've already been coding for
|
||||||
<span className="font-mono text-base text-green-400 ml-1">
|
<span className="font-mono text-base text-green-400 ml-1">
|
||||||
@@ -41,7 +39,7 @@ export function Activity() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mx-auto max-w-5xl rounded-3xl border border-white/10 bg-white/5 p-3 shadow-[0_20px_80px_rgba(0,0,0,0.35)] backdrop-blur-sm sm:p-4 md:p-6">
|
<div className="mx-auto max-w-5xl rounded-3xl border border-white/10 bg-white/5 p-3 shadow-[0_20px_80px_rgba(0,0,0,0.35)] backdrop-blur-sm sm:p-4 md:p-6">
|
||||||
<div className="overflow-x-auto rounded-2xl border border-white/10 bg-[#0a0a12]">
|
<div className="overflow-x-auto rounded-2xl bg-[#0d1117]">
|
||||||
<img
|
<img
|
||||||
src={activitySvg}
|
src={activitySvg}
|
||||||
alt="Git activity graph"
|
alt="Git activity graph"
|
||||||
|
|||||||
+64
-91
@@ -1,121 +1,94 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
type Affiliate = {
|
import { useProfile } from "../context/ProfileContext";
|
||||||
name: string;
|
|
||||||
good: string[];
|
|
||||||
bad: string[];
|
|
||||||
link: string;
|
|
||||||
icon: string;
|
|
||||||
location: string;
|
|
||||||
provides: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
const affiliates: Affiliate[] = [
|
|
||||||
{
|
|
||||||
name: "SparkedHost",
|
|
||||||
good: ["Decent Support", "Good Bot Hosting", "Generous Webhosting"],
|
|
||||||
bad: ["Staff can be hit or miss"],
|
|
||||||
link: "https://billing.sparkedhost.com/aff.php?aff=1843",
|
|
||||||
icon: "https://sparkedhost.com/_next/static/media/logo-text.fce7e4c5.svg",
|
|
||||||
location: "Global",
|
|
||||||
provides: ["A ton of Game Hosting", "Web Hosting", "VPS", "Bot Hosting", "Domains"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Datalix",
|
|
||||||
good: ["Uptime", "Hardware", "Prices", "Support"],
|
|
||||||
bad: ["Nothing"],
|
|
||||||
link: "https://datalix.de/a/space",
|
|
||||||
icon: "https://cdn.datalix.de/images/header.png",
|
|
||||||
location: "Germany",
|
|
||||||
provides: ["KVM VPS", "Web Hosting", "Dedicated Servers", "Game Servers", "S3", "Nextcloud", "Reselling"],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
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-6 px-4 sm:space-y-8">
|
<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-2xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-blue-400 to-purple-500 sm:text-3xl">
|
<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 className="mx-auto max-w-2xl text-sm text-gray-400 sm:text-base">
|
|
||||||
Looking for hosting? I recommend checking out Datalix!
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 sm: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="w-full rounded-xl border border-white/10 bg-white/5 p-4 backdrop-blur-sm transition-all duration-300 hover:border-purple-500/40 hover:bg-white/10"
|
className="w-full rounded-xl border border-white/10 bg-white/[0.03] p-4 transition-all duration-300 hover:border-white/25 hover:bg-white/[0.06]"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col items-center text-center gap-2 mb-3">
|
<div className="mb-3 flex flex-col items-center gap-2 text-center">
|
||||||
<img
|
{affiliate.icon ? (
|
||||||
src={affiliate.icon}
|
<img
|
||||||
alt={affiliate.name}
|
src={affiliate.icon}
|
||||||
className="h-7 max-w-[130px] w-auto object-contain rounded"
|
alt={affiliate.name}
|
||||||
/>
|
className="h-7 w-auto max-w-[130px] rounded object-contain"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold text-white">
|
<h3 className="text-lg font-semibold text-white">{affiliate.name}</h3>
|
||||||
{affiliate.name}
|
<p className="mt-1 text-[10px] font-medium uppercase leading-none tracking-widest text-gray-500">
|
||||||
</h3>
|
|
||||||
<p className="text-[10px] uppercase tracking-widest text-gray-500 font-medium leading-none mt-1">
|
|
||||||
{affiliate.location}
|
{affiliate.location}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
{affiliate.provides.length > 0 ? (
|
||||||
<p className="text-xs font-semibold text-blue-300 mb-1.5">
|
<div>
|
||||||
Provides
|
<p className="mb-1.5 text-xs font-semibold text-gray-400">Provides</p>
|
||||||
</p>
|
<div className="flex flex-wrap gap-2">
|
||||||
<div className="flex flex-wrap gap-2">
|
{affiliate.provides.map((item) => (
|
||||||
{affiliate.provides.map((item) => (
|
<span
|
||||||
<span
|
key={item}
|
||||||
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"
|
||||||
className="inline-flex items-center gap-1.5 rounded-full border border-blue-400/30 bg-blue-500/10 px-2.5 py-1 text-xs text-blue-200"
|
>
|
||||||
>
|
{item}
|
||||||
{item}
|
</span>
|
||||||
</span>
|
))}
|
||||||
))}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : null}
|
||||||
<div>
|
{affiliate.good.length > 0 ? (
|
||||||
<p className="text-xs font-semibold text-emerald-300 mb-1.5">
|
<div>
|
||||||
Good
|
<p className="mb-1.5 text-xs font-semibold text-emerald-300">Good</p>
|
||||||
</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
|
key={item}
|
||||||
key={item}
|
className="inline-flex items-center gap-1.5 rounded-full border border-emerald-400/30 bg-emerald-500/10 px-2.5 py-1 text-xs text-emerald-200"
|
||||||
className="inline-flex items-center gap-1.5 rounded-full border border-emerald-400/30 bg-emerald-500/10 px-2.5 py-1 text-xs text-emerald-200"
|
>
|
||||||
>
|
<span className="font-bold">+</span>
|
||||||
<span className="font-bold">+</span>
|
{item}
|
||||||
{item}
|
</span>
|
||||||
</span>
|
))}
|
||||||
))}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : null}
|
||||||
<div>
|
{affiliate.bad.length > 0 ? (
|
||||||
<p className="text-xs font-semibold text-rose-300 mb-1.5">
|
<div>
|
||||||
Bad
|
<p className="mb-1.5 text-xs font-semibold text-rose-300">Bad</p>
|
||||||
</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
|
key={item}
|
||||||
key={item}
|
className="inline-flex items-center gap-1.5 rounded-full border border-rose-400/30 bg-rose-500/10 px-2.5 py-1 text-xs text-rose-200"
|
||||||
className="inline-flex items-center gap-1.5 rounded-full border border-rose-400/30 bg-rose-500/10 px-2.5 py-1 text-xs text-rose-200"
|
>
|
||||||
>
|
<span className="font-bold">-</span>
|
||||||
<span className="font-bold">-</span>
|
{item}
|
||||||
{item}
|
</span>
|
||||||
</span>
|
))}
|
||||||
))}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useProfile } from "../context/ProfileContext";
|
|
||||||
|
|
||||||
export function AgentSkills() {
|
|
||||||
const { skills } = useProfile();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="w-full max-w-6xl mx-auto px-4 space-y-6 sm:space-y-8">
|
|
||||||
<div className="text-center space-y-3">
|
|
||||||
<h2 className="text-3xl font-bold leading-tight text-transparent bg-clip-text bg-gradient-to-r from-amber-400 to-orange-500 sm:text-4xl">
|
|
||||||
Agent Skills
|
|
||||||
</h2>
|
|
||||||
<p className="mx-auto max-w-2xl text-sm text-gray-400 sm:text-base">
|
|
||||||
Skills i throw at my Agents to help them solve my prompts.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{skills.length === 0 ? (
|
|
||||||
<div className="rounded-3xl border border-white/10 bg-white/5 px-5 py-8 text-center text-gray-400 sm:px-6 sm:py-10">
|
|
||||||
No agent skills published yet.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-1 gap-5 lg:grid-cols-2 sm:gap-6">
|
|
||||||
{skills.map((skill, index) => (
|
|
||||||
<article
|
|
||||||
key={`${skill.name}-${index}`}
|
|
||||||
className="group w-full rounded-3xl border border-amber-500/20 bg-gradient-to-br from-amber-500/10 via-white/5 to-orange-500/5 p-5 shadow-[0_0_0_1px_rgba(245,158,11,0.08)] backdrop-blur-sm transition-all duration-300 hover:border-amber-500/40 sm:p-6 md:p-7"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:flex-wrap sm:items-start sm:justify-between">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{(skill.emojis || []).map((emoji, emojiIndex) => (
|
|
||||||
<span
|
|
||||||
key={`${skill.name}-emoji-${emojiIndex}`}
|
|
||||||
className="inline-flex h-9 w-9 items-center justify-center rounded-xl bg-amber-500/15 text-lg border border-amber-500/20"
|
|
||||||
title={emoji}
|
|
||||||
>
|
|
||||||
{emoji}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<h3 className="text-xl font-semibold text-white sm:text-2xl">{skill.name}</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{skill.link ? (
|
|
||||||
<a
|
|
||||||
href={skill.link}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="inline-flex items-center justify-center rounded-full border border-amber-400/30 bg-amber-400/10 px-4 py-2 text-xs font-semibold text-amber-100 transition-colors hover:bg-amber-400/20 sm:self-start"
|
|
||||||
>
|
|
||||||
Open Link
|
|
||||||
</a>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-5 space-y-4 text-sm leading-6 text-gray-300">
|
|
||||||
<p>
|
|
||||||
<span className="font-semibold text-amber-200">Description:</span> {skill.description}
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<span className="font-semibold text-amber-200">Purpose:</span> {skill.purpose}
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<span className="font-semibold text-amber-200">Solves:</span> {skill.what_it_solves}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 flex flex-wrap gap-2">
|
|
||||||
{(skill.tags || []).map((tag, tagIndex) => (
|
|
||||||
<span
|
|
||||||
key={`${skill.name}-tag-${tagIndex}`}
|
|
||||||
className="rounded-full border border-white/10 bg-black/20 px-3 py-1 text-xs font-medium text-gray-200"
|
|
||||||
>
|
|
||||||
{tag}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,18 +1,18 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { CONTACT_EMAIL } from "../types";
|
||||||
|
|
||||||
export function Contact() {
|
export function Contact() {
|
||||||
return (
|
return (
|
||||||
<section className="mx-auto w-full max-w-2xl space-y-4 pb-8 text-center">
|
<section className="mx-auto w-full max-w-2xl space-y-4 pb-8 text-center">
|
||||||
<h2 className="text-2xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-green-400 via-blue-500 to-purple-500 sm:text-3xl">
|
<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>
|
|
||||||
<div className="rounded-3xl border border-blue-500/20 bg-gradient-to-br from-blue-500/10 via-purple-500/10 to-pink-500/10 p-6 backdrop-blur-md sm:p-8">
|
|
||||||
<p className="mb-6 text-base text-gray-300 sm:mb-8 sm:text-lg">
|
<p className="mb-6 text-base text-gray-300 sm:mb-8 sm:text-lg">
|
||||||
Im almost always down to talk about anything. Tech, life and whatever. Feel free to reach out to me on Discord or via Email, i usually respond pretty quickly.
|
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 justify-center gap-3 sm:flex-row sm:gap-4">
|
<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="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"
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+125
-138
@@ -1,32 +1,62 @@
|
|||||||
import { useState, useRef } from "react";
|
"use client";
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { TimezoneClockBlock } from "../components/TimezoneClockBlock";
|
|
||||||
|
|
||||||
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,
|
|
||||||
timeGapWarningThreshold = 5,
|
// Discord presence via SHSF (drives the avatar ring + name dot colour).
|
||||||
}: {
|
const STATUS_URL =
|
||||||
glowColor: string;
|
"https://shsf.reversed.dev/api/exec/6/c084ec4a-1b20-491e-ab2e-67c5fa8881e6";
|
||||||
borderStatus: string;
|
|
||||||
displayMessage: string;
|
type Presence = "online" | "idle" | "dnd" | "offline";
|
||||||
rotatingMessages: string[];
|
|
||||||
statusMessage: string;
|
const PRESENCE: Record<Presence, { ring: string; glow: string; dot: string; label: string }> = {
|
||||||
oldUsernames: string[];
|
online: {
|
||||||
timeGapWarningThreshold?: number;
|
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();
|
||||||
|
|
||||||
@@ -35,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,141 +85,101 @@ 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-16 flex flex-col items-center text-center space-y-6 px-4 sm:mt-20 sm: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 }}
|
<span
|
||||||
></div>
|
className={`inline-block h-3 w-3 rounded-full transition-colors duration-500 ${style.dot}`}
|
||||||
<div
|
title={style.label}
|
||||||
className={`relative h-32 w-32 overflow-hidden rounded-full border-4 bg-black transition-colors duration-500 sm:h-48 sm:w-48 ${borderStatus}`}
|
aria-label={style.label}
|
||||||
>
|
/>
|
||||||
<img
|
</h1>
|
||||||
src="https://cdn.reversed.dev/pictures/20250405_120402.png"
|
<p className="font-mono text-sm text-gray-500">{HANDLE}</p>
|
||||||
alt="Space"
|
|
||||||
className="h-full w-full scale-110 object-cover transition-transform duration-700 group-hover:scale-125"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Rotating Message Bubble (Left Side) */}
|
|
||||||
<div className="absolute -left-4 top-4 hidden -translate-x-full md:block">
|
|
||||||
<div className="relative bg-gradient-to-br from-purple-500/20 to-pink-500/10 backdrop-blur-sm border border-purple-500/30 rounded-2xl px-4 py-3 shadow-lg">
|
|
||||||
{/* Arrow pointing to profile */}
|
|
||||||
<div className="absolute right-0 top-1/2 translate-x-2 -translate-y-1/2 w-0 h-0 border-t-8 border-t-transparent border-b-8 border-b-transparent border-l-8 border-l-purple-500/30"></div>
|
|
||||||
<div className="absolute right-0 top-1/2 translate-x-1.5 -translate-y-1/2 w-0 h-0 border-t-8 border-t-transparent border-b-8 border-b-transparent border-l-8 border-l-purple-500/20"></div>
|
|
||||||
|
|
||||||
<p className="text-sm text-gray-200 whitespace-nowrap font-medium font-mono">
|
|
||||||
{displayMessage || rotatingMessages[0]}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Status Bubble (Right Side) */}
|
<p className="mx-auto max-w-xl text-base leading-relaxed text-gray-400 sm:mx-0 sm:text-lg">
|
||||||
<div className="absolute -right-4 top-8 hidden translate-x-full md:block">
|
Full-stack developer & open-source author. Building server
|
||||||
<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">
|
infrastructure, developer tools, and web applications.
|
||||||
{/* Arrow pointing to profile */}
|
|
||||||
<div className="absolute left-0 top-1/2 -translate-x-2 -translate-y-1/2 w-0 h-0 border-t-8 border-t-transparent border-b-8 border-b-transparent border-r-8 border-r-white/20"></div>
|
|
||||||
<div className="absolute left-0 top-1/2 -translate-x-1.5 -translate-y-1/2 w-0 h-0 border-t-8 border-t-transparent border-b-8 border-b-transparent border-r-8 border-r-white/10"></div>
|
|
||||||
|
|
||||||
<p className="text-sm text-gray-300 whitespace-nowrap">
|
|
||||||
Currently{" "}
|
|
||||||
<span className="text-white font-semibold">
|
|
||||||
{statusMessage}
|
|
||||||
</span>
|
|
||||||
<br />
|
|
||||||
on Discord
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-gray-400/70 mt-1">
|
|
||||||
Data from <a href="https://github.com/Space-Banane/shsf-discord-status" className="text-blue-400 hover:underline">Discord</a> (cached & delayed)
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mobile: Discord status + message (hidden on md+ where bubbles appear) */}
|
<div className="flex flex-wrap items-center justify-center gap-3 sm:justify-start">
|
||||||
<div className="flex flex-col items-center gap-2 md:hidden w-full max-w-sm">
|
|
||||||
{(displayMessage || rotatingMessages[0]) && (
|
|
||||||
<div className="w-full bg-gradient-to-br from-purple-500/20 to-pink-500/10 backdrop-blur-sm border border-purple-500/30 rounded-2xl px-4 py-2.5 text-sm text-gray-200 font-mono text-center">
|
|
||||||
{displayMessage || rotatingMessages[0]}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{statusMessage && (
|
|
||||||
<div className="flex items-center gap-2 rounded-2xl border border-white/15 bg-white/5 px-4 py-2.5 text-sm backdrop-blur-sm">
|
|
||||||
<span className="h-2 w-2 shrink-0 rounded-full bg-green-400" />
|
|
||||||
<span className="text-gray-300">Currently</span>
|
|
||||||
<span className="font-semibold text-white">{statusMessage}</span>
|
|
||||||
<span className="text-gray-400">on Discord</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Name & Description */}
|
|
||||||
<div className="space-y-4 max-w-2xl">
|
|
||||||
<h1 className="text-4xl font-extrabold leading-tight sm:text-5xl md:text-7xl">
|
|
||||||
Hey, I'm{" "}
|
|
||||||
<span
|
|
||||||
className="relative inline-block text-transparent bg-clip-text bg-gradient-to-r from-blue-400 via-purple-500 to-pink-500 px-2"
|
|
||||||
style={{ marginLeft: "0.15em", marginRight: "0.15em" }}
|
|
||||||
>
|
|
||||||
Space²
|
|
||||||
</span>
|
|
||||||
</h1>
|
|
||||||
<p className="text-base font-light text-gray-400 sm:text-xl md:text-2xl">
|
|
||||||
A{" "}
|
|
||||||
<span className="line-through decoration-purple-500/50 decoration-2">
|
|
||||||
Self-proclaimed
|
|
||||||
</span>{" "}
|
|
||||||
Developer breaking things to see how they work.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Luna AI Profile Link */}
|
|
||||||
<div className="pt-4 flex justify-center">
|
|
||||||
<div className="flex flex-wrap items-center justify-center gap-3">
|
|
||||||
<a
|
<a
|
||||||
href="https://luna.spaceistyping.com"
|
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 w-full items-center justify-center gap-3 rounded-full border border-white/10 bg-white/5 px-5 py-2.5 text-center shadow-lg transition-all duration-300 hover:border-purple-500/50 hover:bg-white/10 hover:shadow-purple-500/10 sm:w-auto"
|
|
||||||
>
|
>
|
||||||
<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 rounded-full border border-white/10 bg-white/5 px-4 py-2 shadow-sm transition-all duration-300 hover:border-gray-400 hover:bg-white/10"
|
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-4 py-2.5 text-sm font-medium text-gray-200 transition-all duration-300 hover:border-gray-400 hover:bg-white/10"
|
||||||
aria-label="GitHub"
|
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 rounded-full border border-white/10 bg-white/5 px-4 py-2 shadow-sm transition-all duration-300 hover:border-yellow-400 hover:bg-white/10"
|
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-4 py-2.5 text-sm font-medium text-gray-200 transition-all duration-300 hover:border-emerald-400/50 hover:bg-white/10"
|
||||||
aria-label="Gitea"
|
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>
|
</div>
|
||||||
|
|
||||||
<TimezoneClockBlock warningThresholdHours={timeGapWarningThreshold} />
|
{/* Right: avatar with live status ring */}
|
||||||
|
<div className="relative shrink-0" onMouseMove={handleAvatarMouseMove}>
|
||||||
|
<div className={`absolute -inset-1 rounded-full blur-md transition-colors duration-500 ${style.glow}`} />
|
||||||
|
<div
|
||||||
|
className={`relative h-32 w-32 overflow-hidden rounded-full border-4 bg-black transition-colors duration-500 sm:h-44 sm:w-44 ${style.ring}`}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/api/profile-image"
|
||||||
|
alt="Paul W."
|
||||||
|
className="h-full w-full scale-105 object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</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,63 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { ExperienceCard } from "@/components/ExperienceCard";
|
|
||||||
import type { Experience } from "../types";
|
|
||||||
|
|
||||||
const ENABLE_SKILLS_EXPERIENCE = true;
|
|
||||||
|
|
||||||
interface SkillsExperienceProps {
|
|
||||||
experiences: Experience[];
|
|
||||||
onSelectExperience: (experience: Experience) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SkillsExperience({
|
|
||||||
experiences,
|
|
||||||
onSelectExperience,
|
|
||||||
}: SkillsExperienceProps) {
|
|
||||||
if (!ENABLE_SKILLS_EXPERIENCE) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const groupedExperiences = experiences.reduce(
|
|
||||||
(acc, exp) => {
|
|
||||||
if (!acc[exp.type]) acc[exp.type] = [];
|
|
||||||
acc[exp.type].push(exp);
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{} as Record<string, Experience[]>,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="w-full max-w-6xl mx-auto space-y-8 px-4 pb-20 sm:space-y-12">
|
|
||||||
<div className="text-center space-y-4">
|
|
||||||
<h2 className="text-2xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-blue-400 to-purple-500 sm:text-3xl">
|
|
||||||
Skills & Experience
|
|
||||||
</h2>
|
|
||||||
<p className="text-sm text-gray-400 sm:text-base">
|
|
||||||
<span className="text-red-400">
|
|
||||||
THIS IS MISSING A LOT OF THINGS, BE AWARE
|
|
||||||
</span>{" "}
|
|
||||||
- Things I've worked with over the years.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 gap-8 md:grid-cols-2 sm:gap-12">
|
|
||||||
{Object.entries(groupedExperiences).map(([type, items]) => (
|
|
||||||
<div key={type} className="space-y-6">
|
|
||||||
<h3 className="border-l-4 border-blue-500 pl-4 text-lg font-semibold capitalize sm:text-xl">
|
|
||||||
{type}
|
|
||||||
</h3>
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
||||||
{items.map((exp, index) => (
|
|
||||||
<ExperienceCard
|
|
||||||
key={index}
|
|
||||||
experience={exp}
|
|
||||||
onClick={() => onSelectExperience(exp)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { GitBranch } from "lucide-react";
|
||||||
|
import { useProfile } from "../context/ProfileContext";
|
||||||
|
|
||||||
|
export function Stats() {
|
||||||
|
const { projects, loading } = useProfile();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="mx-auto w-full max-w-5xl px-4">
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:max-w-xs">
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-white/[0.03] p-5">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<span className="text-sm text-gray-400">Projects</span>
|
||||||
|
<GitBranch className="h-4 w-4 text-gray-500" />
|
||||||
|
</div>
|
||||||
|
<p className="text-3xl font-bold text-white">
|
||||||
|
{loading ? "—" : projects.length}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">and many more private ones</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
const ENABLE_TECH_STACK = false;
|
|
||||||
|
|
||||||
const techStack = [
|
|
||||||
{
|
|
||||||
title: "Monitoring & Testing",
|
|
||||||
items: [
|
|
||||||
{ name: "GitHub Actions", description: "CI/CD & automation" },
|
|
||||||
{ name: "Vitest", description: "Unit & integration testing" },
|
|
||||||
{ name: "Sentry", description: "Error monitoring" },
|
|
||||||
{ name: "Umami.is", description: "Privacy-friendly analytics" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Software Stack",
|
|
||||||
items: [
|
|
||||||
{ name: "Next.js / React", description: "Frontend & SSR framework" },
|
|
||||||
{ name: "Tailwind CSS", description: "Utility-first styling" },
|
|
||||||
{ name: "TypeScript", description: "Typed JavaScript" },
|
|
||||||
{
|
|
||||||
name: "Custom Library & Webserver API",
|
|
||||||
description: "Backend & shared logic",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Virtualisation",
|
|
||||||
items: [
|
|
||||||
{ name: "Proxmox", description: "Hypervisor & VM management" },
|
|
||||||
{ name: "Docker", description: "Containerization" },
|
|
||||||
{ name: "LXC", description: "Lightweight containers" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "OS",
|
|
||||||
items: [
|
|
||||||
{ name: "Ubuntu", description: "Linux distribution" },
|
|
||||||
{
|
|
||||||
name: "Windows Server 2025",
|
|
||||||
description: "Active Directory & domain controller",
|
|
||||||
},
|
|
||||||
{ name: "Debian", description: "Linux distribution" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Servers",
|
|
||||||
items: [
|
|
||||||
{ name: "KVMs from datalix.de", description: "Cloud provider" },
|
|
||||||
{ name: "Home server", description: "Self-hosted option" },
|
|
||||||
{ name: "AWS", description: "Cloud computing" }
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export function TechStack() {
|
|
||||||
if (!ENABLE_TECH_STACK) {
|
|
||||||
// Disabled
|
|
||||||
return (
|
|
||||||
<section className="w-full max-w-4xl mx-auto px-4 space-y-10 mt-16 mb-24">
|
|
||||||
<div className="text-center space-y-3">
|
|
||||||
<h2 className="text-3xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-cyan-400 to-blue-500 sm:text-4xl">
|
|
||||||
Tech Stack
|
|
||||||
</h2>
|
|
||||||
<p className="mx-auto max-w-2xl text-sm text-gray-400 md:text-base">
|
|
||||||
This section is currently disabled. It may be re-enabled in the future, but for now, it's hidden.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<section className="w-full max-w-4xl mx-auto px-4 space-y-10 mt-16 mb-24">
|
|
||||||
<div className="text-center space-y-3">
|
|
||||||
<h2 className="text-3xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-cyan-400 to-blue-500 sm:text-4xl">
|
|
||||||
Tech Stack
|
|
||||||
</h2>
|
|
||||||
<p className="mx-auto max-w-2xl text-sm text-gray-400 md:text-base">
|
|
||||||
A overview of the tools and technologies I use to build and host my applications.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="max-w-2xl mx-auto space-y-4">
|
|
||||||
{techStack.map((layer) => (
|
|
||||||
<div
|
|
||||||
key={layer.title}
|
|
||||||
className="p-5 rounded-2xl bg-gradient-to-br from-cyan-500/[0.07] to-blue-500/[0.03] backdrop-blur-md border border-cyan-500/10 hover:border-cyan-500/30 transition-all duration-500"
|
|
||||||
>
|
|
||||||
<h3 className="text-lg font-bold text-white mb-3 flex items-center gap-2">
|
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-cyan-500" />
|
|
||||||
{layer.title}
|
|
||||||
</h3>
|
|
||||||
<ul className="space-y-3">
|
|
||||||
{layer.items.map((item) => (
|
|
||||||
<li
|
|
||||||
key={item.name}
|
|
||||||
className="flex flex-col group cursor-default"
|
|
||||||
>
|
|
||||||
<span className="text-sm font-semibold text-cyan-100 group-hover:text-cyan-400 transition-colors">
|
|
||||||
{item.name}
|
|
||||||
</span>
|
|
||||||
<span className="text-[11px] text-gray-500 font-normal leading-relaxed">
|
|
||||||
{item.description}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
export function Uptime() {
|
|
||||||
return (
|
|
||||||
<section className="w-full max-w-6xl mx-auto space-y-8 px-4 sm:space-y-10">
|
|
||||||
<div className="text-center space-y-4">
|
|
||||||
<h2 className="text-2xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-blue-400 to-purple-500 sm:text-3xl">
|
|
||||||
Downtime
|
|
||||||
</h2>
|
|
||||||
<p className="mx-auto max-w-2xl text-sm text-gray-400 sm:text-base">
|
|
||||||
I love selfhosting stuff, can you tell?
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mx-auto max-w-3xl">
|
|
||||||
<div className="w-full rounded-2xl border border-white/6 bg-gradient-to-br from-gray-800/20 to-transparent p-5 backdrop-blur-sm sm:p-6">
|
|
||||||
<h3 className="mb-4 text-center text-xl font-semibold text-white sm:text-2xl">
|
|
||||||
Github vs HomeLab Uptime
|
|
||||||
</h3>
|
|
||||||
<div className="flex flex-col items-center justify-between gap-6 sm:flex-row">
|
|
||||||
<div className="flex-1 text-center">
|
|
||||||
<img src="/gh_down.png" alt="GitHub downtime" className="mx-auto h-24 object-contain sm:h-28" />
|
|
||||||
<h4 className="mt-3 font-medium text-white">GitHub</h4>
|
|
||||||
<p className="text-gray-400 mt-1">Goes down more.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="h-px w-24 bg-white/6 sm:h-24 sm:w-px" />
|
|
||||||
|
|
||||||
<div className="flex-1 text-center">
|
|
||||||
<img src="/homelab_down.png" alt="HomeLab" className="mx-auto h-24 object-contain sm:h-28" />
|
|
||||||
<h4 className="mt-3 font-medium text-white">HomeLab</h4>
|
|
||||||
<p className="text-gray-400 mt-1">Goes down less.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+102
-121
@@ -1,133 +1,114 @@
|
|||||||
"use client";
|
"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());
|
return (
|
||||||
if (yearMonthMatch) {
|
<section className="w-full max-w-4xl mx-auto px-4 space-y-6 sm:space-y-8">
|
||||||
const year = Number.parseInt(yearMonthMatch[1], 10);
|
<div className="text-center space-y-2">
|
||||||
const monthIndex = Number.parseInt(yearMonthMatch[2], 10) - 1;
|
<h2 className="text-2xl font-semibold text-white sm:text-3xl">Work Experience</h2>
|
||||||
if (monthIndex >= 0 && monthIndex <= 11) {
|
<p className="mx-auto max-w-2xl text-sm text-gray-500 sm:text-base">
|
||||||
return new Date(Date.UTC(year, monthIndex, 1)).toLocaleDateString("en-US", {
|
Actual work that i did at actual companies.
|
||||||
year: "numeric",
|
</p>
|
||||||
month: "short",
|
</div>
|
||||||
timeZone: "UTC",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const timestamp = Date.parse(dateValue);
|
{experiences.length === 0 ? (
|
||||||
if (Number.isNaN(timestamp)) return dateValue;
|
<div className="rounded-2xl border border-white/10 bg-white/[0.03] p-5 text-center sm:p-6 md:p-8">
|
||||||
return new Date(timestamp).toLocaleDateString("en-US", {
|
<p className="text-gray-400">No work experience entries available yet.</p>
|
||||||
year: "numeric",
|
</div>
|
||||||
month: "short",
|
) : (
|
||||||
});
|
<div className="space-y-4">
|
||||||
}
|
{experiences.map((entry) => (
|
||||||
|
<div
|
||||||
export function WorkExperience({ realWork }: WorkExperienceProps) {
|
key={entry.id}
|
||||||
const sortedRealWork = [...realWork].sort(
|
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"
|
||||||
(a, b) =>
|
>
|
||||||
getSortTimestamp(b) - getSortTimestamp(a) ||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-2">
|
||||||
getWorkDateTimestamp(b.from) - getWorkDateTimestamp(a.from),
|
<div className="flex items-start gap-3">
|
||||||
);
|
{entry.iconUrl ? (
|
||||||
|
<img
|
||||||
return (
|
src={entry.iconUrl}
|
||||||
<section className="w-full max-w-4xl mx-auto px-4 space-y-6 sm:space-y-8">
|
alt=""
|
||||||
<div className="text-center space-y-2">
|
className="mt-1 h-10 w-10 shrink-0 rounded-lg object-contain"
|
||||||
<h2 className="text-2xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-cyan-400 to-blue-500 sm:text-3xl">
|
/>
|
||||||
Work Experience
|
) : null}
|
||||||
</h2>
|
<div>
|
||||||
<p className="mx-auto max-w-2xl text-sm text-gray-400 sm:text-base">
|
<h3 className="text-xl font-semibold text-white sm:text-2xl">
|
||||||
Actual work that i did at actual companies.
|
{entry.company}
|
||||||
</p>
|
</h3>
|
||||||
</div>
|
<p className="mt-1 text-sm font-medium text-gray-300">{entry.role}</p>
|
||||||
|
<p className="mt-1 text-xs font-medium uppercase tracking-wide text-gray-500">
|
||||||
{realWork.length === 0 ? (
|
{formatMonth(entry.fromDate)} —{" "}
|
||||||
<div className="rounded-2xl border border-cyan-500/20 bg-gradient-to-br from-cyan-500/10 to-blue-500/5 p-5 text-center backdrop-blur-sm sm:p-6 md:p-8">
|
{entry.toDate ? formatMonth(entry.toDate) : "Present"}
|
||||||
<p className="text-gray-300">No work experience entries available yet.</p>
|
<span className="ml-2 text-gray-600">
|
||||||
</div>
|
· {formatDuration(entry.fromDate, entry.toDate)}
|
||||||
) : (
|
</span>
|
||||||
<div className="space-y-4">
|
</p>
|
||||||
{sortedRealWork.map((entry, index) => (
|
</div>
|
||||||
<div
|
</div>
|
||||||
key={`${entry.company}-${index}`}
|
{entry.url ? (
|
||||||
className="w-full space-y-4 rounded-2xl border border-cyan-500/20 bg-gradient-to-br from-cyan-500/10 to-blue-500/5 p-5 backdrop-blur-sm sm:space-y-5 sm:p-6 md:p-8"
|
<a
|
||||||
>
|
href={entry.url}
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-2">
|
target="_blank"
|
||||||
<div>
|
rel="noreferrer"
|
||||||
<h3 className="text-xl font-semibold text-white sm:text-2xl">{entry.company}</h3>
|
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"
|
||||||
{entry.role ? (
|
>
|
||||||
<p className="text-sm font-medium text-cyan-200/90 mt-1">{entry.role}</p>
|
<span>{new URL(entry.url).hostname.replace(/^www\./, "")}</span>
|
||||||
) : null}
|
<svg viewBox="0 0 20 20" fill="currentColor" aria-hidden className="h-4 w-4">
|
||||||
{entry.from || entry.until ? (
|
<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" />
|
||||||
<p className="text-xs font-medium uppercase tracking-wide text-cyan-300/80 mt-1">
|
<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" />
|
||||||
{entry.from ? formatWorkDate(entry.from) : "Unknown"} -{" "}
|
</svg>
|
||||||
{entry.until
|
</a>
|
||||||
? entry.until.trim().toLowerCase() === "present"
|
) : null}
|
||||||
? "Present"
|
</div>
|
||||||
: formatWorkDate(entry.until)
|
|
||||||
: "Present"}
|
<p className="leading-relaxed text-gray-300">{entry.summary}</p>
|
||||||
</p>
|
|
||||||
) : null}
|
{entry.tags.length > 0 ? (
|
||||||
</div>
|
<div className="flex flex-wrap gap-2">
|
||||||
{entry.url ? (
|
{entry.tags.map((tag) => (
|
||||||
<a
|
<span
|
||||||
href={entry.url}
|
key={`${entry.id}-${tag}`}
|
||||||
target="_blank"
|
className="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs font-medium text-gray-300"
|
||||||
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"
|
{tag}
|
||||||
>
|
</span>
|
||||||
<span>{new URL(entry.url).hostname.replace(/^www\./, "")}</span>
|
))}
|
||||||
<svg
|
</div>
|
||||||
viewBox="0 0 20 20"
|
) : null}
|
||||||
fill="currentColor"
|
</div>
|
||||||
aria-hidden="true"
|
))}
|
||||||
className="h-4 w-4"
|
</div>
|
||||||
>
|
)}
|
||||||
<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" />
|
</section>
|
||||||
<path d="M11.25 3.5a.75.75 0 0 0 0 1.5h2.69l-4.72 4.72a.75.75 0 1 0 1.06 1.06L15 6.06v2.69a.75.75 0 0 0 1.5 0V3.5h-5.25Z" />
|
);
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-gray-300 leading-relaxed">{entry.summary}</p>
|
|
||||||
|
|
||||||
{entry.tags && entry.tags.length > 0 ? (
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{entry.tags.map((tag) => (
|
|
||||||
<span
|
|
||||||
key={`${entry.company}-${tag}`}
|
|
||||||
className="px-3 py-1 rounded-full text-xs font-semibold bg-cyan-500/20 text-cyan-200 border border-cyan-500/30"
|
|
||||||
>
|
|
||||||
{tag}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
+49
-43
@@ -1,53 +1,59 @@
|
|||||||
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 Skill {
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
purpose: string;
|
|
||||||
what_it_solves: string;
|
|
||||||
link: string;
|
|
||||||
emojis?: string[];
|
|
||||||
tags?: 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 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