Compare commits
5
Commits
277393578a
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2333ecf874 | ||
|
|
e268f2a359 | ||
|
|
fd72e1082a | ||
|
|
85dbdb3db4 | ||
|
|
3ea4946164 |
+38
-16
@@ -1,24 +1,46 @@
|
|||||||
# Logs
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
logs
|
|
||||||
*.log
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
pnpm-debug.log*
|
.pnpm-debug.log*
|
||||||
lerna-debug.log*
|
|
||||||
|
|
||||||
node_modules
|
# env files (can opt-in for committing if needed)
|
||||||
dist
|
.env*
|
||||||
dist-ssr
|
|
||||||
*.local
|
|
||||||
|
|
||||||
# Editor directories and files
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
|
# editors
|
||||||
.vscode/*
|
.vscode/*
|
||||||
!.vscode/extensions.json
|
!.vscode/extensions.json
|
||||||
.idea
|
.idea
|
||||||
.DS_Store
|
|
||||||
*.suo
|
|
||||||
*.ntvs*
|
|
||||||
*.njsproj
|
|
||||||
*.sln
|
|
||||||
*.sw?
|
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<!-- BEGIN:nextjs-agent-rules -->
|
||||||
|
|
||||||
|
# This is NOT the Next.js you know
|
||||||
|
|
||||||
|
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
|
||||||
|
|
||||||
|
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
|
||||||
|
|
||||||
|
<!-- END:nextjs-agent-rules -->
|
||||||
-16
@@ -1,16 +0,0 @@
|
|||||||
# Stage 1: Build the application
|
|
||||||
FROM node:20-alpine AS builder
|
|
||||||
WORKDIR /app
|
|
||||||
COPY package*.json ./
|
|
||||||
RUN npm install
|
|
||||||
COPY . .
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
# Stage 2: Serve the static files with Nginx
|
|
||||||
FROM nginx:alpine
|
|
||||||
# Copy the build files from the builder stage to the Nginx web directory
|
|
||||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
|
||||||
# Expose port 80 where the Nginx server runs
|
|
||||||
EXPOSE 80
|
|
||||||
# Command to start Nginx server
|
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
|
||||||
@@ -1,73 +1,28 @@
|
|||||||
# React + TypeScript + Vite
|
# selimaj.dev
|
||||||
|
|
||||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
Portfolio site. Next.js (App Router, static export) and Tailwind CSS v4.
|
||||||
|
|
||||||
Currently, two official plugins are available:
|
```bash
|
||||||
|
npm install
|
||||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
npm run dev # http://localhost:3000
|
||||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
npm run build # static site in out/
|
||||||
|
npm run lint
|
||||||
## React Compiler
|
|
||||||
|
|
||||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
|
||||||
|
|
||||||
## Expanding the ESLint configuration
|
|
||||||
|
|
||||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
|
||||||
|
|
||||||
```js
|
|
||||||
export default defineConfig([
|
|
||||||
globalIgnores(['dist']),
|
|
||||||
{
|
|
||||||
files: ['**/*.{ts,tsx}'],
|
|
||||||
extends: [
|
|
||||||
// Other configs...
|
|
||||||
|
|
||||||
// Remove tseslint.configs.recommended and replace with this
|
|
||||||
tseslint.configs.recommendedTypeChecked,
|
|
||||||
// Alternatively, use this for stricter rules
|
|
||||||
tseslint.configs.strictTypeChecked,
|
|
||||||
// Optionally, add this for stylistic rules
|
|
||||||
tseslint.configs.stylisticTypeChecked,
|
|
||||||
|
|
||||||
// Other configs...
|
|
||||||
],
|
|
||||||
languageOptions: {
|
|
||||||
parserOptions: {
|
|
||||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
|
||||||
tsconfigRootDir: import.meta.dirname,
|
|
||||||
},
|
|
||||||
// other options...
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
```
|
```
|
||||||
|
|
||||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
`npm run build` writes plain HTML/CSS/JS to `out/`, which any static host can serve.
|
||||||
|
|
||||||
```js
|
## Where things live
|
||||||
// eslint.config.js
|
|
||||||
import reactX from 'eslint-plugin-react-x'
|
|
||||||
import reactDom from 'eslint-plugin-react-dom'
|
|
||||||
|
|
||||||
export default defineConfig([
|
- `src/content/`: all copy and data, including every placeholder (marked `PLACEHOLDER`).
|
||||||
globalIgnores(['dist']),
|
- `profile.ts`: name, title, stance, dossier fields, links, map lock.
|
||||||
{
|
- `dossier.ts`: the five About files.
|
||||||
files: ['**/*.{ts,tsx}'],
|
- `projects.ts`: projects, statuses, node-map positions, featured nodes.
|
||||||
extends: [
|
- `public/profile/Mono.png`: profile picture (the favicon is a copy at `src/app/icon.png`). Replace the file, or point `profile.pfp` at a new one.
|
||||||
// Other configs...
|
- `public/terrain.svg`: hero terrain contours (Dajt elevation data).
|
||||||
// Enable lint rules for React
|
- `src/app/globals.css`: colour tokens, bracket/grid utilities, and all motion.
|
||||||
reactX.configs['recommended-typescript'],
|
|
||||||
// Enable lint rules for React DOM
|
## Rules worth keeping
|
||||||
reactDom.configs.recommended,
|
|
||||||
],
|
- Green (`accent`) means active or detected: hover, focus, current page, live telemetry, the lock. Nothing at rest is green.
|
||||||
languageOptions: {
|
- The map shows Tirana city centre only. Never add anything finer than city scale.
|
||||||
parserOptions: {
|
- All motion sits behind `prefers-reduced-motion: no-preference`. Text renders without JS.
|
||||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
|
||||||
tsconfigRootDir: import.meta.dirname,
|
|
||||||
},
|
|
||||||
// other options...
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
import js from '@eslint/js'
|
|
||||||
import globals from 'globals'
|
|
||||||
import reactHooks from 'eslint-plugin-react-hooks'
|
|
||||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
|
||||||
import tseslint from 'typescript-eslint'
|
|
||||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
|
||||||
|
|
||||||
export default defineConfig([
|
|
||||||
globalIgnores(['dist']),
|
|
||||||
{
|
|
||||||
files: ['**/*.{ts,tsx}'],
|
|
||||||
extends: [
|
|
||||||
js.configs.recommended,
|
|
||||||
tseslint.configs.recommended,
|
|
||||||
reactHooks.configs.flat.recommended,
|
|
||||||
reactRefresh.configs.vite,
|
|
||||||
],
|
|
||||||
languageOptions: {
|
|
||||||
ecmaVersion: 2020,
|
|
||||||
globals: globals.browser,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
|
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||||
|
import nextTs from "eslint-config-next/typescript";
|
||||||
|
|
||||||
|
const eslintConfig = defineConfig([
|
||||||
|
...nextVitals,
|
||||||
|
...nextTs,
|
||||||
|
// Override default ignores of eslint-config-next.
|
||||||
|
globalIgnores([
|
||||||
|
// Default ignores of eslint-config-next:
|
||||||
|
".next/**",
|
||||||
|
"out/**",
|
||||||
|
"build/**",
|
||||||
|
"next-env.d.ts",
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
-13
@@ -1,13 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Selimaj.dev | Klesti Selimaj</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
// Static-first: `next build` emits plain HTML/CSS/JS into `out/`.
|
||||||
|
output: "export",
|
||||||
|
trailingSlash: true,
|
||||||
|
images: { unoptimized: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
Generated
+5233
-8452
File diff suppressed because it is too large
Load Diff
+21
-76
@@ -1,84 +1,29 @@
|
|||||||
{
|
{
|
||||||
"name": "selimaj-portfolio",
|
"name": "selimaj-dev",
|
||||||
"version": "1.0.0",
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"private": true,
|
||||||
"license": "MIT",
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host",
|
"dev": "next dev",
|
||||||
"build": "vite build",
|
"build": "next build",
|
||||||
"preview": "vite preview --host",
|
"start": "next start",
|
||||||
"check": "tsc --noEmit",
|
"lint": "eslint"
|
||||||
"format": "prettier --write ."
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"next": "16.3.8",
|
||||||
"@radix-ui/react-accordion": "^1.2.12",
|
"react": "19.2.8",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
"react-dom": "19.2.8"
|
||||||
"@radix-ui/react-aspect-ratio": "^1.1.7",
|
|
||||||
"@radix-ui/react-avatar": "^1.1.10",
|
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
|
||||||
"@radix-ui/react-context-menu": "^2.2.16",
|
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
|
||||||
"@radix-ui/react-hover-card": "^1.1.15",
|
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
|
||||||
"@radix-ui/react-menubar": "^1.1.16",
|
|
||||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
|
||||||
"@radix-ui/react-popover": "^1.1.15",
|
|
||||||
"@radix-ui/react-progress": "^1.1.7",
|
|
||||||
"@radix-ui/react-radio-group": "^1.3.8",
|
|
||||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
|
||||||
"@radix-ui/react-separator": "^1.1.7",
|
|
||||||
"@radix-ui/react-slider": "^1.3.6",
|
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
|
||||||
"@radix-ui/react-toggle": "^1.1.10",
|
|
||||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
|
||||||
"axios": "^1.12.0",
|
|
||||||
"class-variance-authority": "^0.7.1",
|
|
||||||
"clsx": "^2.1.1",
|
|
||||||
"cmdk": "^1.1.1",
|
|
||||||
"embla-carousel-react": "^8.6.0",
|
|
||||||
"framer-motion": "^12.23.22",
|
|
||||||
"input-otp": "^1.4.2",
|
|
||||||
"lucide-react": "^0.453.0",
|
|
||||||
"nanoid": "^5.1.5",
|
|
||||||
"next-themes": "^0.4.6",
|
|
||||||
"react": "^19.2.1",
|
|
||||||
"react-day-picker": "^9.11.1",
|
|
||||||
"react-dom": "^19.2.1",
|
|
||||||
"react-hook-form": "^7.64.0",
|
|
||||||
"react-resizable-panels": "^3.0.6",
|
|
||||||
"recharts": "^2.15.2",
|
|
||||||
"sonner": "^2.0.7",
|
|
||||||
"streamdown": "^1.4.0",
|
|
||||||
"tailwind-merge": "^3.3.1",
|
|
||||||
"tailwindcss-animate": "^1.0.7",
|
|
||||||
"vaul": "^1.1.2",
|
|
||||||
"wouter": "^3.3.5",
|
|
||||||
"zod": "^4.1.12"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/typography": "^0.5.15",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@tailwindcss/vite": "^4.2.1",
|
"@types/node": "^20",
|
||||||
"@types/node": "^24.7.0",
|
"@types/react": "^19",
|
||||||
"@types/react": "^19.2.1",
|
"@types/react-dom": "^19",
|
||||||
"@types/react-dom": "^19.2.1",
|
"eslint": "^9",
|
||||||
"@vitejs/plugin-react": "^5.0.4",
|
"eslint-config-next": "16.3.8",
|
||||||
"autoprefixer": "^10.4.20",
|
"tailwindcss": "^4",
|
||||||
"pnpm": "^10.15.1",
|
"typescript": "^5"
|
||||||
"postcss": "^8.4.47",
|
|
||||||
"prettier": "^3.6.2",
|
|
||||||
"tailwindcss": "^4.2.1",
|
|
||||||
"tsx": "^4.19.1",
|
|
||||||
"tw-animate-css": "^1.4.0",
|
|
||||||
"typescript": "5.6.3",
|
|
||||||
"vite": "^7.1.7",
|
|
||||||
"vitest": "^2.1.4"
|
|
||||||
},
|
},
|
||||||
"packageManager": "[email protected]"
|
"engines": {
|
||||||
|
"node": "22.x"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 8.8 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 8.8 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 304 KiB |
-32
@@ -1,32 +0,0 @@
|
|||||||
import { Toaster } from "@/components/ui/sonner";
|
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
|
||||||
import NotFound from "@/pages/NotFound";
|
|
||||||
import { Route, Switch } from "wouter";
|
|
||||||
import ErrorBoundary from "./components/ErrorBoundary";
|
|
||||||
import { ThemeProvider } from "./contexts/ThemeContext";
|
|
||||||
import Home from "./pages/Home";
|
|
||||||
|
|
||||||
function Router() {
|
|
||||||
return (
|
|
||||||
<Switch>
|
|
||||||
<Route path={"/"} component={Home} />
|
|
||||||
<Route path={"/404"} component={NotFound} />
|
|
||||||
<Route component={NotFound} />
|
|
||||||
</Switch>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
return (
|
|
||||||
<ErrorBoundary>
|
|
||||||
<ThemeProvider defaultTheme="dark">
|
|
||||||
<TooltipProvider>
|
|
||||||
<Toaster />
|
|
||||||
<Router />
|
|
||||||
</TooltipProvider>
|
|
||||||
</ThemeProvider>
|
|
||||||
</ErrorBoundary>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default App;
|
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { StatusTag } from "@/components/StatusTag";
|
||||||
|
import { dossier, fileBySlug, type Proof } from "@/content/dossier";
|
||||||
|
|
||||||
|
export const dynamicParams = false;
|
||||||
|
|
||||||
|
export function generateStaticParams() {
|
||||||
|
return dossier.map((f) => ({ slug: f.slug }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: PageProps<"/about/[slug]">): Promise<Metadata> {
|
||||||
|
const { slug } = await params;
|
||||||
|
const f = fileBySlug(slug);
|
||||||
|
return f ? { title: f.title, description: f.summary } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProofRow({ p }: { p: Proof }) {
|
||||||
|
const inner = (
|
||||||
|
<>
|
||||||
|
<span>
|
||||||
|
<span className="font-display text-[17px] font-semibold tracking-[0.06em] uppercase">{p.label}</span>
|
||||||
|
<span className="mt-0.5 block text-[11px] text-mute">{p.note}</span>
|
||||||
|
</span>
|
||||||
|
{p.status ? <StatusTag status={p.status} /> : <span />}
|
||||||
|
<span aria-hidden="true" className="text-mute group-hover:text-accent group-focus-visible:text-accent">
|
||||||
|
{p.href ? (p.href.startsWith("http") ? "↗" : "→") : ""}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
const cls = "group bracket grid min-h-14 grid-cols-[minmax(0,1fr)_auto_16px] items-center gap-5 px-3 [--bc:transparent]";
|
||||||
|
if (!p.href) return <div className={cls}>{inner}</div>;
|
||||||
|
const interactive = `${cls} hover:[--bc:var(--color-accent)] focus-visible:outline-none focus-visible:[--bc:var(--color-accent)]`;
|
||||||
|
return p.href.startsWith("http") ? (
|
||||||
|
<a href={p.href} className={interactive}>
|
||||||
|
{inner}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<Link href={p.href} className={interactive}>
|
||||||
|
{inner}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function DossierFilePage({ params }: PageProps<"/about/[slug]">) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const f = fileBySlug(slug);
|
||||||
|
if (!f) notFound();
|
||||||
|
const i = dossier.indexOf(f);
|
||||||
|
const prev = dossier[i - 1];
|
||||||
|
const next = dossier[i + 1];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="pt-[68px] md:pt-[85px]">
|
||||||
|
<div className="mx-auto flex max-w-[1240px] flex-col gap-8 px-4 pt-8 pb-20 md:px-16 md:pt-[72px] md:pb-24 lg:flex-row lg:items-start lg:gap-24">
|
||||||
|
<aside aria-label="Dossier files" className="lg:sticky lg:top-8 lg:w-[220px] lg:flex-none">
|
||||||
|
<Link href="/about/" className="inline-flex min-h-11 items-center gap-2 text-[10px] tracking-[0.22em] text-mute hover:text-accent">
|
||||||
|
<span aria-hidden="true">←</span> DOSSIER INDEX
|
||||||
|
</Link>
|
||||||
|
<ol className="mt-4 flex flex-wrap gap-x-4 text-[11px] tracking-[0.16em] lg:block">
|
||||||
|
{dossier.map((d) => {
|
||||||
|
const current = d.slug === f.slug;
|
||||||
|
return (
|
||||||
|
<li key={d.slug}>
|
||||||
|
<Link
|
||||||
|
href={`/about/${d.slug}/`}
|
||||||
|
aria-current={current ? "page" : undefined}
|
||||||
|
className={`flex min-h-10 items-center gap-3 uppercase ${current ? "text-ink" : "text-mute hover:text-ink"}`}
|
||||||
|
>
|
||||||
|
<span className={`w-6 ${current ? "text-accent" : ""}`}>{d.no}</span>
|
||||||
|
{d.title}
|
||||||
|
{current && <span aria-hidden="true" className="size-1.5 bg-accent" />}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<article className="min-w-0 max-w-[720px] flex-1">
|
||||||
|
<div className="flex flex-wrap gap-5 text-[10px] tracking-[0.24em] text-mute">
|
||||||
|
<span>
|
||||||
|
FILE {f.no} / {String(dossier.length).padStart(2, "0")}
|
||||||
|
</span>
|
||||||
|
<span>CLASS · OPEN</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="mt-3.5 font-display text-[52px] leading-[0.95] font-semibold uppercase md:text-[80px]">{f.title}</h1>
|
||||||
|
<div aria-hidden="true" className="mt-8 h-px bg-white/10" />
|
||||||
|
|
||||||
|
<p className="mt-10 text-xl leading-[1.55] md:text-[22px]">{f.lead}</p>
|
||||||
|
{f.paragraphs.map((t) => (
|
||||||
|
<p key={t} className="mt-5 text-[15px] leading-[1.8] text-ink-2">
|
||||||
|
{t}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<section aria-labelledby="proof-title" className="bracket mt-14 border border-white/6 bg-surface p-6 [--bc:rgb(242_244_245/0.45)]">
|
||||||
|
<h2 id="proof-title" className="text-[10px] font-normal tracking-[0.3em] text-mute">
|
||||||
|
PROOF
|
||||||
|
</h2>
|
||||||
|
<ul className="mt-4 flex flex-col gap-1">
|
||||||
|
{f.proof.map((p) => (
|
||||||
|
<li key={p.label}>
|
||||||
|
<ProofRow p={p} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<nav aria-label="Adjacent files" className="mt-16 flex flex-col justify-between gap-4 border-t border-white/8 pt-6 sm:flex-row">
|
||||||
|
{prev ? (
|
||||||
|
<Link href={`/about/${prev.slug}/`} className="group flex min-h-11 flex-col gap-1.5">
|
||||||
|
<span className="text-[9px] tracking-[0.24em] text-mute">← FILE {prev.no}</span>
|
||||||
|
<span className="font-display text-lg font-semibold tracking-[0.06em] uppercase group-hover:text-accent">{prev.title}</span>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
|
{next && (
|
||||||
|
<Link href={`/about/${next.slug}/`} className="group flex min-h-11 flex-col gap-1.5 sm:text-right">
|
||||||
|
<span className="text-[9px] tracking-[0.24em] text-mute">FILE {next.no} →</span>
|
||||||
|
<span className="font-display text-lg font-semibold tracking-[0.06em] uppercase group-hover:text-accent">{next.title}</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { dossier } from "@/content/dossier";
|
||||||
|
import { profile } from "@/content/profile";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Dossier",
|
||||||
|
description: "Five short files: leadership, entrepreneurship, building, discipline, passions.",
|
||||||
|
};
|
||||||
|
|
||||||
|
const cols = "md:grid-cols-[96px_240px_minmax(0,1fr)_200px_32px] xl:grid-cols-[96px_260px_minmax(0,1fr)_240px_32px]";
|
||||||
|
|
||||||
|
export default function AboutPage() {
|
||||||
|
return (
|
||||||
|
<main className="grid-bg min-h-full pt-[68px] md:pt-[85px]">
|
||||||
|
<div className="mx-auto max-w-[1440px] px-4 pt-10 pb-20 md:px-16 md:pt-[72px] md:pb-24">
|
||||||
|
<div className="flex flex-col justify-between gap-10 md:flex-row md:items-end md:gap-16">
|
||||||
|
<div>
|
||||||
|
<div className="label tracking-[0.3em]">Case file · Subject 0001</div>
|
||||||
|
<h1 className="mt-3 font-display text-[52px] leading-[0.95] font-semibold uppercase md:text-7xl">Dossier</h1>
|
||||||
|
<p className="mt-5 max-w-[520px] text-sm leading-[1.7] text-ink-2">
|
||||||
|
Five files. Each one is short: two or three paragraphs and one piece of proof.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<dl className="bracket grid min-w-[280px] grid-cols-[110px_minmax(0,1fr)] gap-y-2 border border-white/6 bg-surface px-[22px] py-[18px] text-[11px] [--bc:rgb(242_244_245/0.4)]">
|
||||||
|
<dt className="pt-0.5 text-[9px] tracking-[0.22em] text-mute">SUBJECT</dt>
|
||||||
|
<dd>{profile.displayName}</dd>
|
||||||
|
<dt className="pt-0.5 text-[9px] tracking-[0.22em] text-mute">DOMAIN</dt>
|
||||||
|
<dd>selimaj.dev</dd>
|
||||||
|
<dt className="pt-0.5 text-[9px] tracking-[0.22em] text-mute">FILES</dt>
|
||||||
|
<dd>{String(dossier.length).padStart(2, "0")}</dd>
|
||||||
|
<dt className="pt-0.5 text-[9px] tracking-[0.22em] text-mute">ACCESS</dt>
|
||||||
|
<dd>Open</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div aria-hidden="true" className={`mt-16 hidden gap-6 border-b border-white/10 px-5 pb-3 text-[9px] tracking-[0.24em] text-mute md:grid ${cols}`}>
|
||||||
|
<span>ID</span>
|
||||||
|
<span>FILE</span>
|
||||||
|
<span>SUMMARY</span>
|
||||||
|
<span>PROOF</span>
|
||||||
|
<span />
|
||||||
|
</div>
|
||||||
|
<ol className="mt-8 border-t border-white/7 md:mt-0 md:border-t-0">
|
||||||
|
{dossier.map((f) => (
|
||||||
|
<li key={f.slug} className="border-b border-white/7">
|
||||||
|
<Link
|
||||||
|
href={`/about/${f.slug}/`}
|
||||||
|
className={`group bracket grid min-h-[88px] grid-cols-[72px_minmax(0,1fr)_24px] items-center gap-6 px-3 transition-colors [--bc:transparent] hover:bg-white/2 hover:[--bc:var(--color-accent)] focus-visible:outline-none focus-visible:[--bc:var(--color-accent)] md:px-5 ${cols}`}
|
||||||
|
>
|
||||||
|
<span className="text-[11px] tracking-[0.2em] text-mute group-hover:text-accent group-focus-visible:text-accent">FILE {f.no}</span>
|
||||||
|
<span className="font-display text-lg font-semibold tracking-[0.06em] uppercase md:text-[22px]">{f.title}</span>
|
||||||
|
<span className="hidden text-xs leading-[1.6] text-ink-2 md:block">{f.summary}</span>
|
||||||
|
<span className="hidden text-[10px] tracking-[0.18em] text-mute uppercase md:block">{f.proofSummary}</span>
|
||||||
|
<span aria-hidden="true" className="text-mute transition-transform group-hover:translate-x-1 group-hover:text-accent">
|
||||||
|
→
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
<p aria-hidden="true" className="mt-5 text-[9px] tracking-[0.22em] text-mute/70">
|
||||||
|
{String(dossier.length).padStart(2, "0")} FILES · END OF INDEX
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
/* `js:` applies only once the head script has marked <html class="js">. */
|
||||||
|
@custom-variant js (&:where(.js *));
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--color-night: #050607;
|
||||||
|
--color-surface: #0b0d0e;
|
||||||
|
--color-surface-2: #121517;
|
||||||
|
--color-ink: #f2f4f5;
|
||||||
|
--color-ink-2: #c4cacd;
|
||||||
|
--color-mute: #8b9296;
|
||||||
|
/* Green means active or detected. Never decoration. */
|
||||||
|
--color-accent: #4dff95;
|
||||||
|
--color-line: rgb(255 255 255 / 0.07);
|
||||||
|
--color-line-2: rgb(255 255 255 / 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--font-display: var(--font-chakra), ui-sans-serif, system-ui, sans-serif;
|
||||||
|
--font-mono: var(--font-jetbrains), ui-monospace, SFMono-Regular, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
html {
|
||||||
|
background: var(--color-night);
|
||||||
|
color: var(--color-ink);
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
:focus-visible {
|
||||||
|
outline: 1px solid var(--color-accent);
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
::selection {
|
||||||
|
background: rgb(77 255 149 / 0.25);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Corner brackets. Set --bc for colour, --bl for arm length. Uses only the
|
||||||
|
background-image longhands so it composes with bg-* colour utilities. */
|
||||||
|
@utility bracket {
|
||||||
|
--bc: rgb(242 244 245 / 0.28);
|
||||||
|
--bl: 10px;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(var(--bc), var(--bc)), linear-gradient(var(--bc), var(--bc)),
|
||||||
|
linear-gradient(var(--bc), var(--bc)), linear-gradient(var(--bc), var(--bc)),
|
||||||
|
linear-gradient(var(--bc), var(--bc)), linear-gradient(var(--bc), var(--bc)),
|
||||||
|
linear-gradient(var(--bc), var(--bc)), linear-gradient(var(--bc), var(--bc));
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: 0 0, 0 0, 100% 0, 100% 0, 0 100%, 0 100%, 100% 100%, 100% 100%;
|
||||||
|
background-size:
|
||||||
|
var(--bl) 1px, 1px var(--bl), var(--bl) 1px, 1px var(--bl),
|
||||||
|
var(--bl) 1px, 1px var(--bl), var(--bl) 1px, 1px var(--bl);
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility grid-bg {
|
||||||
|
background-image:
|
||||||
|
linear-gradient(rgb(255 255 255 / 0.03) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgb(255 255 255 / 0.03) 1px, transparent 1px);
|
||||||
|
background-size: 48px 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility scan-bg {
|
||||||
|
background-image: repeating-linear-gradient(0deg, rgb(255 255 255 / 0.016) 0 1px, transparent 1px 3px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility label {
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.24em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--color-mute);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Boot overlay (only ever shown when the head script sets data-boot) ---------- */
|
||||||
|
.boot {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
html[data-boot] .boot {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
html[data-boot] body {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
/* Hold the hero sequence until boot finishes; removing data-boot restarts it. */
|
||||||
|
html[data-boot] .seq {
|
||||||
|
animation: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Motion (all of it opt-in; reduced motion gets the final state) ---------- */
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
.seq {
|
||||||
|
animation-delay: var(--d, 0s);
|
||||||
|
animation-fill-mode: both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spin {
|
||||||
|
animation: spin 16s linear infinite;
|
||||||
|
transform-origin: 60px 60px;
|
||||||
|
}
|
||||||
|
.blink {
|
||||||
|
animation: blink 1.1s steps(2) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Text resolves from outline to solid with a short glitch. */
|
||||||
|
.resolve {
|
||||||
|
animation-name: resolve;
|
||||||
|
animation-duration: 0.55s;
|
||||||
|
animation-timing-function: linear;
|
||||||
|
}
|
||||||
|
/* Panels build as a dashed wireframe, then fill. */
|
||||||
|
.panel {
|
||||||
|
animation-name: panel;
|
||||||
|
animation-duration: 0.6s;
|
||||||
|
animation-timing-function: linear;
|
||||||
|
}
|
||||||
|
/* Map strokes draw in (paths need pathLength="1"). */
|
||||||
|
.draw path,
|
||||||
|
.draw circle,
|
||||||
|
.draw rect {
|
||||||
|
stroke-dasharray: 1;
|
||||||
|
animation: draw 0.9s ease-out both;
|
||||||
|
animation-delay: var(--d, 0s);
|
||||||
|
}
|
||||||
|
html[data-boot] .draw path,
|
||||||
|
html[data-boot] .draw circle,
|
||||||
|
html[data-boot] .draw rect {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
.reticle {
|
||||||
|
animation-name: reticle;
|
||||||
|
animation-duration: 0.7s;
|
||||||
|
animation-timing-function: cubic-bezier(0.6, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
.trace {
|
||||||
|
animation-name: trace;
|
||||||
|
animation-duration: 0.3s;
|
||||||
|
animation-timing-function: ease-out;
|
||||||
|
transform-origin: 0 50%;
|
||||||
|
}
|
||||||
|
.appear {
|
||||||
|
animation-name: appear;
|
||||||
|
animation-duration: 0.01s;
|
||||||
|
}
|
||||||
|
.bust-solid {
|
||||||
|
animation-name: bust-solid;
|
||||||
|
animation-duration: 0.8s;
|
||||||
|
animation-timing-function: linear;
|
||||||
|
}
|
||||||
|
.bust-scan {
|
||||||
|
animation-name: bust-scan;
|
||||||
|
animation-duration: 0.8s;
|
||||||
|
animation-timing-function: linear;
|
||||||
|
}
|
||||||
|
.page-resolve {
|
||||||
|
animation: page-resolve 0.45s linear both;
|
||||||
|
}
|
||||||
|
.type-line {
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
animation: type 0.3s steps(24) both;
|
||||||
|
animation-delay: var(--d, 0s);
|
||||||
|
}
|
||||||
|
.boot-out {
|
||||||
|
animation: boot-out 0.25s linear both;
|
||||||
|
}
|
||||||
|
.seg-on {
|
||||||
|
animation: seg-grow 0.45s ease-out both;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.page-resolve {
|
||||||
|
animation: fade 0.2s ease-out both;
|
||||||
|
}
|
||||||
|
.bust-scan {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes blink {
|
||||||
|
50% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes fade {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes appear {
|
||||||
|
from {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes resolve {
|
||||||
|
0% {
|
||||||
|
color: transparent;
|
||||||
|
-webkit-text-stroke: 1px rgb(242 244 245 / 0.45);
|
||||||
|
clip-path: inset(0 100% 0 0);
|
||||||
|
}
|
||||||
|
40% {
|
||||||
|
color: transparent;
|
||||||
|
-webkit-text-stroke: 1px rgb(242 244 245 / 0.45);
|
||||||
|
clip-path: inset(0 0 0 0);
|
||||||
|
}
|
||||||
|
55% {
|
||||||
|
transform: translateX(2px);
|
||||||
|
text-shadow: -2px 0 rgb(255 70 90 / 0.45), 2px 0 rgb(70 150 255 / 0.45);
|
||||||
|
}
|
||||||
|
62% {
|
||||||
|
transform: translateX(-1px);
|
||||||
|
}
|
||||||
|
70% {
|
||||||
|
transform: none;
|
||||||
|
text-shadow: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes panel {
|
||||||
|
0% {
|
||||||
|
clip-path: inset(0 0 100% 0);
|
||||||
|
background-color: transparent;
|
||||||
|
outline: 1px dashed rgb(242 244 245 / 0.35);
|
||||||
|
}
|
||||||
|
45% {
|
||||||
|
clip-path: inset(0 0 0 0);
|
||||||
|
background-color: transparent;
|
||||||
|
outline: 1px dashed rgb(242 244 245 / 0.35);
|
||||||
|
}
|
||||||
|
58% {
|
||||||
|
transform: translateX(2px);
|
||||||
|
filter: drop-shadow(-1px 0 rgb(255 70 90 / 0.5)) drop-shadow(1px 0 rgb(70 150 255 / 0.5));
|
||||||
|
}
|
||||||
|
66% {
|
||||||
|
transform: none;
|
||||||
|
filter: none;
|
||||||
|
outline: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes draw {
|
||||||
|
from {
|
||||||
|
stroke-dashoffset: 1;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
stroke-dashoffset: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes reticle {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate(-90px, 50px) scale(1.4);
|
||||||
|
}
|
||||||
|
10% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
45% {
|
||||||
|
transform: translate(40px, -24px) scale(1.4);
|
||||||
|
}
|
||||||
|
80% {
|
||||||
|
transform: translate(0, 0) scale(1.4);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translate(0, 0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes trace {
|
||||||
|
from {
|
||||||
|
transform: scaleX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes bust-solid {
|
||||||
|
from {
|
||||||
|
clip-path: inset(0 100% 0 0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
clip-path: inset(0 0 0 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes bust-scan {
|
||||||
|
0% {
|
||||||
|
left: 0;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
95% {
|
||||||
|
left: 100%;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
left: 100%;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes page-resolve {
|
||||||
|
0% {
|
||||||
|
clip-path: inset(0 0 100% 0);
|
||||||
|
filter: drop-shadow(-2px 0 rgb(255 70 90 / 0.5)) drop-shadow(2px 0 rgb(70 150 255 / 0.5));
|
||||||
|
}
|
||||||
|
60% {
|
||||||
|
clip-path: inset(0 0 0 0);
|
||||||
|
transform: translateX(2px);
|
||||||
|
}
|
||||||
|
75% {
|
||||||
|
transform: translateX(-1px);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: none;
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes type {
|
||||||
|
from {
|
||||||
|
max-width: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
max-width: 760px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes boot-out {
|
||||||
|
40% {
|
||||||
|
transform: translateX(3px);
|
||||||
|
filter: drop-shadow(-2px 0 rgb(255 70 90 / 0.6)) drop-shadow(2px 0 rgb(70 150 255 / 0.6));
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
clip-path: inset(50% 0 50% 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes seg-grow {
|
||||||
|
from {
|
||||||
|
clip-path: inset(0 100% 0 0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
clip-path: inset(0 0 0 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,41 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Chakra_Petch, JetBrains_Mono } from "next/font/google";
|
||||||
|
import { SiteHeader } from "@/components/SiteHeader";
|
||||||
|
import { SiteFooter } from "@/components/SiteFooter";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
const chakra = Chakra_Petch({
|
||||||
|
variable: "--font-chakra",
|
||||||
|
subsets: ["latin"],
|
||||||
|
weight: ["400", "500", "600", "700"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const jetbrains = JetBrains_Mono({
|
||||||
|
variable: "--font-jetbrains",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: { default: "Selimaj · Entrepreneur & Builder", template: "%s · Selimaj" },
|
||||||
|
description: "Selimaj, entrepreneur and builder. I build the layer underneath.",
|
||||||
|
metadataBase: new URL("https://selimaj.dev"),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Runs before paint: marks JS as available, and arms the boot sequence once
|
||||||
|
// per session on the home page (never with reduced motion).
|
||||||
|
const headScript = `(function(){var d=document.documentElement;d.classList.add('js');try{if(location.pathname==='/'&&!sessionStorage.getItem('boot')&&!matchMedia('(prefers-reduced-motion: reduce)').matches){d.setAttribute('data-boot','')}}catch(e){}})();`;
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: LayoutProps<"/">) {
|
||||||
|
return (
|
||||||
|
<html lang="en" suppressHydrationWarning className={`${chakra.variable} ${jetbrains.variable}`}>
|
||||||
|
<head>
|
||||||
|
<script dangerouslySetInnerHTML={{ __html: headScript }} />
|
||||||
|
</head>
|
||||||
|
<body className="flex min-h-dvh flex-col overflow-x-hidden bg-night text-ink">
|
||||||
|
<SiteHeader />
|
||||||
|
<div className="flex-1">{children}</div>
|
||||||
|
<SiteFooter />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default function NotFound() {
|
||||||
|
return (
|
||||||
|
<main className="grid-bg flex min-h-[70dvh] items-center pt-[85px]">
|
||||||
|
<div className="mx-auto w-full max-w-[1440px] px-4 md:px-16">
|
||||||
|
<div className="label tracking-[0.3em]">404 · Signal lost</div>
|
||||||
|
<h1 className="mt-3 font-display text-[52px] leading-[0.95] font-semibold uppercase md:text-7xl">No trace</h1>
|
||||||
|
<p className="mt-5 text-sm text-ink-2">Nothing resolves at this address.</p>
|
||||||
|
<Link href="/" className="mt-8 inline-flex min-h-12 items-center gap-3 bg-ink px-[22px] text-xs tracking-[0.2em] text-night hover:bg-accent">
|
||||||
|
RETURN TO BASE <span aria-hidden="true">→</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { BootSequence } from "@/components/BootSequence";
|
||||||
|
import { FeaturedNodes } from "@/components/FeaturedNodes";
|
||||||
|
import { Hero } from "@/components/Hero";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<BootSequence />
|
||||||
|
<main>
|
||||||
|
<Hero />
|
||||||
|
<section aria-labelledby="nodes-title" className="mx-auto max-w-[1440px] px-4 pt-16 pb-16 md:px-16 md:pt-24 md:pb-20">
|
||||||
|
<div className="flex flex-wrap items-end justify-between gap-6">
|
||||||
|
<div>
|
||||||
|
<div className="label tracking-[0.3em]">02 / Nodes</div>
|
||||||
|
<h2 id="nodes-title" className="mt-2.5 font-display text-[28px] font-semibold tracking-[0.04em] uppercase md:text-[40px]">
|
||||||
|
Featured
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<Link href="/projects/" className="inline-flex min-h-11 items-center gap-2.5 text-[11px] tracking-[0.2em] text-mute hover:text-accent">
|
||||||
|
ALL PROJECTS <span aria-hidden="true">→</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<FeaturedNodes />
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { ProjectIndex } from "@/components/ProjectIndex";
|
||||||
|
import { projects } from "@/content/projects";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Projects",
|
||||||
|
description: "Everything I’ve built and conceived, from Enclave and Osias to cyberdecks and RC planes.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ProjectsPage() {
|
||||||
|
return (
|
||||||
|
<main className="pt-[68px] md:pt-[85px]">
|
||||||
|
<div className="mx-auto max-w-[1440px] px-4 pt-10 pb-20 md:px-16 md:pt-16 md:pb-24">
|
||||||
|
<div className="label tracking-[0.3em]">Node index · Root selimaj.dev · {projects.length} nodes</div>
|
||||||
|
<h1 className="mt-3 font-display text-[52px] leading-[0.95] font-semibold uppercase md:text-7xl">Projects</h1>
|
||||||
|
<p className="mt-[18px] max-w-[560px] text-[13px] leading-[1.7] text-ink-2">
|
||||||
|
Most of what I've built and conceived. Concepts are labelled as concepts.
|
||||||
|
</p>
|
||||||
|
<ProjectIndex />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
|
// Re-mounts on every navigation, so each page resolves in from wireframe.
|
||||||
|
// Home is skipped: the hero runs its own sequence and the terrain stays still.
|
||||||
|
export default function Template({ children }: { children: React.ReactNode }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
if (pathname === "/") return <>{children}</>;
|
||||||
|
return <div className="page-resolve seq">{children}</div>;
|
||||||
|
}
|
||||||
@@ -1,198 +0,0 @@
|
|||||||
/*
|
|
||||||
* ABOUT SECTION — selimaj.dev
|
|
||||||
* Design: Glassmorphic Terminal
|
|
||||||
* Asymmetric layout: text left, abstract visual right
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { EarthIcon, LayersIcon, TargetIcon, ZapIcon } from "lucide-react";
|
|
||||||
import { useEffect, useRef } from "react";
|
|
||||||
|
|
||||||
const ABOUT_VISUAL =
|
|
||||||
"/logo.png";
|
|
||||||
|
|
||||||
function useReveal() {
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
useEffect(() => {
|
|
||||||
const el = ref.current;
|
|
||||||
if (!el) return;
|
|
||||||
const observer = new IntersectionObserver(
|
|
||||||
([entry]) => {
|
|
||||||
if (entry.isIntersecting) {
|
|
||||||
el.classList.add("visible");
|
|
||||||
observer.disconnect();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ threshold: 0.15 },
|
|
||||||
);
|
|
||||||
observer.observe(el);
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, []);
|
|
||||||
return ref;
|
|
||||||
}
|
|
||||||
|
|
||||||
const highlights = [
|
|
||||||
{ Icon: ZapIcon, label: "Currently", value: "Grinding on SaaS products" },
|
|
||||||
{ Icon: LayersIcon, label: "Fav Stack", value: "Rust · TypeScript · Linux" },
|
|
||||||
{ Icon: TargetIcon, label: "Focus", value: "Low-level systems & UI/UX" },
|
|
||||||
{ Icon: EarthIcon, label: "Based in", value: "Albania" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function AboutSection() {
|
|
||||||
const ref1 = useReveal();
|
|
||||||
const ref2 = useReveal();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
id="about"
|
|
||||||
className="py-32 relative"
|
|
||||||
style={{ background: "#080808" }}
|
|
||||||
>
|
|
||||||
{/* Subtle top border */}
|
|
||||||
<div className="absolute top-0 left-0 right-0 h-px bg-linear-to-r from-transparent via-white/10 to-transparent" />
|
|
||||||
|
|
||||||
<div className="max-w-6xl mx-auto px-6">
|
|
||||||
{/* Section label */}
|
|
||||||
<div className="terminal-label mb-4">> about.me</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-16 items-center">
|
|
||||||
{/* Left: Text */}
|
|
||||||
<div ref={ref1} className="reveal">
|
|
||||||
<h2 className="section-heading text-4xl sm:text-5xl text-white mb-8 leading-tight">
|
|
||||||
Building the future, <span className="text-white/40">one commit at a time.</span>
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="space-y-4 text-white/60 leading-relaxed"
|
|
||||||
style={{ fontFamily: "'DM Sans', sans-serif" }}
|
|
||||||
>
|
|
||||||
<p>
|
|
||||||
I'm{" "}
|
|
||||||
<strong className="text-white font-semibold">
|
|
||||||
Klesti Selimaj
|
|
||||||
</strong>{" "}
|
|
||||||
— a 16-year-old software developer and entrepreneur from Albania
|
|
||||||
with 6 years of hands-on experience. I started coding at 10 and
|
|
||||||
never stopped.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
My passion lies in{" "}
|
|
||||||
<strong className="text-white/80">
|
|
||||||
low-level systems programming
|
|
||||||
</strong>
|
|
||||||
, building developer tools, and crafting polished user
|
|
||||||
experiences. I work primarily in{" "}
|
|
||||||
<strong className="text-white/80">Rust</strong> and{" "}
|
|
||||||
<strong className="text-white/80">TypeScript</strong>, and I'm
|
|
||||||
deeply interested in hacking, Linux internals, and TUI
|
|
||||||
applications.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Beyond code, I'm building towards a successful SaaS product —
|
|
||||||
combining my technical depth with an entrepreneurial mindset to
|
|
||||||
create tools that developers actually want to use.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Highlights grid */}
|
|
||||||
<div className="mt-10 grid grid-cols-2 gap-3">
|
|
||||||
{highlights.map((h) => (
|
|
||||||
<div key={h.label} className="glass rounded-xl p-4 card-hover">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="text-lg">{<h.Icon />}</div>
|
|
||||||
<div>
|
|
||||||
<div className="terminal-label text-xs mb-0.5">
|
|
||||||
{h.label}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="text-sm text-white/80 font-medium"
|
|
||||||
style={{ fontFamily: "'DM Sans', sans-serif" }}
|
|
||||||
>
|
|
||||||
{h.value}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Social links */}
|
|
||||||
<div className="mt-8 flex flex-wrap gap-3">
|
|
||||||
{[
|
|
||||||
{
|
|
||||||
label: "GitHub",
|
|
||||||
href: "https://github.com/selimaj-dev",
|
|
||||||
icon: "⌥",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "YouTube",
|
|
||||||
href: "https://youtube.com/@SelimajDev",
|
|
||||||
icon: "▶",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Instagram",
|
|
||||||
href: "https://instagram.com/selimaj.dev",
|
|
||||||
icon: "◈",
|
|
||||||
},
|
|
||||||
].map((s) => (
|
|
||||||
<a
|
|
||||||
key={s.label}
|
|
||||||
href={s.href}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="skill-chip hover:no-underline"
|
|
||||||
>
|
|
||||||
<span className="text-white/40">{s.icon}</span>
|
|
||||||
{s.label}
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right: Visual */}
|
|
||||||
<div ref={ref2} className="reveal flex justify-center lg:justify-end">
|
|
||||||
<div className="relative w-full max-w-md">
|
|
||||||
{/* Glow behind image */}
|
|
||||||
<div
|
|
||||||
className="absolute inset-0 rounded-2xl blur-3xl opacity-20"
|
|
||||||
style={{
|
|
||||||
background:
|
|
||||||
"radial-gradient(circle, rgba(255,255,255,0.15) 0%, transparent 70%)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="relative glass rounded-2xl overflow-hidden float-anim">
|
|
||||||
<img
|
|
||||||
src={ABOUT_VISUAL}
|
|
||||||
alt="Abstract tech visualization"
|
|
||||||
className="w-full h-auto object-cover"
|
|
||||||
style={{ aspectRatio: "1/1" }}
|
|
||||||
/>
|
|
||||||
{/* Overlay with terminal text */}
|
|
||||||
<div
|
|
||||||
className="absolute bottom-0 left-0 right-0 p-5"
|
|
||||||
style={{
|
|
||||||
background:
|
|
||||||
"linear-gradient(to top, rgba(8,8,8,0.9) 0%, transparent 100%)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="terminal-label text-xs">
|
|
||||||
<span className="text-white/30">$</span>{" "}
|
|
||||||
<span className="text-white/60">whoami</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="text-white/80 text-sm mt-1"
|
|
||||||
style={{ fontFamily: "'JetBrains Mono', monospace" }}
|
|
||||||
>
|
|
||||||
Klesti Selimaj
|
|
||||||
</div>
|
|
||||||
<div className="terminal-label text-xs mt-0.5">
|
|
||||||
uid=1000(klesti) groups=ORUS,Axiom,Saturn,Nexa
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useSession } from "./Telemetry";
|
||||||
|
|
||||||
|
const DURATION = 1900;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Once-per-session boot screen on the home page. It only shows when the head
|
||||||
|
* script has set <html data-boot>, so without JS (or with reduced motion) it
|
||||||
|
* never appears. Click, tap or any key skips it.
|
||||||
|
*/
|
||||||
|
export function BootSequence() {
|
||||||
|
const s = useSession();
|
||||||
|
const [leaving, setLeaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = document.documentElement;
|
||||||
|
if (!root.hasAttribute("data-boot")) return;
|
||||||
|
|
||||||
|
let done = false;
|
||||||
|
let outTimer: number | undefined;
|
||||||
|
const finish = () => {
|
||||||
|
if (done) return;
|
||||||
|
done = true;
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem("boot", "1");
|
||||||
|
} catch {}
|
||||||
|
setLeaving(true);
|
||||||
|
outTimer = window.setTimeout(() => {
|
||||||
|
root.removeAttribute("data-boot");
|
||||||
|
window.dispatchEvent(new Event("boot:done"));
|
||||||
|
}, 250);
|
||||||
|
};
|
||||||
|
const timer = window.setTimeout(finish, DURATION);
|
||||||
|
window.addEventListener("keydown", finish);
|
||||||
|
window.addEventListener("pointerdown", finish);
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
clearTimeout(outTimer);
|
||||||
|
window.removeEventListener("keydown", finish);
|
||||||
|
window.removeEventListener("pointerdown", finish);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const stamp = s ? new Date(s.start).toISOString() : "";
|
||||||
|
const lines = [
|
||||||
|
{ text: "SESSION OPEN", tail: "[ OK ]" },
|
||||||
|
{ text: stamp },
|
||||||
|
{ text: s ? `HASH ${s.hash}` : "HASH" },
|
||||||
|
{ text: "SIGNAL ACQUIRED · TRACE ▸ TIRANA, AL" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
aria-label="Loading"
|
||||||
|
className={`boot fixed inset-0 z-50 flex-col justify-end bg-night ${leaving ? "boot-out" : ""}`}
|
||||||
|
>
|
||||||
|
<div aria-hidden="true" className="grid-bg absolute inset-0" />
|
||||||
|
<div aria-hidden="true" className="scan-bg absolute inset-0" />
|
||||||
|
<div className="relative px-6 pb-[18vh] text-[13px] leading-[2.1] tracking-[0.08em] md:px-24 md:text-sm">
|
||||||
|
{lines.map((l, i) => (
|
||||||
|
<div key={i} className="type-line flex gap-[18px] text-ink-2 first:text-ink" style={{ "--d": `${0.1 + i * 0.3}s` } as React.CSSProperties}>
|
||||||
|
<span className="text-mute">></span>
|
||||||
|
{l.text}
|
||||||
|
{l.tail && <span className="text-mute">{l.tail}</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="type-line flex gap-[18px] text-accent" style={{ "--d": "1.3s" } as React.CSSProperties}>
|
||||||
|
<span className="text-mute">></span>LOCKING<span className="blink">▌</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-10 w-full max-w-[420px]">
|
||||||
|
<div className="flex justify-between text-[9px] tracking-[0.22em] text-mute">
|
||||||
|
<span>TRACE</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 h-px bg-white/12">
|
||||||
|
<div className="h-px origin-left bg-accent shadow-[0_0_8px_var(--color-accent)] [animation:trace_1.8s_ease-out_both]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="absolute right-6 bottom-8 text-[10px] tracking-[0.24em] text-mute md:right-16">CLICK OR PRESS ANY KEY TO SKIP</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
|
import { useRef } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 2.5D identity reveal. The picture first shows as a high-contrast scanline
|
||||||
|
* "wireframe", a scan sweeps across and resolves it to solid, and on desktop
|
||||||
|
* the layers tilt apart slightly with the pointer. No 3D library needed.
|
||||||
|
*/
|
||||||
|
export function Bust({
|
||||||
|
src,
|
||||||
|
delay = "0s",
|
||||||
|
className = "",
|
||||||
|
}: {
|
||||||
|
src: string;
|
||||||
|
delay?: string;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const tiltRef = useRef<HTMLDivElement>(null);
|
||||||
|
const meshRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
function onMove(e: React.PointerEvent<HTMLDivElement>) {
|
||||||
|
if (
|
||||||
|
e.pointerType !== "mouse" ||
|
||||||
|
matchMedia("(prefers-reduced-motion: reduce)").matches
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
const r = e.currentTarget.getBoundingClientRect();
|
||||||
|
const x = (e.clientX - r.left) / r.width - 0.5;
|
||||||
|
const y = (e.clientY - r.top) / r.height - 0.5;
|
||||||
|
if (tiltRef.current)
|
||||||
|
tiltRef.current.style.transform = `rotateX(${(-y * 8).toFixed(2)}deg) rotateY(${(x * 10).toFixed(2)}deg)`;
|
||||||
|
if (meshRef.current)
|
||||||
|
meshRef.current.style.transform = `translate(${(x * -6).toFixed(1)}px, ${(y * -6).toFixed(1)}px)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLeave() {
|
||||||
|
if (tiltRef.current) tiltRef.current.style.transform = "";
|
||||||
|
if (meshRef.current) meshRef.current.style.transform = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`relative aspect-square [perspective:600px] ${className}`}
|
||||||
|
onPointerMove={onMove}
|
||||||
|
onPointerLeave={onLeave}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={tiltRef}
|
||||||
|
className="absolute inset-0 transition-transform duration-200 ease-out [transform-style:preserve-3d]"
|
||||||
|
>
|
||||||
|
{/* wireframe pass */}
|
||||||
|
<div className="absolute inset-0 [mask-image:repeating-linear-gradient(0deg,#000_0_1px,transparent_1px_5px)]">
|
||||||
|
<Image
|
||||||
|
src={src}
|
||||||
|
alt=""
|
||||||
|
fill
|
||||||
|
sizes="168px"
|
||||||
|
className="object-cover opacity-80 [filter:grayscale(1)_contrast(2.4)_brightness(1.6)_drop-shadow(1px_0_0_rgb(255_70_90/0.35))_drop-shadow(-1px_0_0_rgb(70_150_255/0.35))]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* solid pass */}
|
||||||
|
<div
|
||||||
|
className="bust-solid seq absolute inset-0"
|
||||||
|
style={{ "--d": delay } as React.CSSProperties}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={src}
|
||||||
|
alt="Profile picture"
|
||||||
|
fill
|
||||||
|
sizes="168px"
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* residual mesh that floats a few px above the photo */}
|
||||||
|
{/*<div
|
||||||
|
ref={meshRef}
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute inset-0 opacity-[0.12] transition-transform duration-200 ease-out [background-image:repeating-linear-gradient(0deg,#f2f4f5_0_1px,transparent_1px_8px)] [mask-image:linear-gradient(90deg,#000,transparent_70%)]"
|
||||||
|
/>*/}
|
||||||
|
{/* scan line */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="bust-scan seq absolute top-0 bottom-0 left-full w-px bg-accent opacity-0 shadow-[0_0_8px_var(--color-accent)]"
|
||||||
|
style={{ "--d": delay } as React.CSSProperties}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
/*
|
|
||||||
* CONTACT SECTION — selimaj.dev
|
|
||||||
* Design: Glassmorphic Terminal
|
|
||||||
* Clean contact links with glass cards
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
|
||||||
import { Github, Youtube, Instagram, ExternalLink, Mail, MailIcon } from "lucide-react";
|
|
||||||
|
|
||||||
function useReveal(threshold = 0.1) {
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
useEffect(() => {
|
|
||||||
const el = ref.current;
|
|
||||||
if (!el) return;
|
|
||||||
const observer = new IntersectionObserver(
|
|
||||||
([entry]) => {
|
|
||||||
if (entry.isIntersecting) {
|
|
||||||
el.classList.add("visible");
|
|
||||||
observer.disconnect();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ threshold }
|
|
||||||
);
|
|
||||||
observer.observe(el);
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, [threshold]);
|
|
||||||
return ref;
|
|
||||||
}
|
|
||||||
|
|
||||||
const contactLinks = [
|
|
||||||
{
|
|
||||||
icon: Github,
|
|
||||||
label: "GitHub",
|
|
||||||
handle: "@selimaj-dev",
|
|
||||||
href: "https://github.com/selimaj-dev",
|
|
||||||
description: "97+ repositories · 2,670 contributions/year",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: Youtube,
|
|
||||||
label: "YouTube",
|
|
||||||
handle: "@SelimajDev",
|
|
||||||
href: "https://youtube.com/@SelimajDev",
|
|
||||||
description: "Dev content, tutorials, and builds",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: Instagram,
|
|
||||||
label: "Instagram",
|
|
||||||
handle: "@selimaj.dev",
|
|
||||||
href: "https://instagram.com/selimaj.dev",
|
|
||||||
description: "Behind the scenes and updates",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: MailIcon,
|
|
||||||
label: "Email",
|
|
||||||
handle: "[email protected]",
|
|
||||||
href: "mailto:[email protected]",
|
|
||||||
description: "For business inquiries, collaborations, and contact",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function ContactSection() {
|
|
||||||
const headerRef = useReveal();
|
|
||||||
const contentRef = useReveal(0.1);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section id="contact" className="py-32 relative" style={{ background: "#080808" }}>
|
|
||||||
<div className="absolute top-0 left-0 right-0 h-px bg-linear-to-r from-transparent via-white/10 to-transparent" />
|
|
||||||
|
|
||||||
{/* Subtle radial glow */}
|
|
||||||
<div
|
|
||||||
className="absolute inset-0 pointer-events-none"
|
|
||||||
style={{
|
|
||||||
background: "radial-gradient(ellipse 60% 40% at 50% 100%, rgba(255,255,255,0.03) 0%, transparent 70%)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="max-w-6xl mx-auto px-6">
|
|
||||||
{/* Header */}
|
|
||||||
<div ref={headerRef} className="reveal mb-16 text-center">
|
|
||||||
<div className="terminal-label mb-4 text-center">> contact --open</div>
|
|
||||||
<h2 className="section-heading text-4xl sm:text-5xl text-white leading-tight mb-6">
|
|
||||||
Let's Build
|
|
||||||
<br />
|
|
||||||
<span className="text-white/40">Something Together</span>
|
|
||||||
</h2>
|
|
||||||
<p
|
|
||||||
className="text-white/50 max-w-xl mx-auto text-base leading-relaxed"
|
|
||||||
style={{ fontFamily: "'DM Sans', sans-serif" }}
|
|
||||||
>
|
|
||||||
I'm always open to interesting projects, collaborations, or just a good conversation
|
|
||||||
about tech. Reach out through any of the channels below.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Contact cards */}
|
|
||||||
<div ref={contentRef} className="reveal">
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-12">
|
|
||||||
{contactLinks.map((link) => {
|
|
||||||
const Icon = link.icon;
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
key={link.label}
|
|
||||||
href={link.href}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="glass rounded-2xl p-6 card-hover group no-underline flex flex-col gap-4"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="w-10 h-10 glass-strong rounded-xl flex items-center justify-center">
|
|
||||||
<Icon size={18} className="text-white/70 group-hover:text-white transition-colors" />
|
|
||||||
</div>
|
|
||||||
<ExternalLink size={12} className="text-white/20 group-hover:text-white/50 transition-colors" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
className="text-white font-semibold text-sm mb-0.5"
|
|
||||||
style={{ fontFamily: "'Syne', sans-serif" }}
|
|
||||||
>
|
|
||||||
{link.label}
|
|
||||||
</div>
|
|
||||||
<div className="terminal-label text-xs mb-2">{link.handle}</div>
|
|
||||||
<div
|
|
||||||
className="text-white/40 text-xs leading-relaxed"
|
|
||||||
style={{ fontFamily: "'DM Sans', sans-serif" }}
|
|
||||||
>
|
|
||||||
{link.description}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Big CTA */}
|
|
||||||
<div className="glass rounded-2xl p-10 text-center">
|
|
||||||
<div className="terminal-label mb-4">> open_to_work --status</div>
|
|
||||||
<h3
|
|
||||||
className="text-2xl sm:text-3xl font-bold text-white mb-4"
|
|
||||||
style={{ fontFamily: "'Syne', sans-serif" }}
|
|
||||||
>
|
|
||||||
Open to freelance & collaboration
|
|
||||||
</h3>
|
|
||||||
<p
|
|
||||||
className="text-white/50 mb-8 max-w-lg mx-auto text-sm leading-relaxed"
|
|
||||||
style={{ fontFamily: "'DM Sans', sans-serif" }}
|
|
||||||
>
|
|
||||||
Looking for interesting projects, SaaS co-founders, or clients who need
|
|
||||||
a developer who ships fast and cares about quality.
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
|
|
||||||
<a
|
|
||||||
href="https://github.com/selimaj-dev"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="px-8 py-3.5 bg-white text-black font-semibold rounded-xl hover:bg-white/90 transition-all duration-200 text-sm no-underline"
|
|
||||||
style={{ fontFamily: "'DM Sans', sans-serif" }}
|
|
||||||
>
|
|
||||||
View GitHub Profile
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="https://instagram.com/selimaj.dev"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="px-8 py-3.5 glass border border-white/10 text-white/70 font-medium rounded-xl hover:text-white hover:border-white/20 transition-all duration-200 text-sm no-underline"
|
|
||||||
style={{ fontFamily: "'DM Sans', sans-serif" }}
|
|
||||||
>
|
|
||||||
DM on Instagram
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { AlertTriangle, RotateCcw } from "lucide-react";
|
|
||||||
import { Component, type ReactNode } from "react";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface State {
|
|
||||||
hasError: boolean;
|
|
||||||
error: Error | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
class ErrorBoundary extends Component<Props, State> {
|
|
||||||
constructor(props: Props) {
|
|
||||||
super(props);
|
|
||||||
this.state = { hasError: false, error: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
static getDerivedStateFromError(error: Error): State {
|
|
||||||
return { hasError: true, error };
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
if (this.state.hasError) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center min-h-screen p-8 bg-background">
|
|
||||||
<div className="flex flex-col items-center w-full max-w-2xl p-8">
|
|
||||||
<AlertTriangle
|
|
||||||
size={48}
|
|
||||||
className="text-destructive mb-6 shrink-0"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<h2 className="text-xl mb-4">An unexpected error occurred.</h2>
|
|
||||||
|
|
||||||
<div className="p-4 w-full rounded bg-muted overflow-auto mb-6">
|
|
||||||
<pre className="text-sm text-muted-foreground whitespace-break-spaces">
|
|
||||||
{this.state.error?.stack}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => window.location.reload()}
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-2 px-4 py-2 rounded-lg",
|
|
||||||
"bg-primary text-primary-foreground",
|
|
||||||
"hover:opacity-90 cursor-pointer"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<RotateCcw size={16} />
|
|
||||||
Reload Page
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.props.children;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ErrorBoundary;
|
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { brandOf, featured, featuredRoot, projectById, projects, root } from "@/content/projects";
|
||||||
|
import { Segment } from "./Segment";
|
||||||
|
|
||||||
|
export function FeaturedNodes() {
|
||||||
|
const [active, setActive] = useState<string | null>(null);
|
||||||
|
const nodes = featured.map((f) => ({ ...f, project: projectById(f.id)! }));
|
||||||
|
const stageRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [size, setSize] = useState<{ w: number; h: number } | null>(null);
|
||||||
|
|
||||||
|
// Lines are drawn in px so they stay 1px crisp at any stage size.
|
||||||
|
useEffect(() => {
|
||||||
|
const el = stageRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const ro = new ResizeObserver(([e]) => setSize({ w: e.contentRect.width, h: e.contentRect.height }));
|
||||||
|
ro.observe(el);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={stageRef}
|
||||||
|
className="bracket grid-bg relative mt-8 flex flex-col gap-1 border border-white/6 bg-surface p-6 [--bc:rgb(242_244_245/0.35)] md:block md:h-[460px] md:p-0"
|
||||||
|
onMouseLeave={() => setActive(null)}
|
||||||
|
>
|
||||||
|
{size && (
|
||||||
|
<div aria-hidden="true" className="hidden md:block">
|
||||||
|
{nodes.map((n) => (
|
||||||
|
<Segment
|
||||||
|
key={`${n.id}-${active === n.id}`}
|
||||||
|
x1={(featuredRoot.x / 100) * size.w}
|
||||||
|
y1={(featuredRoot.y / 100) * size.h}
|
||||||
|
x2={(n.x / 100) * size.w}
|
||||||
|
y2={(n.y / 100) * size.h}
|
||||||
|
on={active === n.id}
|
||||||
|
dashed
|
||||||
|
strong
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Root: the site itself, labelled to the left so no line crosses it. */}
|
||||||
|
<div
|
||||||
|
className="relative min-h-12 md:absolute md:top-(--y) md:left-(--x) md:min-h-0"
|
||||||
|
style={{ "--x": `${featuredRoot.x}%`, "--y": `${featuredRoot.y}%` } as React.CSSProperties}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute top-2.5 left-0 box-border size-7 rounded-full border border-ink/70 bg-night md:-top-3.5 md:-left-3.5"
|
||||||
|
>
|
||||||
|
<span className="absolute top-[9px] left-[9px] size-2 rounded-full bg-ink" />
|
||||||
|
</span>
|
||||||
|
<span className="ml-10 block px-3 py-2.5 md:absolute md:-top-[22px] md:right-6 md:ml-0 md:w-max md:text-right">
|
||||||
|
<span className="flex items-baseline gap-3 md:flex-row-reverse">
|
||||||
|
<span className="font-display text-lg font-semibold tracking-[0.08em] uppercase">{root.name}</span>
|
||||||
|
<span className="text-[9px] tracking-[0.22em] text-mute uppercase">Root</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{nodes.map((n) => {
|
||||||
|
const on = active === n.id;
|
||||||
|
const brand = brandOf(n.project);
|
||||||
|
const meta = n.id === "orus" ? "BRAND" : brand ? `${n.project.status} · ${brand.toUpperCase()}` : n.project.status;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={n.id}
|
||||||
|
href={`/projects/#${n.id}`}
|
||||||
|
onMouseEnter={() => setActive(n.id)}
|
||||||
|
onFocus={() => setActive(n.id)}
|
||||||
|
onBlur={() => setActive(null)}
|
||||||
|
className="group relative block min-h-12 outline-none md:absolute md:top-(--y) md:left-(--x) md:min-h-0"
|
||||||
|
style={{ "--x": `${n.x}%`, "--y": `${n.y}%` } as React.CSSProperties}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={`absolute top-4 left-2 box-border size-3 border md:-top-1.5 md:-left-1.5 ${n.id === "orus" ? "rotate-45" : ""} ${
|
||||||
|
on ? "border-accent bg-accent shadow-[0_0_14px_var(--color-accent)]" : "border-ink/60 bg-night"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={`bracket ml-10 block min-h-11 px-3 py-2.5 group-focus-visible:outline group-focus-visible:outline-accent md:absolute md:-top-[22px] md:left-6 md:ml-0 md:w-[300px] ${
|
||||||
|
on ? "[--bc:var(--color-accent)]" : "[--bc:transparent]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="flex items-baseline gap-3">
|
||||||
|
<span className="font-display text-lg font-semibold tracking-[0.08em] uppercase">{n.project.name}</span>
|
||||||
|
<span className="text-[9px] tracking-[0.22em] text-mute">{meta}</span>
|
||||||
|
</span>
|
||||||
|
<span className={`mt-1.5 block text-[11px] leading-[1.55] text-ink-2 ${on ? "" : "md:sr-only"}`}>{n.project.desc}</span>
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<div aria-hidden="true" className="absolute right-4 bottom-3 hidden text-[9px] tracking-[0.2em] text-mute/70 uppercase md:block">
|
||||||
|
Root {root.sub} · {featured.length} of {projects.length} nodes
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
/*
|
|
||||||
* FOOTER — selimaj.dev
|
|
||||||
* Design: Glassmorphic Terminal
|
|
||||||
* Minimal footer with branding and links
|
|
||||||
*/
|
|
||||||
|
|
||||||
export default function Footer() {
|
|
||||||
const year = new Date().getFullYear();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<footer
|
|
||||||
className="py-10 relative"
|
|
||||||
style={{
|
|
||||||
background: "#080808",
|
|
||||||
borderTop: "1px solid rgba(255,255,255,0.06)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="max-w-6xl mx-auto px-6">
|
|
||||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
|
|
||||||
{/* Brand */}
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span
|
|
||||||
className="text-white font-bold text-base tracking-tight"
|
|
||||||
style={{ fontFamily: "'Syne', sans-serif" }}
|
|
||||||
>
|
|
||||||
selimaj<span className="text-white/30">.dev</span>
|
|
||||||
</span>
|
|
||||||
<span className="text-white/20 text-xs">·</span>
|
|
||||||
<span className="terminal-label text-xs">Klesti Selimaj</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Links */}
|
|
||||||
<div className="flex items-center gap-6">
|
|
||||||
{[
|
|
||||||
{ label: "GitHub", href: "https://github.com/selimaj-dev" },
|
|
||||||
{ label: "YouTube", href: "https://youtube.com/@SelimajDev" },
|
|
||||||
{ label: "Instagram", href: "https://instagram.com/selimaj.dev" },
|
|
||||||
].map((link) => (
|
|
||||||
<a
|
|
||||||
key={link.label}
|
|
||||||
href={link.href}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="terminal-label text-xs hover:text-white/70 transition-colors"
|
|
||||||
>
|
|
||||||
{link.label}
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Copyright */}
|
|
||||||
<div className="terminal-label text-xs">
|
|
||||||
© {year} selimaj.dev
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
dossierFields,
|
||||||
|
lock,
|
||||||
|
profile,
|
||||||
|
type DossierField,
|
||||||
|
} from "@/content/profile";
|
||||||
|
import { Bust } from "./Bust";
|
||||||
|
import { Scramble } from "./Scramble";
|
||||||
|
import { LocalTime, SessionStamp } from "./Telemetry";
|
||||||
|
import { TerrainLayer } from "./TerrainLayer";
|
||||||
|
import { LockReticle, TiranaMap } from "./TiranaMap";
|
||||||
|
|
||||||
|
const d = (s: number) => ({ "--d": `${s}s` }) as React.CSSProperties;
|
||||||
|
|
||||||
|
/** Dossier values decode one after another, starting once the card has built. */
|
||||||
|
const DOSSIER_START = 1.35;
|
||||||
|
const DOSSIER_STAGGER = 0.14;
|
||||||
|
|
||||||
|
function DossierList({
|
||||||
|
fields,
|
||||||
|
className = "",
|
||||||
|
}: {
|
||||||
|
fields: DossierField[];
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<dl
|
||||||
|
className={`grid grid-cols-[124px_minmax(0,1fr)] gap-y-2.5 border-t border-white/8 pt-[18px] text-xs leading-[1.45] xl:grid-cols-[116px_minmax(0,1fr)] ${className}`}
|
||||||
|
>
|
||||||
|
{fields.map((f, i) => {
|
||||||
|
const delay = DOSSIER_START + i * DOSSIER_STAGGER;
|
||||||
|
return (
|
||||||
|
<div key={f.label} className="contents">
|
||||||
|
<dt className="pt-[3px] text-[9px] uppercase tracking-[0.22em] text-mute">
|
||||||
|
{f.label}
|
||||||
|
</dt>
|
||||||
|
<dd className="flex flex-wrap gap-x-3.5">
|
||||||
|
{f.links ? (
|
||||||
|
f.links.map((l, j) => (
|
||||||
|
<a
|
||||||
|
key={l.href}
|
||||||
|
href={l.href}
|
||||||
|
className="border-b border-white/25 hover:border-accent"
|
||||||
|
>
|
||||||
|
<Scramble
|
||||||
|
text={l.label}
|
||||||
|
delay={delay + j * 0.2}
|
||||||
|
step={30}
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Scramble text={f.value} delay={delay} step={30} />
|
||||||
|
)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</dl>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Hero() {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
aria-labelledby="hero-title"
|
||||||
|
className="relative overflow-hidden border-b border-line xl:flex xl:min-h-dvh xl:items-center"
|
||||||
|
>
|
||||||
|
<TerrainLayer />
|
||||||
|
|
||||||
|
<div className="relative z-10 mx-auto w-full max-w-[1440px] pt-[68px] md:pt-[84px] xl:h-[864px]">
|
||||||
|
{/* Lock assembly: a strip on small screens, the full stage on desktop. */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="relative h-[240px] overflow-hidden border-b border-line [--lx:50%] xl:pointer-events-none xl:[--lx:45%] 2xl:[--lx:50%] xl:absolute xl:inset-0 xl:h-auto xl:overflow-visible xl:border-0"
|
||||||
|
>
|
||||||
|
<TiranaMap className="absolute top-[calc(50%-320px)] left-[calc(var(--lx)-320px)] size-[640px] xl:top-[-86px]" />
|
||||||
|
<LockReticle
|
||||||
|
className="reticle seq absolute top-[calc(50%-60px)] left-[calc(var(--lx)-60px)] max-xl:scale-75 xl:top-[174px]"
|
||||||
|
style={d(0.25)}
|
||||||
|
/>
|
||||||
|
<div className="absolute bottom-3 left-4 text-[9px] leading-[1.7] tracking-[0.14em] text-mute xl:bottom-auto xl:left-[calc(var(--lx)-60px)] xl:top-[306px] xl:text-[10px]">
|
||||||
|
<div className="text-accent">
|
||||||
|
<Scramble text="LOCK ACQUIRED" delay={0.85} step={35} nowrap />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Scramble
|
||||||
|
text={`${lock.lat} · ${lock.lon}`}
|
||||||
|
delay={0.95}
|
||||||
|
step={30}
|
||||||
|
nowrap
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="uppercase">
|
||||||
|
<Scramble text={lock.place} delay={1.05} step={30} nowrap />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="trace seq absolute top-[233px] right-[510px] left-[calc(var(--lx)+66px)] hidden h-px bg-accent/55 xl:block"
|
||||||
|
style={d(0.9)}
|
||||||
|
>
|
||||||
|
<span className="absolute -top-4 left-0 text-[9px] whitespace-nowrap tracking-[0.2em] text-accent">
|
||||||
|
TRACE ▸ 0001
|
||||||
|
</span>
|
||||||
|
<span className="absolute -top-[3px] -right-[3px] size-[7px] bg-accent" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-9 px-4 pt-6 pb-16 md:px-16 xl:block xl:p-0">
|
||||||
|
{/* Headline */}
|
||||||
|
<div className="xl:absolute xl:top-[364px] xl:left-16 xl:w-[620px]">
|
||||||
|
<p className="mb-3 text-[9px] tracking-[0.28em] text-mute xl:mb-5 xl:text-[10px] xl:tracking-[0.3em]">
|
||||||
|
<Scramble
|
||||||
|
text="TARGET RESOLVED · SUBJECT 0001"
|
||||||
|
delay={0.2}
|
||||||
|
step={15}
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
<h1
|
||||||
|
id="hero-title"
|
||||||
|
className="font-display text-[46px] leading-[0.96] font-semibold uppercase sm:text-[64px] xl:text-[84px] xl:leading-[0.94] xl:tracking-[0.01em]"
|
||||||
|
>
|
||||||
|
<Scramble text="ENTREPRENEUR" delay={0.3} step={35} nowrap />
|
||||||
|
<br />
|
||||||
|
<Scramble text="& BUILDER" delay={0.5} step={35} nowrap />
|
||||||
|
</h1>
|
||||||
|
{/*<p className="mt-4 text-[15px] leading-normal text-ink-2 xl:mt-6 xl:text-[17px]">
|
||||||
|
<Scramble text={profile.stance} delay={0.7} step={15} />
|
||||||
|
</p>*/}
|
||||||
|
<div
|
||||||
|
className="resolve seq mt-7 flex flex-col gap-2.5 sm:flex-row sm:flex-wrap sm:gap-3 xl:mt-10"
|
||||||
|
style={d(1.4)}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href="/projects/"
|
||||||
|
className="inline-flex min-h-12 items-center justify-between gap-3 bg-ink px-[22px] text-xs font-medium tracking-[0.2em] text-night transition-colors hover:bg-accent hover:text-night"
|
||||||
|
>
|
||||||
|
VIEW PROJECTS <span aria-hidden="true">→</span>
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/about/"
|
||||||
|
className="bracket inline-flex min-h-12 items-center justify-between gap-3 border border-white/12 px-[22px] text-xs tracking-[0.2em] transition-colors [--bc:rgb(242_244_245/0.6)] hover:border-transparent hover:text-accent hover:[--bc:var(--color-accent)]"
|
||||||
|
>
|
||||||
|
ABOUT ME <span aria-hidden="true">↗</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<DossierList fields={dossierFields} className="mt-9 xl:hidden" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Profile card */}
|
||||||
|
<article
|
||||||
|
aria-labelledby="profile-name"
|
||||||
|
className="bracket panel seq order-first border border-white/7 bg-surface/90 p-4 [--bc:rgb(242_244_245/0.55)] xl:absolute xl:top-[124px] xl:right-16 xl:order-none xl:w-[446px] xl:p-[22px]"
|
||||||
|
style={d(1.0)}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between text-[9px] tracking-[0.2em] text-mute xl:text-[10px]">
|
||||||
|
<span>PROFILE / 0001</span>
|
||||||
|
<span className="text-accent">● MATCH CONFIRMED</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3.5 flex items-end gap-4 xl:mt-[18px] xl:items-start xl:gap-[22px]">
|
||||||
|
<figure className="bracket w-[112px] flex-none [--bc:rgb(242_244_245/0.4)] xl:w-[168px]">
|
||||||
|
<Bust src={profile.pfp} delay="1.2s" />
|
||||||
|
{profile.pfpIsPlaceholder && (
|
||||||
|
<figcaption className="pt-2 pb-0.5 text-[9px] tracking-[0.2em] text-mute max-xl:hidden">
|
||||||
|
PFP · PLACEHOLDER
|
||||||
|
</figcaption>
|
||||||
|
)}
|
||||||
|
</figure>
|
||||||
|
<div className="min-w-0 xl:pt-1">
|
||||||
|
<div className="text-[9px] tracking-[0.24em] text-mute">
|
||||||
|
ALIAS
|
||||||
|
</div>
|
||||||
|
<h2
|
||||||
|
id="profile-name"
|
||||||
|
className="mt-1.5 font-display text-[28px] leading-none font-semibold tracking-[0.06em] uppercase xl:text-4xl"
|
||||||
|
>
|
||||||
|
<Scramble
|
||||||
|
text={profile.displayName.toUpperCase()}
|
||||||
|
delay={1.2}
|
||||||
|
step={90}
|
||||||
|
nowrap
|
||||||
|
/>
|
||||||
|
</h2>
|
||||||
|
<div className="mt-2.5 text-[9px] leading-[1.9] tracking-[0.16em] text-mute xl:mt-[22px]">
|
||||||
|
<div className="max-xl:hidden">MESH 2.5D · RESOLVED</div>
|
||||||
|
<div className="max-xl:hidden">CLEARANCE · PUBLIC</div>
|
||||||
|
{profile.pfpIsPlaceholder && (
|
||||||
|
<div className="xl:hidden">PFP · PLACEHOLDER</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DossierList
|
||||||
|
fields={dossierFields}
|
||||||
|
className="mt-[22px] hidden xl:grid"
|
||||||
|
/>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-10 hidden xl:block">
|
||||||
|
<div className="relative mx-auto h-20 max-w-[1440px]">
|
||||||
|
{/* Corner telemetry, pinned to the bottom of the screen-tall hero */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="appear seq absolute bottom-[22px] left-16 hidden text-[9px] leading-[1.8] tracking-[0.16em] text-mute/70 xl:block"
|
||||||
|
style={d(0.2)}
|
||||||
|
>
|
||||||
|
<SessionStamp />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute bottom-[22px] left-1/2 hidden -translate-x-1/2 text-[9px] tracking-[0.3em] text-mute xl:block"
|
||||||
|
>
|
||||||
|
SCROLL ▾ NODES
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="appear seq absolute right-16 bottom-[22px] hidden text-right text-[9px] leading-[1.8] tracking-[0.16em] xl:block"
|
||||||
|
style={d(0.2)}
|
||||||
|
>
|
||||||
|
<div className="text-accent">
|
||||||
|
<LocalTime />
|
||||||
|
</div>
|
||||||
|
<div className="text-mute/70">VISITOR · UNRESOLVED</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
/*
|
|
||||||
* HERO SECTION — selimaj.dev
|
|
||||||
* Design: Glassmorphic Terminal
|
|
||||||
* Full-viewport dark hero with animated background image, typewriter effect, and glass CTA
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
const HERO_BG = "https://d2xsxph8kpxj0f.cloudfront.net/310519663439135967/RkRL4wd7YzNoLyaN7suWbR/hero-bg-G3393XyTzqxu3D4dR63od8.webp";
|
|
||||||
|
|
||||||
const typewriterPhrases = [
|
|
||||||
"Software Developer.",
|
|
||||||
"Entrepreneur.",
|
|
||||||
"SaaS Builder.",
|
|
||||||
"Systems Programmer.",
|
|
||||||
"Open Source Contributor.",
|
|
||||||
];
|
|
||||||
|
|
||||||
function useTypewriter(phrases: string[], speed = 60, pause = 1800) {
|
|
||||||
const [display, setDisplay] = useState("");
|
|
||||||
const [phraseIdx, setPhraseIdx] = useState(0);
|
|
||||||
const [charIdx, setCharIdx] = useState(0);
|
|
||||||
const [deleting, setDeleting] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const current = phrases[phraseIdx];
|
|
||||||
let timeout: ReturnType<typeof setTimeout>;
|
|
||||||
|
|
||||||
if (!deleting && charIdx < current.length) {
|
|
||||||
timeout = setTimeout(() => setCharIdx((c) => c + 1), speed);
|
|
||||||
} else if (!deleting && charIdx === current.length) {
|
|
||||||
timeout = setTimeout(() => setDeleting(true), pause);
|
|
||||||
} else if (deleting && charIdx > 0) {
|
|
||||||
timeout = setTimeout(() => setCharIdx((c) => c - 1), speed / 2);
|
|
||||||
} else if (deleting && charIdx === 0) {
|
|
||||||
setDeleting(false);
|
|
||||||
setPhraseIdx((i) => (i + 1) % phrases.length);
|
|
||||||
}
|
|
||||||
|
|
||||||
setDisplay(current.slice(0, charIdx));
|
|
||||||
return () => clearTimeout(timeout);
|
|
||||||
}, [charIdx, deleting, phraseIdx, phrases, speed, pause]);
|
|
||||||
|
|
||||||
return display;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function HeroSection() {
|
|
||||||
const typed = useTypewriter(typewriterPhrases);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
id="hero"
|
|
||||||
className="relative min-h-screen flex items-center justify-center overflow-hidden"
|
|
||||||
style={{ background: "#080808" }}
|
|
||||||
>
|
|
||||||
{/* Background image */}
|
|
||||||
<div
|
|
||||||
className="absolute inset-0 bg-cover bg-center bg-no-repeat"
|
|
||||||
style={{
|
|
||||||
backgroundImage: `url(${HERO_BG})`,
|
|
||||||
opacity: 0.55,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Gradient overlays */}
|
|
||||||
<div className="absolute inset-0 bg-linear-to-b from-transparent via-transparent to-[#080808]" />
|
|
||||||
<div className="absolute inset-0 bg-linear-to-r from-[#080808]/60 via-transparent to-[#080808]/60" />
|
|
||||||
|
|
||||||
{/* Animated glow orb */}
|
|
||||||
<div
|
|
||||||
className="absolute top-1/3 left-1/2 -translate-x-1/2 -translate-y-1/2 w-150 h-150 rounded-full pointer-events-none"
|
|
||||||
style={{
|
|
||||||
background: "radial-gradient(circle, rgba(255,255,255,0.04) 0%, transparent 70%)",
|
|
||||||
animation: "float 6s ease-in-out infinite",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="relative z-10 max-w-5xl mx-auto px-6 text-center">
|
|
||||||
{/* Terminal label */}
|
|
||||||
<div className="inline-flex items-center gap-2 mb-8 px-4 py-2 glass rounded-full">
|
|
||||||
<span className="w-2 h-2 rounded-full bg-white/60 inline-block" />
|
|
||||||
<span className="terminal-label text-xs">
|
|
||||||
> klesti@selimaj.dev ~ $
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main heading */}
|
|
||||||
<h1
|
|
||||||
className="text-5xl sm:text-6xl md:text-7xl lg:text-8xl font-bold text-white mb-6 leading-none tracking-tight fade-in-up"
|
|
||||||
style={{ fontFamily: "'Syne', sans-serif" }}
|
|
||||||
>
|
|
||||||
Klesti
|
|
||||||
<br />
|
|
||||||
<span className="text-white/30">Selimaj</span>
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
{/* Typewriter subtitle */}
|
|
||||||
<div
|
|
||||||
className="text-xl sm:text-2xl md:text-3xl font-medium text-white/70 mb-4 h-10 fade-in-up delay-200"
|
|
||||||
style={{ fontFamily: "'Syne', sans-serif" }}
|
|
||||||
>
|
|
||||||
{typed}
|
|
||||||
<span className="cursor-blink text-white/50 ml-0.5">|</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
<p
|
|
||||||
className="text-base sm:text-lg text-white/45 max-w-2xl mx-auto mb-12 leading-relaxed fade-in-up delay-400"
|
|
||||||
style={{ fontFamily: "'DM Sans', sans-serif" }}
|
|
||||||
>
|
|
||||||
16 years old · 6 years of experience · Albania
|
|
||||||
<br />
|
|
||||||
Passionate about low-level systems, hacking, UI/UX, and making cool tools.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* CTA Buttons */}
|
|
||||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-4 fade-in-up delay-500">
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
document.querySelector("#projects")?.scrollIntoView({ behavior: "smooth" });
|
|
||||||
}}
|
|
||||||
className="px-8 py-3.5 bg-white text-black font-semibold rounded-xl hover:bg-white/90 transition-all duration-200 text-sm"
|
|
||||||
style={{ fontFamily: "'DM Sans', sans-serif" }}
|
|
||||||
>
|
|
||||||
View Projects
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
document.querySelector("#contact")?.scrollIntoView({ behavior: "smooth" });
|
|
||||||
}}
|
|
||||||
className="px-8 py-3.5 glass text-white/80 font-medium rounded-xl hover:text-white hover:border-white/20 transition-all duration-200 text-sm border border-white/10"
|
|
||||||
style={{ fontFamily: "'DM Sans', sans-serif" }}
|
|
||||||
>
|
|
||||||
Get in Touch
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stats row */}
|
|
||||||
<div className="mt-20 flex flex-row flex-wrap items-center justify-center gap-6 sm:gap-12 fade-in-up delay-700">
|
|
||||||
{[
|
|
||||||
{ value: "6+", label: "Years Experience" },
|
|
||||||
{ value: "97+", label: "Repositories" },
|
|
||||||
{ value: "2,670+", label: "Contributions / Year" },
|
|
||||||
{ value: "#2", label: "National GitHub user" },
|
|
||||||
].map((stat) => (
|
|
||||||
<div key={stat.label} className="text-center">
|
|
||||||
<div
|
|
||||||
className="text-3xl font-bold text-white"
|
|
||||||
style={{ fontFamily: "'Syne', sans-serif" }}
|
|
||||||
>
|
|
||||||
{stat.value}
|
|
||||||
</div>
|
|
||||||
<div className="terminal-label mt-1">{stat.label}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Scroll indicator */}
|
|
||||||
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 flex flex-col items-center gap-2 opacity-0 fade-in-up delay-800" style={{animationFillMode:'forwards'}}>
|
|
||||||
<div className="w-px h-8 bg-linear-to-b from-white/30 to-transparent" />
|
|
||||||
<span className="terminal-label text-xs">scroll</span>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
/*
|
|
||||||
* NAVBAR — selimaj.dev
|
|
||||||
* Design: Glassmorphic Terminal
|
|
||||||
* Glass effect: backdrop-blur + rgba background
|
|
||||||
* Becomes more opaque on scroll
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
const navLinks = [
|
|
||||||
{ label: "About", href: "#about" },
|
|
||||||
{ label: "Projects", href: "#projects" },
|
|
||||||
{ label: "Skills", href: "#skills" },
|
|
||||||
{ label: "Contact", href: "#contact" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function Navbar() {
|
|
||||||
const [scrolled, setScrolled] = useState(false);
|
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleScroll = () => {
|
|
||||||
setScrolled(window.scrollY > 40);
|
|
||||||
};
|
|
||||||
window.addEventListener("scroll", handleScroll, { passive: true });
|
|
||||||
return () => window.removeEventListener("scroll", handleScroll);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleNavClick = (href: string) => {
|
|
||||||
setMenuOpen(false);
|
|
||||||
const el = document.querySelector(href);
|
|
||||||
if (el) {
|
|
||||||
el.scrollIntoView({ behavior: "smooth" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<nav
|
|
||||||
className={`fixed top-0 left-0 right-0 z-50 transition-all duration-500 ${
|
|
||||||
scrolled ? "navbar-glass-solid" : "navbar-glass"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="max-w-6xl mx-auto px-6 flex items-center justify-between h-16">
|
|
||||||
{/* Logo */}
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
onClick={(e) => { e.preventDefault(); window.scrollTo({ top: 0, behavior: "smooth" }); }}
|
|
||||||
className="flex items-center gap-2 group"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="text-white font-bold text-lg tracking-tight"
|
|
||||||
style={{ fontFamily: "'Syne', sans-serif" }}
|
|
||||||
>
|
|
||||||
selimaj<span className="text-white/40">.dev</span>
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
{/* Desktop nav links */}
|
|
||||||
<div className="hidden md:flex items-center gap-1">
|
|
||||||
{navLinks.map((link) => (
|
|
||||||
<button
|
|
||||||
key={link.href}
|
|
||||||
onClick={() => handleNavClick(link.href)}
|
|
||||||
className="px-4 py-2 text-sm text-white/60 hover:text-white transition-colors duration-200 rounded-lg hover:bg-white/5"
|
|
||||||
style={{ fontFamily: "'DM Sans', sans-serif" }}
|
|
||||||
>
|
|
||||||
{link.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
<a
|
|
||||||
href="https://github.com/selimaj-dev"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="ml-3 px-4 py-2 text-sm font-medium text-black bg-white rounded-lg hover:bg-white/90 transition-all duration-200"
|
|
||||||
style={{ fontFamily: "'DM Sans', sans-serif" }}
|
|
||||||
>
|
|
||||||
GitHub
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mobile hamburger */}
|
|
||||||
<button
|
|
||||||
className="md:hidden flex flex-col gap-1.5 p-2"
|
|
||||||
onClick={() => setMenuOpen(!menuOpen)}
|
|
||||||
aria-label="Toggle menu"
|
|
||||||
>
|
|
||||||
<span className={`block w-5 h-0.5 bg-white/70 transition-all duration-300 ${menuOpen ? "rotate-45 translate-y-2" : ""}`} />
|
|
||||||
<span className={`block w-5 h-0.5 bg-white/70 transition-all duration-300 ${menuOpen ? "opacity-0" : ""}`} />
|
|
||||||
<span className={`block w-5 h-0.5 bg-white/70 transition-all duration-300 ${menuOpen ? "-rotate-45 -translate-y-2" : ""}`} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mobile menu */}
|
|
||||||
<div
|
|
||||||
className={`md:hidden overflow-hidden transition-all duration-300 ${
|
|
||||||
menuOpen ? "max-h-64 opacity-100" : "max-h-0 opacity-0"
|
|
||||||
}`}
|
|
||||||
style={{ background: "rgba(8,8,8,0.95)", borderTop: "1px solid rgba(255,255,255,0.06)" }}
|
|
||||||
>
|
|
||||||
<div className="px-6 py-4 flex flex-col gap-1">
|
|
||||||
{navLinks.map((link) => (
|
|
||||||
<button
|
|
||||||
key={link.href}
|
|
||||||
onClick={() => handleNavClick(link.href)}
|
|
||||||
className="text-left px-3 py-3 text-sm text-white/60 hover:text-white transition-colors rounded-lg hover:bg-white/5"
|
|
||||||
style={{ fontFamily: "'DM Sans', sans-serif" }}
|
|
||||||
>
|
|
||||||
{link.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
<a
|
|
||||||
href="https://github.com/selimaj-dev"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="mt-2 px-3 py-3 text-sm font-medium text-black bg-white rounded-lg text-center hover:bg-white/90 transition-all"
|
|
||||||
style={{ fontFamily: "'DM Sans', sans-serif" }}
|
|
||||||
>
|
|
||||||
GitHub
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { brandOf, groups, projects, root, type GroupId, type Project } from "@/content/projects";
|
||||||
|
import { Segment } from "./Segment";
|
||||||
|
import { StatusTag } from "./StatusTag";
|
||||||
|
|
||||||
|
const W = 1200;
|
||||||
|
const H = 720;
|
||||||
|
const groupIds = Object.keys(groups) as GroupId[];
|
||||||
|
const isHub = (p: Project) => groups[p.group].node === p.id;
|
||||||
|
|
||||||
|
function Marker({ p, on }: { p: Project; on: boolean }) {
|
||||||
|
const hub = isHub(p);
|
||||||
|
const size = hub ? 22 : 14;
|
||||||
|
const rest =
|
||||||
|
p.status === "CONCEPT" ? "border border-dashed border-ink/60" : p.status === "ARCHIVED" ? "border border-ink/30" : "border border-ink/65";
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={`relative box-border flex-none ${hub ? "rotate-45" : ""} ${
|
||||||
|
on ? "bg-accent shadow-[0_0_12px_var(--color-accent)]" : `${rest} bg-surface`
|
||||||
|
}`}
|
||||||
|
style={{ width: size, height: size }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProjectCard({ p }: { p: Project }) {
|
||||||
|
const brand = brandOf(p);
|
||||||
|
const body = (
|
||||||
|
<>
|
||||||
|
<span className="flex items-center justify-between gap-3">
|
||||||
|
<span className="flex items-center gap-3">
|
||||||
|
<StatusTag status={p.status} />
|
||||||
|
{brand && <span className="text-[9px] tracking-[0.22em] text-mute uppercase">{brand}</span>}
|
||||||
|
</span>
|
||||||
|
<span aria-hidden="true" className="text-mute transition-colors group-hover:text-accent group-focus-visible:text-accent">
|
||||||
|
{p.href ? "↗" : "→"}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="font-display text-[22px] font-semibold tracking-[0.04em] uppercase">{p.name}</span>
|
||||||
|
<span className="flex-1 text-xs leading-[1.65] text-ink-2">{p.desc}</span>
|
||||||
|
<span className="flex flex-wrap gap-1.5">
|
||||||
|
{p.tags.map((t) => (
|
||||||
|
<span key={t} className="bg-surface-2 px-[7px] py-[3px] text-[10px] text-mute">
|
||||||
|
{t}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
const cls =
|
||||||
|
"group bracket flex min-h-[200px] scroll-mt-24 flex-col gap-3 border border-white/6 bg-surface p-[22px] [--bc:rgb(242_244_245/0.3)] hover:[--bc:var(--color-accent)] focus-visible:[--bc:var(--color-accent)]";
|
||||||
|
return p.href ? (
|
||||||
|
<a id={p.id} href={p.href} className={cls}>
|
||||||
|
{body}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<article id={p.id} className={cls}>
|
||||||
|
{body}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProjectIndex() {
|
||||||
|
const [view, setView] = useState<"map" | "list">("map");
|
||||||
|
const [active, setActive] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Deep links (/projects/#enclave) select that node; small screens start on the list.
|
||||||
|
useEffect(() => {
|
||||||
|
const id = decodeURIComponent(location.hash.slice(1));
|
||||||
|
const small = matchMedia("(max-width: 767px)").matches;
|
||||||
|
if (projects.some((p) => p.id === id) || small) {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- syncing with browser-only state once on mount
|
||||||
|
if (projects.some((p) => p.id === id)) setActive(id);
|
||||||
|
if (small) setView("list");
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const sel = projects.find((p) => p.id === active) ?? null;
|
||||||
|
const count = (s: Project["status"]) => String(projects.filter((p) => p.status === s).length).padStart(2, "0");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="mt-8 flex flex-wrap items-end justify-between gap-6">
|
||||||
|
<ul aria-label="Status legend" className="flex flex-wrap gap-[18px] text-[9px] tracking-[0.2em] text-mute">
|
||||||
|
<li className="flex items-center gap-1.5">
|
||||||
|
<span aria-hidden="true" className="size-[5px] rounded-full bg-accent" />
|
||||||
|
LIVE {count("LIVE")}
|
||||||
|
</li>
|
||||||
|
<li>BUILDING {count("BUILDING")}</li>
|
||||||
|
<li>CONCEPT {count("CONCEPT")}</li>
|
||||||
|
<li>ARCHIVED {count("ARCHIVED")}</li>
|
||||||
|
</ul>
|
||||||
|
<div role="group" aria-label="View" className="hidden border border-white/12 js:flex">
|
||||||
|
{(["map", "list"] as const).map((v) => (
|
||||||
|
<button
|
||||||
|
key={v}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={view === v}
|
||||||
|
onClick={() => setView(v)}
|
||||||
|
className={`min-h-11 min-w-[76px] border-b px-4 text-[11px] tracking-[0.22em] uppercase ${
|
||||||
|
view === v ? "border-accent bg-surface-2 text-ink" : "border-transparent text-mute hover:text-ink"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{v}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* MAP: needs JS to be useful, so it is hidden without it. */}
|
||||||
|
<div className={`hidden ${view === "map" ? "js:block" : ""}`}>
|
||||||
|
<div className="mt-6 overflow-x-auto border border-white/6">
|
||||||
|
<div className="grid-bg relative mx-auto bg-surface [background-size:40px_40px]" style={{ width: W, height: H }}>
|
||||||
|
{groupIds.map((k) => (
|
||||||
|
<Segment key={k} x1={root.map.x} y1={root.map.y} x2={groups[k].x} y2={groups[k].y} on={!!sel && sel.group === k} strong />
|
||||||
|
))}
|
||||||
|
{projects
|
||||||
|
.filter((p) => !isHub(p))
|
||||||
|
.map((p) => (
|
||||||
|
<Segment
|
||||||
|
key={`${p.id}-${active === p.id}`}
|
||||||
|
x1={groups[p.group].x}
|
||||||
|
y1={groups[p.group].y}
|
||||||
|
x2={p.map.x}
|
||||||
|
y2={p.map.y}
|
||||||
|
on={active === p.id}
|
||||||
|
dashed={p.status === "CONCEPT"}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Plain hubs (groups without a project of their own). */}
|
||||||
|
{groupIds
|
||||||
|
.filter((k) => !groups[k].node)
|
||||||
|
.map((k) => {
|
||||||
|
const g = groups[k];
|
||||||
|
return (
|
||||||
|
<div key={k} aria-hidden="true">
|
||||||
|
<div
|
||||||
|
className="absolute text-[9px] whitespace-nowrap tracking-[0.24em] text-mute uppercase"
|
||||||
|
style={{ top: g.y - 7, ...(g.side === "l" ? { right: W - g.x + 14 } : { left: g.x + 14 }) }}
|
||||||
|
>
|
||||||
|
{g.label}
|
||||||
|
</div>
|
||||||
|
<div className="absolute box-border size-1.5 rotate-45 border border-ink/40" style={{ left: g.x - 3, top: g.y - 3 }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Root: the site itself. */}
|
||||||
|
<div className="absolute flex items-center gap-3 whitespace-nowrap" style={{ left: root.map.x - 15, top: root.map.y - 15 }}>
|
||||||
|
<span aria-hidden="true" className="relative box-border size-[30px] flex-none rounded-full border border-ink/70 bg-surface">
|
||||||
|
<span className="absolute top-[10px] left-[10px] size-2 rounded-full bg-ink" />
|
||||||
|
</span>
|
||||||
|
<span className="flex flex-col gap-[3px]">
|
||||||
|
<span className="font-display text-xl font-semibold tracking-[0.08em] uppercase">{root.name}</span>
|
||||||
|
<span className="text-[8px] tracking-[0.22em] text-mute uppercase">Root · {root.sub}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{projects.map((p) => {
|
||||||
|
const on = active === p.id;
|
||||||
|
const g = groups[p.group];
|
||||||
|
const labelLeft = (p.label ?? (p.map.x < g.x ? "l" : "r")) === "l";
|
||||||
|
const m = isHub(p) ? 22 : 14;
|
||||||
|
const pad = 8;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={on}
|
||||||
|
onClick={() => setActive(p.id)}
|
||||||
|
onMouseEnter={() => setActive(p.id)}
|
||||||
|
onFocus={() => setActive(p.id)}
|
||||||
|
className={`bracket absolute flex min-h-12 items-center gap-2.5 whitespace-nowrap focus-visible:outline-none ${
|
||||||
|
on ? "[--bc:var(--color-accent)]" : "[--bc:transparent] hover:[--bc:var(--color-accent)]"
|
||||||
|
} ${labelLeft ? "flex-row-reverse text-right" : ""}`}
|
||||||
|
style={{
|
||||||
|
top: p.map.y - 24,
|
||||||
|
padding: `0 ${pad}px`,
|
||||||
|
...(labelLeft ? { right: W - p.map.x - m / 2 - pad } : { left: p.map.x - m / 2 - pad }),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Marker p={p} on={on} />
|
||||||
|
{/* A hub's label sits a little high so its root line passes underneath. */}
|
||||||
|
<span className={`flex flex-col gap-[3px] ${isHub(p) ? "-translate-y-4" : ""}`}>
|
||||||
|
<span
|
||||||
|
className={`font-display font-semibold tracking-[0.08em] uppercase ${isHub(p) ? "text-base" : "text-[13px]"} ${
|
||||||
|
p.status === "ARCHIVED" && !on ? "text-ink/70" : "text-ink"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{p.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-[8px] tracking-[0.22em] text-mute">{isHub(p) ? "BRAND" : p.status}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div aria-hidden="true" className="absolute bottom-3 left-4 text-[9px] tracking-[0.2em] text-mute/70">
|
||||||
|
GRID 40 · ROOT {root.map.x},{root.map.y}
|
||||||
|
</div>
|
||||||
|
<div aria-hidden="true" className="absolute right-4 bottom-3 text-[9px] tracking-[0.2em] uppercase">
|
||||||
|
{sel ? <span className="text-accent">TRACKING ▸ {sel.name}</span> : <span className="text-mute/70">AWAITING TARGET</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section
|
||||||
|
aria-live="polite"
|
||||||
|
aria-label="Selected node"
|
||||||
|
className={`bracket mt-4 grid min-h-[176px] gap-6 border border-white/6 bg-surface px-7 py-6 md:grid-cols-[minmax(0,1fr)_auto] md:items-center md:gap-x-12 ${
|
||||||
|
sel ? "[--bc:var(--color-accent)]" : "[--bc:rgb(242_244_245/0.3)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{sel ? (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-wrap items-center gap-3.5">
|
||||||
|
<StatusTag status={sel.status} />
|
||||||
|
<span className="text-[9px] tracking-[0.24em] text-mute uppercase">
|
||||||
|
{isHub(sel) ? "Brand" : (brandOf(sel) ?? groups[sel.group].label)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-3 font-display text-3xl font-semibold tracking-[0.04em] uppercase">{sel.name}</h2>
|
||||||
|
<p className="mt-2 max-w-[720px] text-[13px] leading-[1.7] text-ink-2">{sel.desc}</p>
|
||||||
|
<div className="mt-3.5 flex flex-wrap gap-2">
|
||||||
|
{sel.tags.map((t) => (
|
||||||
|
<span key={t} className="bg-surface-2 px-2 py-1 text-[10px] tracking-[0.1em] text-mute">
|
||||||
|
{t}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{sel.href ? (
|
||||||
|
<a href={sel.href} className="inline-flex min-h-12 items-center gap-3 justify-self-start border border-white/14 px-5 text-[11px] tracking-[0.2em] hover:border-accent">
|
||||||
|
VISIT <span aria-hidden="true">↗</span>
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setView("list");
|
||||||
|
requestAnimationFrame(() => document.getElementById(sel.id)?.scrollIntoView({ block: "center" }));
|
||||||
|
}}
|
||||||
|
className="inline-flex min-h-12 items-center gap-3 justify-self-start border border-white/14 px-5 text-[11px] tracking-[0.2em] hover:border-accent hover:text-accent"
|
||||||
|
>
|
||||||
|
VIEW IN LIST <span aria-hidden="true">→</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-[11px] tracking-[0.2em] text-mute">HOVER OR SELECT A NODE TO TRACK IT.</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* LIST: always rendered; hidden only when JS is on and the map is chosen. */}
|
||||||
|
<div className={`mt-10 flex flex-col gap-14 ${view === "map" ? "js:hidden" : ""}`}>
|
||||||
|
{groupIds.map((k) => {
|
||||||
|
const items = projects.filter((p) => p.group === k);
|
||||||
|
return (
|
||||||
|
<section key={k} aria-labelledby={`g-${k}`}>
|
||||||
|
<div className="flex items-baseline gap-4 border-b border-white/8 pb-3.5">
|
||||||
|
<h2 id={`g-${k}`} className="font-display text-xl font-semibold tracking-[0.14em] uppercase">
|
||||||
|
{groups[k].label}
|
||||||
|
</h2>
|
||||||
|
<span className="text-[9px] tracking-[0.22em] text-mute">{String(items.length).padStart(2, "0")}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 grid grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-4">
|
||||||
|
{items.map((p) => (
|
||||||
|
<ProjectCard key={p.id} p={p} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,299 +0,0 @@
|
|||||||
/*
|
|
||||||
* PROJECTS SECTION — selimaj.dev
|
|
||||||
* Design: Glassmorphic Terminal
|
|
||||||
* Masonry-style grid with glass cards, language badges, and star counts
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
|
||||||
import { ExternalLink, Star, GitFork } from "lucide-react";
|
|
||||||
|
|
||||||
function useReveal(threshold = 0.1) {
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
useEffect(() => {
|
|
||||||
const el = ref.current;
|
|
||||||
if (!el) return;
|
|
||||||
const observer = new IntersectionObserver(
|
|
||||||
([entry]) => {
|
|
||||||
if (entry.isIntersecting) {
|
|
||||||
el.classList.add("visible");
|
|
||||||
observer.disconnect();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ threshold },
|
|
||||||
);
|
|
||||||
observer.observe(el);
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, [threshold]);
|
|
||||||
return ref;
|
|
||||||
}
|
|
||||||
|
|
||||||
const LANG_COLORS: Record<string, string> = {
|
|
||||||
TypeScript: "#3178c6",
|
|
||||||
Rust: "#ce422b",
|
|
||||||
Java: "#b07219",
|
|
||||||
JavaScript: "#f1e05a",
|
|
||||||
Python: "#3572A5",
|
|
||||||
Shell: "#89e051",
|
|
||||||
"C#": "#178600",
|
|
||||||
};
|
|
||||||
|
|
||||||
interface Project {
|
|
||||||
name: string;
|
|
||||||
org?: string;
|
|
||||||
description: string;
|
|
||||||
language: string;
|
|
||||||
stars: number;
|
|
||||||
forks?: number;
|
|
||||||
href: string;
|
|
||||||
featured?: boolean;
|
|
||||||
tag?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const projects: Project[] = [
|
|
||||||
{
|
|
||||||
name: "osui",
|
|
||||||
org: "osui-rs",
|
|
||||||
description:
|
|
||||||
"A TUI library written in Rust — build terminal UIs with a component-based approach.",
|
|
||||||
language: "Rust",
|
|
||||||
stars: 7,
|
|
||||||
href: "https://github.com/osui-rs/osui",
|
|
||||||
featured: true,
|
|
||||||
tag: "Library",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "saturnclient",
|
|
||||||
org: "saturnclientmc",
|
|
||||||
description:
|
|
||||||
"Saturn Client — a Minecraft client full of customizability and free cosmetics.",
|
|
||||||
language: "Java",
|
|
||||||
stars: 7,
|
|
||||||
forks: 2,
|
|
||||||
href: "https://github.com/saturnclientmc/saturnclient",
|
|
||||||
featured: true,
|
|
||||||
tag: "Game Client",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "dev-run",
|
|
||||||
org: "orus-dev",
|
|
||||||
description:
|
|
||||||
"DevRun — a community-driven competitive programming platform inspired by speedrunning.",
|
|
||||||
language: "TypeScript",
|
|
||||||
stars: 3,
|
|
||||||
href: "https://github.com/orus-dev/dev-run",
|
|
||||||
featured: true,
|
|
||||||
tag: "Platform",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "nexa-trade",
|
|
||||||
org: "nexa-sol",
|
|
||||||
description:
|
|
||||||
"The Solana trading terminal supercharged by AI — trade smarter.",
|
|
||||||
language: "TypeScript",
|
|
||||||
stars: 1,
|
|
||||||
href: "https://github.com/nexa-sol/nexa-trade",
|
|
||||||
featured: true,
|
|
||||||
tag: "FinTech",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "dockship",
|
|
||||||
org: "orus-dev",
|
|
||||||
description:
|
|
||||||
"A powerful self-hosted deployment platform for managing your applications.",
|
|
||||||
language: "TypeScript",
|
|
||||||
stars: 1,
|
|
||||||
href: "https://github.com/orus-dev/dockship",
|
|
||||||
tag: "DevOps",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "axiom",
|
|
||||||
org: "AxiomChat",
|
|
||||||
description: "A modern real-time chat application built with TypeScript.",
|
|
||||||
language: "TypeScript",
|
|
||||||
stars: 1,
|
|
||||||
href: "https://github.com/AxiomChat/axiom",
|
|
||||||
tag: "App",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "linear-linux",
|
|
||||||
description:
|
|
||||||
"Linux support for linear.app — bringing the popular project management tool to Linux.",
|
|
||||||
language: "JavaScript",
|
|
||||||
stars: 27,
|
|
||||||
forks: 5,
|
|
||||||
href: "https://github.com/selimaj-dev/linear-linux",
|
|
||||||
tag: "Tool",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "osmium",
|
|
||||||
description: "A TUI component-based game engine built on top of OSUI.",
|
|
||||||
language: "Rust",
|
|
||||||
stars: 1,
|
|
||||||
href: "https://github.com/selimaj-dev/osmium",
|
|
||||||
tag: "Engine",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "spark",
|
|
||||||
description:
|
|
||||||
"AI documentation generator that takes context from your repository's source code.",
|
|
||||||
language: "Python",
|
|
||||||
stars: 2,
|
|
||||||
href: "https://github.com/selimaj-dev/spark",
|
|
||||||
tag: "AI Tool",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "flux",
|
|
||||||
description:
|
|
||||||
"A tool and library for using multiple languages in one codebase seamlessly.",
|
|
||||||
language: "Rust",
|
|
||||||
stars: 1,
|
|
||||||
href: "https://github.com/selimaj-dev/flux",
|
|
||||||
tag: "Tool",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "session-rs",
|
|
||||||
description: "A lightweight async WebSocket protocol library for Rust.",
|
|
||||||
language: "Rust",
|
|
||||||
stars: 1,
|
|
||||||
href: "https://github.com/selimaj-dev/session-rs",
|
|
||||||
tag: "Library",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "tailwind-motlib",
|
|
||||||
description: "A motion and stagger animation library for Tailwind CSS.",
|
|
||||||
language: "TypeScript",
|
|
||||||
stars: 0,
|
|
||||||
href: "https://github.com/selimaj-dev/tailwind-motlib",
|
|
||||||
tag: "Library",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
function ProjectCard({ project, delay }: { project: Project; delay: number }) {
|
|
||||||
const langColor = LANG_COLORS[project.language] || "#888";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
href={project.href}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="block glass rounded-2xl p-6 card-hover group no-underline"
|
|
||||||
style={{ animationDelay: `${delay}ms` }}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-start justify-between mb-3">
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
{project.tag && (
|
|
||||||
<span className="terminal-label text-xs inline-block mb-2 px-2 py-0.5 rounded-full border border-white/10 bg-white/5">
|
|
||||||
{project.tag}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<h3
|
|
||||||
className="text-white font-semibold text-base leading-tight truncate"
|
|
||||||
style={{ fontFamily: "'Syne', sans-serif" }}
|
|
||||||
>
|
|
||||||
{project.org ? (
|
|
||||||
<span className="text-white/40">{project.org}/</span>
|
|
||||||
) : null}
|
|
||||||
{project.name}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<ExternalLink
|
|
||||||
size={14}
|
|
||||||
className="text-white/20 group-hover:text-white/60 transition-colors ml-3 mt-1 shrink-0"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
<p
|
|
||||||
className="text-white/50 text-sm leading-relaxed mb-5 line-clamp-2"
|
|
||||||
style={{ fontFamily: "'DM Sans', sans-serif" }}
|
|
||||||
>
|
|
||||||
{project.description}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<span className="lang-badge">
|
|
||||||
<span className="lang-dot" style={{ backgroundColor: langColor }} />
|
|
||||||
{project.language}
|
|
||||||
</span>
|
|
||||||
<span className="lang-badge">
|
|
||||||
<Star size={11} className="text-white/40" />
|
|
||||||
{project.stars}
|
|
||||||
</span>
|
|
||||||
{project.forks !== undefined && (
|
|
||||||
<span className="lang-badge">
|
|
||||||
<GitFork size={11} className="text-white/40" />
|
|
||||||
{project.forks}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ProjectsSection() {
|
|
||||||
const headerRef = useReveal();
|
|
||||||
const gridRef = useReveal(0.05);
|
|
||||||
|
|
||||||
const featured = projects.filter((p) => p.featured);
|
|
||||||
const rest = projects.filter((p) => !p.featured);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
id="projects"
|
|
||||||
className="py-32 relative"
|
|
||||||
style={{ background: "#080808" }}
|
|
||||||
>
|
|
||||||
<div className="absolute top-0 left-0 right-0 h-px bg-linear-to-r from-transparent via-white/10 to-transparent" />
|
|
||||||
|
|
||||||
<div className="max-w-6xl mx-auto px-6">
|
|
||||||
{/* Header */}
|
|
||||||
<div ref={headerRef} className="reveal mb-16">
|
|
||||||
<div className="terminal-label mb-4">> ls projects/</div>
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-end justify-between gap-4">
|
|
||||||
<h2 className="section-heading text-4xl sm:text-5xl text-white leading-tight">
|
|
||||||
Things I've <span className="text-white/40">Built</span>
|
|
||||||
</h2>
|
|
||||||
<a
|
|
||||||
href="https://github.com/selimaj-dev?tab=repositories"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-sm text-white/40 hover:text-white transition-colors terminal-label"
|
|
||||||
>
|
|
||||||
View all 35+ repos →
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Featured projects (larger cards) */}
|
|
||||||
<div ref={gridRef} className="reveal">
|
|
||||||
<div className="terminal-label mb-4 text-xs">// featured</div>
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-4">
|
|
||||||
{featured.map((project, i) => (
|
|
||||||
<ProjectCard
|
|
||||||
key={project.name}
|
|
||||||
project={project}
|
|
||||||
delay={i * 80}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Rest of projects (smaller grid) */}
|
|
||||||
<div className="terminal-label mb-4 text-xs mt-8">
|
|
||||||
// more projects
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
|
||||||
{rest.map((project, i) => (
|
|
||||||
<ProjectCard
|
|
||||||
key={project.name}
|
|
||||||
project={project}
|
|
||||||
delay={i * 60}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
const UPPER = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789#%&@$*+=<>/";
|
||||||
|
const LOWER = "abcdefghijklmnopqrstuvwxyz0123456789#%&@$*+=<>/";
|
||||||
|
const DIGIT = "0123456789#%&@$*";
|
||||||
|
/** Characters that stay put while everything around them is brute-forced. */
|
||||||
|
const FIXED = /[\s.,·()\-/]/;
|
||||||
|
|
||||||
|
const FRAME_MS = 45;
|
||||||
|
|
||||||
|
function noise(ch: string) {
|
||||||
|
if (FIXED.test(ch)) return ch;
|
||||||
|
const pool = /[0-9]/.test(ch) ? DIGIT : ch === ch.toLowerCase() && ch !== ch.toUpperCase() ? LOWER : UPPER;
|
||||||
|
return pool[Math.floor(Math.random() * pool.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
type Frame = { done: string; head: string; rest: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Text that brute-forces itself: every character cycles through random
|
||||||
|
* glyphs, then the real value locks in from left to right.
|
||||||
|
*
|
||||||
|
* Server and no-JS output is the plain text. With reduced motion it never
|
||||||
|
* animates. While the boot screen is up it waits for `boot:done`.
|
||||||
|
*/
|
||||||
|
export function Scramble({
|
||||||
|
text,
|
||||||
|
delay = 0,
|
||||||
|
step = 45,
|
||||||
|
nowrap = false,
|
||||||
|
}: {
|
||||||
|
text: string;
|
||||||
|
/** Seconds before characters start locking in (noise runs from the start). */
|
||||||
|
delay?: number;
|
||||||
|
/** Milliseconds per character as the value locks in. */
|
||||||
|
step?: number;
|
||||||
|
nowrap?: boolean;
|
||||||
|
}) {
|
||||||
|
const [frame, setFrame] = useState<Frame | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
||||||
|
|
||||||
|
const chars = Array.from(text);
|
||||||
|
let raf = 0;
|
||||||
|
let start = 0;
|
||||||
|
let last = -Infinity;
|
||||||
|
|
||||||
|
const build = (locked: number): Frame => ({
|
||||||
|
done: chars.slice(0, locked).join(""),
|
||||||
|
head: locked < chars.length ? chars[locked] : "",
|
||||||
|
rest: chars
|
||||||
|
.slice(locked + 1)
|
||||||
|
.map(noise)
|
||||||
|
.join(""),
|
||||||
|
});
|
||||||
|
|
||||||
|
const loop = (t: number) => {
|
||||||
|
if (!start) start = t;
|
||||||
|
const elapsed = t - start - delay * 1000;
|
||||||
|
const locked = elapsed < 0 ? 0 : Math.floor(elapsed / step);
|
||||||
|
if (locked >= chars.length) {
|
||||||
|
setFrame(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (t - last >= FRAME_MS) {
|
||||||
|
last = t;
|
||||||
|
// Before the reveal starts, the first character is noise too.
|
||||||
|
setFrame(elapsed < 0 ? { done: "", head: noise(chars[0]), rest: build(0).rest } : build(locked));
|
||||||
|
}
|
||||||
|
raf = requestAnimationFrame(loop);
|
||||||
|
};
|
||||||
|
|
||||||
|
const begin = () => {
|
||||||
|
raf = requestAnimationFrame(loop);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (document.documentElement.hasAttribute("data-boot")) {
|
||||||
|
// Hide the real value under the boot screen, then start when it lifts.
|
||||||
|
raf = requestAnimationFrame(() => setFrame({ done: "", head: "", rest: chars.map(noise).join("") }));
|
||||||
|
window.addEventListener("boot:done", begin, { once: true });
|
||||||
|
} else {
|
||||||
|
begin();
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(raf);
|
||||||
|
window.removeEventListener("boot:done", begin);
|
||||||
|
};
|
||||||
|
}, [text, delay, step]);
|
||||||
|
|
||||||
|
if (!frame) return <>{text}</>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="relative inline-block max-w-full align-top">
|
||||||
|
<span className="sr-only">{text}</span>
|
||||||
|
{/* Invisible copy keeps the box exactly the size of the real text. */}
|
||||||
|
<span aria-hidden="true" className={`invisible ${nowrap ? "whitespace-nowrap" : ""}`}>
|
||||||
|
{text}
|
||||||
|
</span>
|
||||||
|
<span aria-hidden="true" className={`absolute inset-0 ${nowrap ? "whitespace-nowrap" : ""}`}>
|
||||||
|
{frame.done}
|
||||||
|
<span className="text-accent">{frame.head}</span>
|
||||||
|
<span className="opacity-50">{frame.rest}</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/** A 1px tracking line between two points, in px, inside a positioned parent. */
|
||||||
|
export function Segment({
|
||||||
|
x1,
|
||||||
|
y1,
|
||||||
|
x2,
|
||||||
|
y2,
|
||||||
|
on,
|
||||||
|
dashed,
|
||||||
|
strong,
|
||||||
|
}: {
|
||||||
|
x1: number;
|
||||||
|
y1: number;
|
||||||
|
x2: number;
|
||||||
|
y2: number;
|
||||||
|
on: boolean;
|
||||||
|
dashed?: boolean;
|
||||||
|
strong?: boolean;
|
||||||
|
}) {
|
||||||
|
const len = Math.hypot(x2 - x1, y2 - y1);
|
||||||
|
const ang = (Math.atan2(y2 - y1, x2 - x1) * 180) / Math.PI;
|
||||||
|
const c = on ? "var(--color-accent)" : strong ? "rgb(242 244 245 / 0.2)" : "rgb(242 244 245 / 0.11)";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className={`pointer-events-none absolute h-px origin-[0_50%] ${on ? "seg-on shadow-[0_0_6px_var(--color-accent)]" : ""}`}
|
||||||
|
style={{
|
||||||
|
left: x1,
|
||||||
|
top: y1,
|
||||||
|
width: len,
|
||||||
|
transform: `rotate(${ang}deg)`,
|
||||||
|
background: dashed && !on ? `repeating-linear-gradient(90deg, ${c} 0 4px, transparent 4px 9px)` : c,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { profile } from "@/content/profile";
|
||||||
|
|
||||||
|
export function SiteFooter() {
|
||||||
|
return (
|
||||||
|
<footer className="border-t border-line">
|
||||||
|
<div className="mx-auto flex max-w-[1440px] flex-wrap justify-between gap-6 px-4 pt-7 pb-10 text-[10px] tracking-[0.18em] text-mute md:px-16">
|
||||||
|
<span>SELIMAJ.DEV · TIRANA, AL</span>
|
||||||
|
<span className="flex gap-6">
|
||||||
|
<a href={profile.github} className="hover:text-accent">GITHUB</a>
|
||||||
|
<a href={profile.git} className="hover:text-accent">GIT.SELIMAJ.DEV</a>
|
||||||
|
</span>
|
||||||
|
<span aria-hidden="true">
|
||||||
|
41.33 N · 19.82 E · <span className="blink text-accent">▌</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { SessionClock } from "./Telemetry";
|
||||||
|
|
||||||
|
const links = [
|
||||||
|
{ href: "/", label: "Home" },
|
||||||
|
{ href: "/about/", label: "About" },
|
||||||
|
{ href: "/projects/", label: "Projects" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const isCurrent = (pathname: string, href: string) =>
|
||||||
|
href === "/"
|
||||||
|
? pathname === "/"
|
||||||
|
: pathname.startsWith(href.replace(/\/$/, ""));
|
||||||
|
|
||||||
|
export function SiteHeader() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const isHome = pathname === "/";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header
|
||||||
|
className={`absolute inset-x-0 top-0 z-30 ${isHome ? "" : "border-b border-line"}`}
|
||||||
|
>
|
||||||
|
<div className="mx-auto flex max-w-[1440px] items-center justify-between gap-6 px-4 py-3 md:px-16 md:py-[22px]">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="flex min-h-11 items-center gap-3"
|
||||||
|
aria-label="selimaj.dev home"
|
||||||
|
>
|
||||||
|
<span className="font-display text-sm font-semibold tracking-[0.24em]">
|
||||||
|
SELIMAJ<span className="text-mute">.DEV</span>
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex min-h-11 items-center gap-2.5 px-1 text-[10px] tracking-[0.22em] md:hidden"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-controls="site-nav"
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
>
|
||||||
|
{open ? "CLOSE" : "MENU"}
|
||||||
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="12"
|
||||||
|
viewBox="0 0 18 12"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M0,1 H18 M6,6 H18 M0,11 H18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<nav
|
||||||
|
id="site-nav"
|
||||||
|
aria-label="Primary"
|
||||||
|
className={`${open ? "block" : "hidden"} absolute inset-x-0 top-full border-y border-line bg-night/95 px-4 backdrop-blur md:static md:block md:border-0 md:bg-transparent md:p-0 md:backdrop-blur-none`}
|
||||||
|
>
|
||||||
|
<ul className="flex flex-col text-[11px] tracking-[0.2em] md:flex-row md:gap-9">
|
||||||
|
{links.map((l) => {
|
||||||
|
const current = isCurrent(pathname, l.href);
|
||||||
|
return (
|
||||||
|
<li key={l.href}>
|
||||||
|
<Link
|
||||||
|
href={l.href}
|
||||||
|
aria-current={current ? "page" : undefined}
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className={`flex min-h-11 items-center gap-2 uppercase md:py-3.5 ${current ? "text-ink" : "text-mute hover:text-accent"}`}
|
||||||
|
>
|
||||||
|
{current && (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="size-1.5 bg-accent shadow-[0_0_8px_var(--color-accent)]"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{l.label}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="hidden text-right text-[10px] leading-relaxed tracking-[0.14em] text-mute lg:block"
|
||||||
|
>
|
||||||
|
<SessionClock />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,248 +0,0 @@
|
|||||||
/*
|
|
||||||
* SKILLS SECTION — selimaj.dev
|
|
||||||
* Design: Glassmorphic Terminal
|
|
||||||
* Flowing chip layout with category grouping
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
|
||||||
CpuIcon,
|
|
||||||
PaletteIcon,
|
|
||||||
RocketIcon,
|
|
||||||
ShieldCheckIcon,
|
|
||||||
TerminalIcon,
|
|
||||||
ZapIcon,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useEffect, useRef } from "react";
|
|
||||||
|
|
||||||
function useReveal(threshold = 0.1) {
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
useEffect(() => {
|
|
||||||
const el = ref.current;
|
|
||||||
if (!el) return;
|
|
||||||
const observer = new IntersectionObserver(
|
|
||||||
([entry]) => {
|
|
||||||
if (entry.isIntersecting) {
|
|
||||||
el.classList.add("visible");
|
|
||||||
observer.disconnect();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ threshold },
|
|
||||||
);
|
|
||||||
observer.observe(el);
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, [threshold]);
|
|
||||||
return ref;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CODE_VISUAL =
|
|
||||||
"https://d2xsxph8kpxj0f.cloudfront.net/310519663439135967/RkRL4wd7YzNoLyaN7suWbR/code-visual-96fdFEYLcFy2ct3UAAmiG6.webp";
|
|
||||||
|
|
||||||
interface SkillCategory {
|
|
||||||
label: string;
|
|
||||||
terminalLabel: string;
|
|
||||||
skills: { name: string; level?: "expert" | "proficient" | "learning" }[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const skillCategories: SkillCategory[] = [
|
|
||||||
{
|
|
||||||
label: "Languages",
|
|
||||||
terminalLabel: "languages/",
|
|
||||||
skills: [
|
|
||||||
{ name: "Rust", level: "expert" },
|
|
||||||
{ name: "TypeScript", level: "expert" },
|
|
||||||
{ name: "JavaScript", level: "expert" },
|
|
||||||
{ name: "Java", level: "proficient" },
|
|
||||||
{ name: "Python", level: "proficient" },
|
|
||||||
{ name: "C#", level: "proficient" },
|
|
||||||
{ name: "Shell / Bash", level: "proficient" },
|
|
||||||
{ name: "Go", level: "learning" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Frontend",
|
|
||||||
terminalLabel: "frontend/",
|
|
||||||
skills: [
|
|
||||||
{ name: "React", level: "expert" },
|
|
||||||
{ name: "Next.js", level: "expert" },
|
|
||||||
{ name: "Tailwind CSS", level: "expert" },
|
|
||||||
{ name: "Framer Motion", level: "proficient" },
|
|
||||||
{ name: "WebSockets", level: "expert" },
|
|
||||||
{ name: "Vite", level: "expert" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Backend & Systems",
|
|
||||||
terminalLabel: "backend/",
|
|
||||||
skills: [
|
|
||||||
{ name: "Node.js", level: "expert" },
|
|
||||||
{ name: "Express", level: "expert" },
|
|
||||||
{ name: "Linux", level: "expert" },
|
|
||||||
{ name: "Docker", level: "proficient" },
|
|
||||||
{ name: "TUI / CLI", level: "expert" },
|
|
||||||
{ name: "Low-level Systems", level: "expert" },
|
|
||||||
{ name: "WebAssembly", level: "proficient" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Tools & Platforms",
|
|
||||||
terminalLabel: "tools/",
|
|
||||||
skills: [
|
|
||||||
{ name: "Git / GitHub", level: "expert" },
|
|
||||||
{ name: "Kali Linux", level: "proficient" },
|
|
||||||
{ name: "Neovim", level: "proficient" },
|
|
||||||
{ name: "PostgreSQL", level: "proficient" },
|
|
||||||
{ name: "Redis", level: "proficient" },
|
|
||||||
{ name: "Figma", level: "proficient" },
|
|
||||||
{ name: "Solana / Web3", level: "proficient" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const levelColors: Record<string, string> = {
|
|
||||||
expert: "rgba(255,255,255,0.12)",
|
|
||||||
proficient: "rgba(255,255,255,0.06)",
|
|
||||||
learning: "rgba(255,255,255,0.03)",
|
|
||||||
};
|
|
||||||
|
|
||||||
const levelBorders: Record<string, string> = {
|
|
||||||
expert: "rgba(255,255,255,0.2)",
|
|
||||||
proficient: "rgba(255,255,255,0.1)",
|
|
||||||
learning: "rgba(255,255,255,0.06)",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function SkillsSection() {
|
|
||||||
const headerRef = useReveal();
|
|
||||||
const contentRef = useReveal(0.05);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
id="skills"
|
|
||||||
className="py-32 relative"
|
|
||||||
style={{ background: "#080808" }}
|
|
||||||
>
|
|
||||||
<div className="absolute top-0 left-0 right-0 h-px bg-linear-to-r from-transparent via-white/10 to-transparent" />
|
|
||||||
|
|
||||||
<div className="max-w-6xl mx-auto px-6">
|
|
||||||
{/* Header */}
|
|
||||||
<div ref={headerRef} className="reveal mb-16">
|
|
||||||
<div className="terminal-label mb-4">> cat skills.json</div>
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-end justify-between gap-4">
|
|
||||||
<h2 className="section-heading text-4xl sm:text-5xl text-white leading-tight">
|
|
||||||
Tech I Work <span className="text-white/40">With</span>
|
|
||||||
</h2>
|
|
||||||
<div className="flex items-center gap-4 terminal-label text-xs">
|
|
||||||
<span className="flex items-center gap-1.5">
|
|
||||||
<span className="w-2 h-2 rounded-full inline-block bg-white/50" />
|
|
||||||
Expert
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center gap-1.5">
|
|
||||||
<span className="w-2 h-2 rounded-full inline-block bg-white/25" />
|
|
||||||
Proficient
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center gap-1.5">
|
|
||||||
<span className="w-2 h-2 rounded-full inline-block bg-white/10" />
|
|
||||||
Learning
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div ref={contentRef} className="reveal">
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
||||||
{/* Left: skill categories */}
|
|
||||||
<div className="lg:col-span-2 space-y-8">
|
|
||||||
{skillCategories.map((cat) => (
|
|
||||||
<div key={cat.label}>
|
|
||||||
<div className="flex items-center gap-3 mb-4">
|
|
||||||
<span className="terminal-label text-xs">
|
|
||||||
{cat.terminalLabel}
|
|
||||||
</span>
|
|
||||||
<div className="flex-1 h-px bg-white/5" />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{cat.skills.map((skill) => (
|
|
||||||
<span
|
|
||||||
key={skill.name}
|
|
||||||
className="skill-chip"
|
|
||||||
style={{
|
|
||||||
background: skill.level
|
|
||||||
? levelColors[skill.level]
|
|
||||||
: undefined,
|
|
||||||
borderColor: skill.level
|
|
||||||
? levelBorders[skill.level]
|
|
||||||
: undefined,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{skill.name}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right: code visual + interests */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Code visual */}
|
|
||||||
<div className="glass rounded-2xl overflow-hidden">
|
|
||||||
<img
|
|
||||||
src={CODE_VISUAL}
|
|
||||||
alt="Code visualization"
|
|
||||||
className="w-full h-48 object-cover opacity-70"
|
|
||||||
/>
|
|
||||||
<div className="p-4">
|
|
||||||
<div className="terminal-label text-xs mb-1">
|
|
||||||
$ git log --oneline
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="text-white/50 text-xs leading-relaxed"
|
|
||||||
style={{ fontFamily: "'JetBrains Mono', monospace" }}
|
|
||||||
>
|
|
||||||
<div>214 commits this month</div>
|
|
||||||
<div>2,670 contributions/year</div>
|
|
||||||
<div>35+ repositories</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Interests */}
|
|
||||||
<div className="glass rounded-2xl p-5">
|
|
||||||
<div className="terminal-label text-xs mb-4">
|
|
||||||
> interests/
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{[
|
|
||||||
{
|
|
||||||
icon: <ShieldCheckIcon size={20} />,
|
|
||||||
label: "Hacking & Security",
|
|
||||||
},
|
|
||||||
{ icon: <CpuIcon size={20} />, label: "Low-level Systems" },
|
|
||||||
{ icon: <PaletteIcon size={20} />, label: "UI/UX Design" },
|
|
||||||
{
|
|
||||||
icon: <RocketIcon size={20} />,
|
|
||||||
label: "SaaS & Startups",
|
|
||||||
},
|
|
||||||
{ icon: <ZapIcon size={20} />, label: "Rust Ecosystem" },
|
|
||||||
{
|
|
||||||
icon: <TerminalIcon size={20} />,
|
|
||||||
label: "Linux & Open Source",
|
|
||||||
},
|
|
||||||
].map((item) => (
|
|
||||||
<div
|
|
||||||
key={item.label}
|
|
||||||
className="flex items-center gap-2 text-sm text-white/60"
|
|
||||||
style={{ fontFamily: "'DM Sans', sans-serif" }}
|
|
||||||
>
|
|
||||||
<span className="text-base">{item.icon}</span>
|
|
||||||
{item.label}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import type { Status } from "@/content/projects";
|
||||||
|
|
||||||
|
const styles: Record<Status, string> = {
|
||||||
|
LIVE: "text-ink-2 border border-white/30",
|
||||||
|
BUILDING: "text-ink-2 border border-white/30",
|
||||||
|
CONCEPT: "text-ink-2 border border-dashed border-white/40",
|
||||||
|
ARCHIVED: "text-mute border border-white/12",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StatusTag({ status }: { status: Status }) {
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center gap-1.5 px-2 py-1 text-[9px] tracking-[0.2em] ${styles[status]}`}>
|
||||||
|
{status === "LIVE" && <span aria-hidden="true" className="size-[5px] rounded-full bg-accent" />}
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useSyncExternalStore } from "react";
|
||||||
|
|
||||||
|
// Real data: when this session opened, a random per-session hash, and the
|
||||||
|
// visitor's local clock. All of it is client-only and never leaves the page.
|
||||||
|
|
||||||
|
type Session = { start: number; hash: string };
|
||||||
|
let session: Session | null = null;
|
||||||
|
|
||||||
|
function getSession(): Session {
|
||||||
|
if (session) return session;
|
||||||
|
try {
|
||||||
|
const raw = sessionStorage.getItem("session");
|
||||||
|
if (raw) return (session = JSON.parse(raw) as Session);
|
||||||
|
} catch {}
|
||||||
|
const bytes = crypto.getRandomValues(new Uint8Array(8));
|
||||||
|
const hash = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
||||||
|
session = { start: Date.now(), hash };
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem("session", JSON.stringify(session));
|
||||||
|
} catch {}
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
const noop = () => () => {};
|
||||||
|
const tick = (cb: () => void) => {
|
||||||
|
const id = setInterval(cb, 1000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
};
|
||||||
|
const nowSeconds = () => Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
export function useSession() {
|
||||||
|
return useSyncExternalStore(noop, getSession, () => null);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNow() {
|
||||||
|
const s = useSyncExternalStore(tick, nowSeconds, () => null);
|
||||||
|
return s === null ? null : new Date(s * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
const p = (n: number) => String(n).padStart(2, "0");
|
||||||
|
|
||||||
|
export function formatTime(d: Date) {
|
||||||
|
return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatOffset(d: Date) {
|
||||||
|
const off = -d.getTimezoneOffset();
|
||||||
|
const a = Math.abs(off);
|
||||||
|
return `UTC${off >= 0 ? "+" : "-"}${p(Math.floor(a / 60))}:${p(a % 60)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatElapsed(ms: number) {
|
||||||
|
const s = Math.max(0, Math.floor(ms / 1000));
|
||||||
|
return `${p(Math.floor(s / 60))}:${p(s % 60)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SessionClock() {
|
||||||
|
const s = useSession();
|
||||||
|
const now = useNow();
|
||||||
|
const id = s ? `${s.hash.slice(0, 4)}·${s.hash.slice(4, 8)}`.toUpperCase() : "····";
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div>SESSION {id}</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-accent">● LIVE</span> T+{s && now ? formatElapsed(now.getTime() - s.start) : "--:--"}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SessionStamp() {
|
||||||
|
const s = useSession();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div>SESSION OPEN · {s ? new Date(s.start).toISOString().slice(0, 19) + "Z" : "…"}</div>
|
||||||
|
<div>HASH {s ? `${s.hash.slice(0, 8)}·${s.hash.slice(8)}` : "…"} · RES CITY-SCALE</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LocalTime({ prefix = "LOCAL" }: { prefix?: string }) {
|
||||||
|
const now = useNow();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{prefix} {now ? `${formatTime(now)} ${formatOffset(now)}` : "--:--:--"}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* Hero background, bottom to top: terrain contours (real Dajt elevation data),
|
||||||
|
* grid, scanlines, vignette. Purely decorative and deliberately static.
|
||||||
|
*/
|
||||||
|
export function TerrainLayer({ className = "" }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<div aria-hidden="true" className={`pointer-events-none absolute inset-0 overflow-hidden ${className}`}>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element -- decorative SVG, no optimisation needed */}
|
||||||
|
<img
|
||||||
|
src="/terrain.svg"
|
||||||
|
alt=""
|
||||||
|
className="absolute -inset-10 h-[calc(100%+80px)] w-[calc(100%+80px)] max-w-none object-cover opacity-20 max-xl:object-[60%_50%]"
|
||||||
|
/>
|
||||||
|
<div className="grid-bg absolute inset-0" />
|
||||||
|
<div className="scan-bg absolute inset-0" />
|
||||||
|
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_55%_35%,transparent_35%,rgb(0_0_0/0.75)_100%)]" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* Stylised Tirana city centre, drawn by hand at city scale only.
|
||||||
|
* Centred on Skanderbeg Square. No street-level detail beyond that.
|
||||||
|
*/
|
||||||
|
export function TiranaMap({ className = "" }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 640 640" aria-hidden="true" className={className}>
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="map-fade">
|
||||||
|
<stop offset="0.45" stopColor="#fff" />
|
||||||
|
<stop offset="1" stopColor="#000" />
|
||||||
|
</radialGradient>
|
||||||
|
<mask id="map-mask">
|
||||||
|
<rect width="640" height="640" fill="url(#map-fade)" />
|
||||||
|
</mask>
|
||||||
|
</defs>
|
||||||
|
<g mask="url(#map-mask)" fill="none" stroke="#fff" strokeWidth="1" className="draw">
|
||||||
|
<circle pathLength={1} cx="320" cy="320" r="300" strokeOpacity="0.08" style={{ "--d": "0.1s" } as React.CSSProperties} />
|
||||||
|
<path pathLength={1} strokeOpacity="0.14" d="M150,250 C170,170 250,130 330,135 C420,140 495,190 505,280 C515,370 470,450 380,480 C290,505 190,480 155,400 C135,350 138,300 150,250Z" />
|
||||||
|
<path pathLength={1} strokeOpacity="0.18" d="M320,640 L320,354" />
|
||||||
|
<path pathLength={1} strokeOpacity="0.18" d="M320,286 L310,0" />
|
||||||
|
<path pathLength={1} strokeOpacity="0.13" d="M286,316 L0,262" />
|
||||||
|
<path pathLength={1} strokeOpacity="0.13" d="M354,322 L640,352" />
|
||||||
|
<path pathLength={1} strokeOpacity="0.08" d="M296,296 L120,120" style={{ "--d": "0.2s" } as React.CSSProperties} />
|
||||||
|
<path pathLength={1} strokeOpacity="0.08" d="M346,344 L560,560" style={{ "--d": "0.2s" } as React.CSSProperties} />
|
||||||
|
<path pathLength={1} strokeOpacity="0.08" d="M346,296 L540,110" style={{ "--d": "0.2s" } as React.CSSProperties} />
|
||||||
|
<path pathLength={1} strokeOpacity="0.08" d="M294,346 L90,560" style={{ "--d": "0.2s" } as React.CSSProperties} />
|
||||||
|
<path pathLength={1} strokeOpacity="0.14" d="M0,470 C110,452 200,492 320,482 C440,472 530,500 640,490" />
|
||||||
|
<path pathLength={1} strokeOpacity="0.14" d="M0,478 C110,460 200,500 320,490 C440,480 530,508 640,498" />
|
||||||
|
<g strokeOpacity="0.07" style={{ "--d": "0.35s" } as React.CSSProperties}>
|
||||||
|
{[
|
||||||
|
[360, 200, 60, 44], [432, 212, 46, 56], [360, 380, 70, 40], [208, 372, 64, 50], [200, 212, 56, 40],
|
||||||
|
[442, 380, 44, 60], [232, 150, 40, 34], [380, 150, 54, 30], [236, 268, 40, 30], [364, 262, 34, 34],
|
||||||
|
].map(([x, y, w, h]) => (
|
||||||
|
<rect key={`${x}-${y}`} pathLength={1} x={x} y={y} width={w} height={h} style={{ "--d": "0.35s" } as React.CSSProperties} />
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
<circle pathLength={1} cx="320" cy="320" r="34" strokeOpacity="0.24" style={{ "--d": "0.4s" } as React.CSSProperties} />
|
||||||
|
<circle pathLength={1} cx="320" cy="320" r="16" strokeOpacity="0.12" style={{ "--d": "0.45s" } as React.CSSProperties} />
|
||||||
|
</g>
|
||||||
|
<text x="362" y="352" fill="#8b9296" fillOpacity="0.7" fontSize="9" letterSpacing="1.6" fontFamily="var(--font-mono)">
|
||||||
|
SKANDERBEG SQ.
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LockReticle({ className = "", style }: { className?: string; style?: React.CSSProperties }) {
|
||||||
|
return (
|
||||||
|
<div aria-hidden="true" className={`bracket size-[120px] [--bc:var(--color-accent)] [--bl:16px] ${className}`} style={style}>
|
||||||
|
<svg viewBox="0 0 120 120" width="120" height="120" fill="none" stroke="var(--color-accent)">
|
||||||
|
<circle className="spin" cx="60" cy="60" r="34" strokeDasharray="3 5" strokeOpacity="0.8" />
|
||||||
|
<circle cx="60" cy="60" r="20" strokeOpacity="0.35" />
|
||||||
|
<path d="M60,14 L60,30 M60,90 L60,106 M14,60 L30,60 M90,60 L106,60" />
|
||||||
|
<circle cx="60" cy="60" r="2.5" fill="var(--color-accent)" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import * as React from "react";
|
|
||||||
import { Slot } from "@radix-ui/react-slot";
|
|
||||||
import { cva, type VariantProps } from "class-variance-authority";
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
const buttonVariants = cva(
|
|
||||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
variant: {
|
|
||||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
|
||||||
destructive:
|
|
||||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
|
||||||
outline:
|
|
||||||
"border bg-transparent shadow-xs hover:bg-accent dark:bg-transparent dark:border-input dark:hover:bg-input/50",
|
|
||||||
secondary:
|
|
||||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
|
||||||
ghost:
|
|
||||||
"hover:bg-accent dark:hover:bg-accent/50",
|
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
|
||||||
},
|
|
||||||
size: {
|
|
||||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
|
||||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
|
||||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
|
||||||
icon: "size-9",
|
|
||||||
"icon-sm": "size-8",
|
|
||||||
"icon-lg": "size-10",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
variant: "default",
|
|
||||||
size: "default",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
function Button({
|
|
||||||
className,
|
|
||||||
variant,
|
|
||||||
size,
|
|
||||||
asChild = false,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"button"> &
|
|
||||||
VariantProps<typeof buttonVariants> & {
|
|
||||||
asChild?: boolean;
|
|
||||||
}) {
|
|
||||||
const Comp = asChild ? Slot : "button";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Comp
|
|
||||||
data-slot="button"
|
|
||||||
className={cn(buttonVariants({ variant, size, className }))}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Button, buttonVariants };
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
import * as React from "react";
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="card"
|
|
||||||
className={cn(
|
|
||||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="card-header"
|
|
||||||
className={cn(
|
|
||||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="card-title"
|
|
||||||
className={cn("leading-none font-semibold", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="card-description"
|
|
||||||
className={cn("text-muted-foreground text-sm", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="card-action"
|
|
||||||
className={cn(
|
|
||||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="card-content"
|
|
||||||
className={cn("px-6", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="card-footer"
|
|
||||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
Card,
|
|
||||||
CardHeader,
|
|
||||||
CardFooter,
|
|
||||||
CardTitle,
|
|
||||||
CardAction,
|
|
||||||
CardDescription,
|
|
||||||
CardContent,
|
|
||||||
};
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import { useTheme } from "next-themes";
|
|
||||||
import { Toaster as Sonner, type ToasterProps } from "sonner";
|
|
||||||
|
|
||||||
const Toaster = ({ ...props }: ToasterProps) => {
|
|
||||||
const { theme = "system" } = useTheme();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Sonner
|
|
||||||
theme={theme as ToasterProps["theme"]}
|
|
||||||
className="toaster group"
|
|
||||||
style={
|
|
||||||
{
|
|
||||||
"--normal-bg": "var(--popover)",
|
|
||||||
"--normal-text": "var(--popover-foreground)",
|
|
||||||
"--normal-border": "var(--border)",
|
|
||||||
} as React.CSSProperties
|
|
||||||
}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export { Toaster };
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
import * as React from "react";
|
|
||||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
function TooltipProvider({
|
|
||||||
delayDuration = 0,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
|
||||||
return (
|
|
||||||
<TooltipPrimitive.Provider
|
|
||||||
data-slot="tooltip-provider"
|
|
||||||
delayDuration={delayDuration}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Tooltip({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
|
||||||
return (
|
|
||||||
<TooltipProvider>
|
|
||||||
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
|
||||||
</TooltipProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TooltipTrigger({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
|
||||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function TooltipContent({
|
|
||||||
className,
|
|
||||||
sideOffset = 0,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
|
||||||
return (
|
|
||||||
<TooltipPrimitive.Portal>
|
|
||||||
<TooltipPrimitive.Content
|
|
||||||
data-slot="tooltip-content"
|
|
||||||
sideOffset={sideOffset}
|
|
||||||
className={cn(
|
|
||||||
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px]" />
|
|
||||||
</TooltipPrimitive.Content>
|
|
||||||
</TooltipPrimitive.Portal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
export { COOKIE_NAME, ONE_YEAR_MS } from "@shared/const";
|
|
||||||
|
|
||||||
// Generate login URL at runtime so redirect URI reflects the current origin.
|
|
||||||
export const getLoginUrl = () => {
|
|
||||||
const oauthPortalUrl = import.meta.env.VITE_OAUTH_PORTAL_URL;
|
|
||||||
const appId = import.meta.env.VITE_APP_ID;
|
|
||||||
const redirectUri = `${window.location.origin}/api/oauth/callback`;
|
|
||||||
const state = btoa(redirectUri);
|
|
||||||
|
|
||||||
const url = new URL(`${oauthPortalUrl}/app-auth`);
|
|
||||||
url.searchParams.set("appId", appId);
|
|
||||||
url.searchParams.set("redirectUri", redirectUri);
|
|
||||||
url.searchParams.set("state", state);
|
|
||||||
url.searchParams.set("type", "signIn");
|
|
||||||
|
|
||||||
return url.toString();
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { projectById, type Status } from "./projects";
|
||||||
|
|
||||||
|
/** Proof statuses always come from the project list, so they can't drift. */
|
||||||
|
const statusOf = (id: string): Status => projectById(id)!.status;
|
||||||
|
|
||||||
|
export type Proof = {
|
||||||
|
label: string;
|
||||||
|
note: string;
|
||||||
|
href?: string;
|
||||||
|
status?: Status;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DossierFile = {
|
||||||
|
slug: string;
|
||||||
|
no: string;
|
||||||
|
title: string;
|
||||||
|
summary: string;
|
||||||
|
proofSummary: string;
|
||||||
|
lead: string;
|
||||||
|
paragraphs: string[];
|
||||||
|
proof: Proof[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// PLACEHOLDER: all copy below is a first draft from the spec. Edit freely.
|
||||||
|
export const dossier: DossierFile[] = [
|
||||||
|
{
|
||||||
|
slug: "leadership",
|
||||||
|
no: "01",
|
||||||
|
title: "Leadership",
|
||||||
|
summary: "Leading groups since I was ten. Clans first, then Orus.",
|
||||||
|
proofSummary: "Orus",
|
||||||
|
lead: "Leading groups and getting people to follow, since I was ten.",
|
||||||
|
paragraphs: [
|
||||||
|
"It started with gaming clans at 10 and 11, and the content around them.",
|
||||||
|
"The same instinct became Orus at 12 and 13.",
|
||||||
|
],
|
||||||
|
proof: [
|
||||||
|
{
|
||||||
|
label: "Orus",
|
||||||
|
note: "My open-source brand for privacy and opsec tools",
|
||||||
|
href: "/projects/#orus",
|
||||||
|
status: statusOf("orus"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "entrepreneurship",
|
||||||
|
no: "02",
|
||||||
|
title: "Entrepreneurship",
|
||||||
|
summary: "Brands and ventures. Systems where incentives and behavior matter.",
|
||||||
|
proofSummary: "Osias · Orus · EquitySMP",
|
||||||
|
lead: "I build brands, and ventures that stand on their own.",
|
||||||
|
paragraphs: [
|
||||||
|
"Orus is one of the brands: open-source software for privacy, independence, and opsec. Outside it I design systems where incentives and behavior matter.",
|
||||||
|
"Osias is a blockchain-powered AI agent for trading emotions and behavior. Alongside it sits a Minecraft economy concept with jurisdictions, taxes, and IPOs.",
|
||||||
|
],
|
||||||
|
proof: [
|
||||||
|
{ label: "Osias", note: "osias.trade", href: "https://osias.trade", status: statusOf("osias") },
|
||||||
|
{ label: "Orus", note: "Open-source privacy and opsec brand", href: "/projects/#orus", status: statusOf("orus") },
|
||||||
|
{ label: "EquitySMP", note: "Real-world-style server economy", href: "/projects/#equitysmp", status: statusOf("equitysmp") },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "builder",
|
||||||
|
no: "03",
|
||||||
|
title: "Builder",
|
||||||
|
summary: "The interesting problems sit one layer lower than where most people stop.",
|
||||||
|
proofSummary: "Saturn · Enclave · Gitea",
|
||||||
|
lead: "I don’t accept the layer underneath as a given.",
|
||||||
|
paragraphs: [
|
||||||
|
"I wrote my own client for Minecraft (Saturn). I’m designing a transport protocol to replace HTTP and WebSockets for Enclave’s chat. I host my own Git.",
|
||||||
|
"The interesting problems are usually one layer lower than where most people stop.",
|
||||||
|
],
|
||||||
|
proof: [
|
||||||
|
{ label: "Saturn", note: "Minecraft client, written from scratch", href: "/projects/#saturn", status: statusOf("saturn") },
|
||||||
|
{ label: "Enclave + transport protocol", note: "Chat platform, and the protocol it runs on", href: "/projects/#enclave", status: statusOf("enclave") },
|
||||||
|
{ label: "git.selimaj.dev", note: "Gitea on a Docker and Coolify homelab", href: "https://git.selimaj.dev", status: statusOf("git") },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "discipline",
|
||||||
|
no: "04",
|
||||||
|
title: "Discipline",
|
||||||
|
summary: "Formerly #1 on Discipline in Albania. Consistency, tracked in public.",
|
||||||
|
proofSummary: "Contribution graph · rank",
|
||||||
|
lead: "Formerly ranked #1 on Discipline in Albania.",
|
||||||
|
paragraphs: [
|
||||||
|
"I track consistency through contribution graphs.",
|
||||||
|
"A long project list reads as sustained output, not abandoned experiments.",
|
||||||
|
],
|
||||||
|
proof: [
|
||||||
|
{ label: "Contribution graph", note: "github.com/selimaj-dev", href: "https://github.com/selimaj-dev" },
|
||||||
|
{ label: "Ranking", note: "Formerly #1 on Discipline, Albania" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "passions",
|
||||||
|
no: "05",
|
||||||
|
title: "Passions",
|
||||||
|
summary: "Cyberdecks, long-range RC planes, algorithmic trading, Minecraft with friends.",
|
||||||
|
proofSummary: "Build logs",
|
||||||
|
lead: "The things I do because I can’t not.",
|
||||||
|
paragraphs: [
|
||||||
|
"Custom hardware: a briefcase cyberdeck, and a compact one that aims to be a Flipper Zero but better.",
|
||||||
|
"Long-range PC and 4G-controlled RC planes built around a custom watercooled powertrain. Algorithmic trading. Minecraft with friends.",
|
||||||
|
],
|
||||||
|
proof: [
|
||||||
|
{ label: "Cyberdecks", note: "Build photos and logs to come", href: "/projects/#briefcase-deck", status: statusOf("briefcase-deck") },
|
||||||
|
{ label: "RC planes", note: "Build photos and logs to come", href: "/projects/#rc-planes", status: statusOf("rc-planes") },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const fileBySlug = (slug: string) => dossier.find((f) => f.slug === slug);
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* Everything personal or placeholder lives in src/content/.
|
||||||
|
* Items marked PLACEHOLDER are meant to be swapped later.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const profile = {
|
||||||
|
// PLACEHOLDER: final display form of the name is still TBD.
|
||||||
|
displayName: "Selimaj",
|
||||||
|
title: "Entrepreneur & Builder",
|
||||||
|
stance: "I build the layer underneath.",
|
||||||
|
// Replace this one file to swap the profile picture (the favicon is src/app/icon.png).
|
||||||
|
pfp: "/profile/Mono.png",
|
||||||
|
pfpIsPlaceholder: false,
|
||||||
|
github: "https://github.com/selimaj-dev",
|
||||||
|
git: "https://git.selimaj.dev",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map lock. Privacy rule: city centre only (Skanderbeg Square, a public
|
||||||
|
* landmark). Never put anything finer than city scale here.
|
||||||
|
*/
|
||||||
|
export const lock = {
|
||||||
|
lat: "41.3275 N",
|
||||||
|
lon: "19.8187 E",
|
||||||
|
place: "Tirana · City centre",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type DossierField = {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
links?: { label: string; href: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dossierFields: DossierField[] = [
|
||||||
|
{ label: "Based", value: "Tirana, Albania" },
|
||||||
|
{ label: "Brands", value: "Orus" },
|
||||||
|
// PLACEHOLDER decision: remove this entry to stop stating it publicly.
|
||||||
|
{ label: "Age", value: "16" },
|
||||||
|
{ label: "Focus", value: "Privacy, independence, opsec, dev tooling" },
|
||||||
|
{ label: "Known for", value: "Saturn, Enclave" },
|
||||||
|
{ label: "Record", value: "Formerly #1 on Discipline, Albania" },
|
||||||
|
{
|
||||||
|
label: "Links",
|
||||||
|
value: "",
|
||||||
|
links: [
|
||||||
|
{ label: "GitHub", href: profile.github },
|
||||||
|
{ label: "git.selimaj.dev", href: profile.git },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
export type Status = "LIVE" | "BUILDING" | "CONCEPT" | "ARCHIVED";
|
||||||
|
|
||||||
|
export type GroupId = "orus" | "ventures" | "infra" | "hardware";
|
||||||
|
|
||||||
|
export type Project = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
group: GroupId;
|
||||||
|
status: Status;
|
||||||
|
desc: string;
|
||||||
|
tags: string[];
|
||||||
|
href?: string;
|
||||||
|
/** Position on the 1200×720 node map. */
|
||||||
|
map: { x: number; y: number };
|
||||||
|
/** Which side of the marker the label sits on (defaults to away from its hub). */
|
||||||
|
label?: "l" | "r";
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The map's root: the site itself. Not a project. */
|
||||||
|
export const root = {
|
||||||
|
name: "Selimaj",
|
||||||
|
sub: "selimaj.dev",
|
||||||
|
map: { x: 600, y: 360 },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Groups branch off the root. A group with `node` uses that project as its hub
|
||||||
|
* (Orus is a brand with its own projects); the rest get a plain labelled hub.
|
||||||
|
*/
|
||||||
|
export const groups: Record<
|
||||||
|
GroupId,
|
||||||
|
{ label: string; x: number; y: number; side: "l" | "r"; node?: string }
|
||||||
|
> = {
|
||||||
|
orus: { label: "Orus", x: 340, y: 240, side: "r", node: "orus" },
|
||||||
|
ventures: { label: "Ventures & systems", x: 840, y: 220, side: "r" },
|
||||||
|
infra: { label: "Infrastructure", x: 360, y: 520, side: "l" },
|
||||||
|
hardware: { label: "Hardware", x: 840, y: 520, side: "r" },
|
||||||
|
};
|
||||||
|
|
||||||
|
// PLACEHOLDER: statuses other than Orus and the self-hosted infrastructure are
|
||||||
|
// best guesses. Links are filled in where known.
|
||||||
|
export const projects: Project[] = [
|
||||||
|
{
|
||||||
|
id: "orus",
|
||||||
|
name: "Orus",
|
||||||
|
group: "orus",
|
||||||
|
status: "LIVE",
|
||||||
|
desc: "One of my brands: open-source software for privacy, independence and opsec. Home to Enclave, Saturn and the cyberdecks.",
|
||||||
|
tags: ["brand", "oss", "privacy"],
|
||||||
|
map: { x: 340, y: 240 },
|
||||||
|
label: "r",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "saturn",
|
||||||
|
name: "Saturn",
|
||||||
|
group: "orus",
|
||||||
|
status: "BUILDING",
|
||||||
|
desc: "Highly advanced and customizable Minecraft client with free emotes and cosmetics",
|
||||||
|
tags: ["minecraft", "client"],
|
||||||
|
map: { x: 190, y: 100 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "enclave",
|
||||||
|
name: "Enclave",
|
||||||
|
group: "orus",
|
||||||
|
status: "BUILDING",
|
||||||
|
desc: "Discord-like chat platform, running on a custom transport protocol.",
|
||||||
|
tags: ["chat", "realtime"],
|
||||||
|
map: { x: 430, y: 80 },
|
||||||
|
label: "r",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "enclave-protocol",
|
||||||
|
name: "Enclave protocol",
|
||||||
|
group: "orus",
|
||||||
|
status: "CONCEPT",
|
||||||
|
desc: "Transport protocol replacing HTTP and WSS; Enclave’s chat protocol runs on top of it. Concept and design notes.",
|
||||||
|
tags: ["networking", "protocol design"],
|
||||||
|
map: { x: 200, y: 330 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "briefcase-deck",
|
||||||
|
name: "Briefcase deck",
|
||||||
|
group: "orus",
|
||||||
|
status: "CONCEPT",
|
||||||
|
desc: "Briefcase cyberdeck: the most versatile and doable build.",
|
||||||
|
tags: ["hardware", "cyberdeck"],
|
||||||
|
map: { x: 160, y: 215 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "compact-deck",
|
||||||
|
name: "Compact deck",
|
||||||
|
group: "orus",
|
||||||
|
status: "CONCEPT",
|
||||||
|
desc: "Compact premium cyberdeck: a Flipper Zero, but better.",
|
||||||
|
tags: ["hardware", "cyberdeck"],
|
||||||
|
map: { x: 290, y: 400 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "osias",
|
||||||
|
name: "Osias",
|
||||||
|
group: "ventures",
|
||||||
|
status: "BUILDING",
|
||||||
|
desc: "Blockchain-powered AI agent for trading emotions and behavior.",
|
||||||
|
tags: ["ai", "blockchain"],
|
||||||
|
href: "https://osias.trade",
|
||||||
|
map: { x: 700, y: 90 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "equitysmp",
|
||||||
|
name: "EquitySMP",
|
||||||
|
group: "ventures",
|
||||||
|
status: "BUILDING",
|
||||||
|
desc: "Minecraft server built around an advanced real-world-style economy.",
|
||||||
|
tags: ["minecraft", "economy"],
|
||||||
|
map: { x: 990, y: 90 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "mc-economy",
|
||||||
|
name: "MC economy",
|
||||||
|
group: "ventures",
|
||||||
|
status: "CONCEPT",
|
||||||
|
desc: "Jurisdictions and taxes, world-healing tied to liquidity, trading and IPOs, sandbox GTA-style elements.",
|
||||||
|
tags: ["systems design", "economy"],
|
||||||
|
map: { x: 1020, y: 280 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "algo-trading",
|
||||||
|
name: "Algo trading",
|
||||||
|
group: "ventures",
|
||||||
|
status: "ARCHIVED",
|
||||||
|
desc: "Several repos exploring automated crypto trading strategies.",
|
||||||
|
tags: ["trading", "crypto"],
|
||||||
|
map: { x: 980, y: 400 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "git",
|
||||||
|
name: "Self-hosted Git",
|
||||||
|
group: "infra",
|
||||||
|
status: "LIVE",
|
||||||
|
desc: "Gitea at git.selimaj.dev, on a Docker and Coolify homelab server behind Cloudflare.",
|
||||||
|
tags: ["gitea", "docker", "coolify"],
|
||||||
|
href: "https://git.selimaj.dev",
|
||||||
|
map: { x: 190, y: 470 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "homelab",
|
||||||
|
name: "HomeLab",
|
||||||
|
group: "infra",
|
||||||
|
status: "LIVE",
|
||||||
|
desc: "The server environment everything self-hosted runs on.",
|
||||||
|
tags: ["infra", "docker"],
|
||||||
|
map: { x: 300, y: 660 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "rc-planes",
|
||||||
|
name: "RC planes",
|
||||||
|
group: "hardware",
|
||||||
|
status: "CONCEPT",
|
||||||
|
desc: "Custom long-range, PC and 4G-controlled planes pushing speed and efficiency, built around a custom watercooled powertrain.",
|
||||||
|
tags: ["hardware", "rc", "4g"],
|
||||||
|
map: { x: 1000, y: 620 },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const projectById = (id: string) => projects.find((p) => p.id === id);
|
||||||
|
|
||||||
|
/** The brand a project is published under, if any. */
|
||||||
|
export const brandOf = (p: Project) =>
|
||||||
|
p.group === "orus" && p.id !== "orus" ? "Orus" : undefined;
|
||||||
|
|
||||||
|
/** Home page featured strip, branching from the root. Positions are % of the stage. */
|
||||||
|
export const featuredRoot = { x: 16, y: 50 };
|
||||||
|
export const featured = [
|
||||||
|
{ id: "orus", x: 42, y: 16 },
|
||||||
|
{ id: "enclave", x: 66, y: 36 },
|
||||||
|
{ id: "saturn", x: 62, y: 64 },
|
||||||
|
{ id: "osias", x: 40, y: 84 },
|
||||||
|
] as const;
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
import React, { createContext, useContext, useEffect, useState } from "react";
|
|
||||||
|
|
||||||
type Theme = "light" | "dark";
|
|
||||||
|
|
||||||
interface ThemeContextType {
|
|
||||||
theme: Theme;
|
|
||||||
toggleTheme?: () => void;
|
|
||||||
switchable: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
|
||||||
|
|
||||||
interface ThemeProviderProps {
|
|
||||||
children: React.ReactNode;
|
|
||||||
defaultTheme?: Theme;
|
|
||||||
switchable?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ThemeProvider({
|
|
||||||
children,
|
|
||||||
defaultTheme = "light",
|
|
||||||
switchable = false,
|
|
||||||
}: ThemeProviderProps) {
|
|
||||||
const [theme, setTheme] = useState<Theme>(() => {
|
|
||||||
if (switchable) {
|
|
||||||
const stored = localStorage.getItem("theme");
|
|
||||||
return (stored as Theme) || defaultTheme;
|
|
||||||
}
|
|
||||||
return defaultTheme;
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const root = document.documentElement;
|
|
||||||
if (theme === "dark") {
|
|
||||||
root.classList.add("dark");
|
|
||||||
} else {
|
|
||||||
root.classList.remove("dark");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (switchable) {
|
|
||||||
localStorage.setItem("theme", theme);
|
|
||||||
}
|
|
||||||
}, [theme, switchable]);
|
|
||||||
|
|
||||||
const toggleTheme = switchable
|
|
||||||
? () => {
|
|
||||||
setTheme(prev => (prev === "light" ? "dark" : "light"));
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ThemeContext.Provider value={{ theme, toggleTheme, switchable }}>
|
|
||||||
{children}
|
|
||||||
</ThemeContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useTheme() {
|
|
||||||
const context = useContext(ThemeContext);
|
|
||||||
if (!context) {
|
|
||||||
throw new Error("useTheme must be used within ThemeProvider");
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import * as React from "react";
|
|
||||||
|
|
||||||
const MOBILE_BREAKPOINT = 768;
|
|
||||||
|
|
||||||
export function useIsMobile() {
|
|
||||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
|
|
||||||
undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
|
|
||||||
const onChange = () => {
|
|
||||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
|
||||||
};
|
|
||||||
mql.addEventListener("change", onChange);
|
|
||||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
|
||||||
return () => mql.removeEventListener("change", onChange);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return !!isMobile;
|
|
||||||
}
|
|
||||||
-280
@@ -1,280 +0,0 @@
|
|||||||
/*
|
|
||||||
* DESIGN SYSTEM — selimaj.dev
|
|
||||||
* Philosophy: Glassmorphic Terminal
|
|
||||||
* Background: near-black (#080808), Glass cards: rgba(255,255,255,0.04) + blur
|
|
||||||
* Typography: Syne (headings) + JetBrains Mono (labels/code) + Geist (body)
|
|
||||||
* Accent: White only — no color distractions
|
|
||||||
*/
|
|
||||||
|
|
||||||
@import "tailwindcss";
|
|
||||||
@import "tw-animate-css";
|
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
|
||||||
|
|
||||||
@theme inline {
|
|
||||||
--radius-sm: calc(var(--radius) - 4px);
|
|
||||||
--radius-md: calc(var(--radius) - 2px);
|
|
||||||
--radius-lg: var(--radius);
|
|
||||||
--radius-xl: calc(var(--radius) + 4px);
|
|
||||||
--color-background: var(--background);
|
|
||||||
--color-foreground: var(--foreground);
|
|
||||||
--color-card: var(--card);
|
|
||||||
--color-card-foreground: var(--card-foreground);
|
|
||||||
--color-popover: var(--popover);
|
|
||||||
--color-popover-foreground: var(--popover-foreground);
|
|
||||||
--color-primary: var(--primary);
|
|
||||||
--color-primary-foreground: var(--primary-foreground);
|
|
||||||
--color-secondary: var(--secondary);
|
|
||||||
--color-secondary-foreground: var(--secondary-foreground);
|
|
||||||
--color-muted: var(--muted);
|
|
||||||
--color-muted-foreground: var(--muted-foreground);
|
|
||||||
--color-accent: var(--accent);
|
|
||||||
--color-accent-foreground: var(--accent-foreground);
|
|
||||||
--color-destructive: var(--destructive);
|
|
||||||
--color-destructive-foreground: var(--destructive-foreground);
|
|
||||||
--color-border: var(--border);
|
|
||||||
--color-input: var(--input);
|
|
||||||
--color-ring: var(--ring);
|
|
||||||
}
|
|
||||||
|
|
||||||
:root {
|
|
||||||
--radius: 0.75rem;
|
|
||||||
--background: oklch(0.06 0 0);
|
|
||||||
--foreground: oklch(0.95 0 0);
|
|
||||||
--card: oklch(0.09 0 0);
|
|
||||||
--card-foreground: oklch(0.95 0 0);
|
|
||||||
--popover: oklch(0.09 0 0);
|
|
||||||
--popover-foreground: oklch(0.95 0 0);
|
|
||||||
--primary: oklch(0.95 0 0);
|
|
||||||
--primary-foreground: oklch(0.06 0 0);
|
|
||||||
--secondary: oklch(0.12 0 0);
|
|
||||||
--secondary-foreground: oklch(0.85 0 0);
|
|
||||||
--muted: oklch(0.14 0 0);
|
|
||||||
--muted-foreground: oklch(0.55 0 0);
|
|
||||||
--accent: oklch(0.14 0 0);
|
|
||||||
--accent-foreground: oklch(0.95 0 0);
|
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
|
||||||
--destructive-foreground: oklch(0.985 0 0);
|
|
||||||
--border: oklch(1 0 0 / 8%);
|
|
||||||
--input: oklch(1 0 0 / 10%);
|
|
||||||
--ring: oklch(0.95 0 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
@layer base {
|
|
||||||
* {
|
|
||||||
@apply border-border outline-ring/50;
|
|
||||||
}
|
|
||||||
html {
|
|
||||||
scroll-behavior: smooth;
|
|
||||||
}
|
|
||||||
body {
|
|
||||||
@apply bg-background text-foreground;
|
|
||||||
font-family: 'Geist', 'DM Sans', sans-serif;
|
|
||||||
background-color: #080808;
|
|
||||||
color: #f0f0f0;
|
|
||||||
overflow-x: hidden;
|
|
||||||
}
|
|
||||||
button:not(:disabled),
|
|
||||||
[role="button"]:not([aria-disabled="true"]),
|
|
||||||
[type="button"]:not(:disabled),
|
|
||||||
[type="submit"]:not(:disabled),
|
|
||||||
a[href],
|
|
||||||
select:not(:disabled) {
|
|
||||||
@apply cursor-pointer;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@layer components {
|
|
||||||
.container {
|
|
||||||
width: 100%;
|
|
||||||
margin-left: auto;
|
|
||||||
margin-right: auto;
|
|
||||||
padding-left: 1rem;
|
|
||||||
padding-right: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.flex {
|
|
||||||
min-height: 0;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
|
||||||
.container {
|
|
||||||
padding-left: 1.5rem;
|
|
||||||
padding-right: 1.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
.container {
|
|
||||||
padding-left: 2rem;
|
|
||||||
padding-right: 2rem;
|
|
||||||
max-width: 1280px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Glass card utility */
|
|
||||||
.glass {
|
|
||||||
background: rgba(255, 255, 255, 0.04);
|
|
||||||
backdrop-filter: blur(12px);
|
|
||||||
-webkit-backdrop-filter: blur(12px);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.glass-strong {
|
|
||||||
background: rgba(255, 255, 255, 0.07);
|
|
||||||
backdrop-filter: blur(20px);
|
|
||||||
-webkit-backdrop-filter: blur(20px);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Navbar glass */
|
|
||||||
.navbar-glass {
|
|
||||||
background: rgba(8, 8, 8, 0.6);
|
|
||||||
backdrop-filter: blur(20px);
|
|
||||||
-webkit-backdrop-filter: blur(20px);
|
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar-glass-solid {
|
|
||||||
background: rgba(8, 8, 8, 0.92);
|
|
||||||
backdrop-filter: blur(20px);
|
|
||||||
-webkit-backdrop-filter: blur(20px);
|
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Terminal label style */
|
|
||||||
.terminal-label {
|
|
||||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: rgba(255, 255, 255, 0.45);
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Section heading style */
|
|
||||||
.section-heading {
|
|
||||||
font-family: 'Syne', sans-serif;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Subtle glow on hover for cards */
|
|
||||||
.card-hover {
|
|
||||||
transition: border-color 0.3s ease, box-shadow 0.3s ease, transform 0.3s ease;
|
|
||||||
}
|
|
||||||
.card-hover:hover {
|
|
||||||
border-color: rgba(255, 255, 255, 0.2);
|
|
||||||
box-shadow: 0 0 30px rgba(255, 255, 255, 0.04), 0 8px 32px rgba(0,0,0,0.4);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Animated gradient text */
|
|
||||||
.gradient-text {
|
|
||||||
background: linear-gradient(135deg, #ffffff 0%, rgba(255,255,255,0.6) 100%);
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
background-clip: text;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Noise overlay */
|
|
||||||
.noise-overlay::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='0.03'/%3E%3C/svg%3E");
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Scroll reveal animation */
|
|
||||||
.reveal {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(24px);
|
|
||||||
transition: opacity 0.6s ease, transform 0.6s ease;
|
|
||||||
}
|
|
||||||
.reveal.visible {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Skill chip */
|
|
||||||
.skill-chip {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.375rem;
|
|
||||||
padding: 0.375rem 0.875rem;
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
||||||
border-radius: 9999px;
|
|
||||||
font-family: 'JetBrains Mono', monospace;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: rgba(255, 255, 255, 0.75);
|
|
||||||
transition: background 0.2s, border-color 0.2s, color 0.2s;
|
|
||||||
}
|
|
||||||
.skill-chip:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
border-color: rgba(255, 255, 255, 0.2);
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Cursor blink */
|
|
||||||
@keyframes blink {
|
|
||||||
0%, 100% { opacity: 1; }
|
|
||||||
50% { opacity: 0; }
|
|
||||||
}
|
|
||||||
.cursor-blink {
|
|
||||||
animation: blink 1s step-end infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Floating animation */
|
|
||||||
@keyframes float {
|
|
||||||
0%, 100% { transform: translateY(0px); }
|
|
||||||
50% { transform: translateY(-8px); }
|
|
||||||
}
|
|
||||||
.float-anim {
|
|
||||||
animation: float 4s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Fade in up */
|
|
||||||
@keyframes fadeInUp {
|
|
||||||
from { opacity: 0; transform: translateY(30px); }
|
|
||||||
to { opacity: 1; transform: translateY(0); }
|
|
||||||
}
|
|
||||||
.fade-in-up {
|
|
||||||
opacity: 0;
|
|
||||||
animation: fadeInUp 0.7s ease forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Stagger delays */
|
|
||||||
.delay-100 { animation-delay: 0.1s; }
|
|
||||||
.delay-200 { animation-delay: 0.2s; }
|
|
||||||
.delay-300 { animation-delay: 0.3s; }
|
|
||||||
.delay-400 { animation-delay: 0.4s; }
|
|
||||||
.delay-500 { animation-delay: 0.5s; }
|
|
||||||
.delay-600 { animation-delay: 0.6s; }
|
|
||||||
.delay-700 { animation-delay: 0.7s; }
|
|
||||||
.delay-800 { animation-delay: 0.8s; }
|
|
||||||
|
|
||||||
/* White dot separator */
|
|
||||||
.dot-sep::after {
|
|
||||||
content: '·';
|
|
||||||
margin: 0 0.5rem;
|
|
||||||
color: rgba(255,255,255,0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Project language badge */
|
|
||||||
.lang-badge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.25rem;
|
|
||||||
font-family: 'JetBrains Mono', monospace;
|
|
||||||
font-size: 0.7rem;
|
|
||||||
color: rgba(255,255,255,0.5);
|
|
||||||
}
|
|
||||||
.lang-dot {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import { clsx, type ClassValue } from "clsx";
|
|
||||||
import { twMerge } from "tailwind-merge";
|
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
|
||||||
return twMerge(clsx(inputs));
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { createRoot } from "react-dom/client";
|
|
||||||
import App from "./App";
|
|
||||||
import "./index.css";
|
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(<App />);
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
/*
|
|
||||||
* HOME PAGE — selimaj.dev
|
|
||||||
* Design: Glassmorphic Terminal
|
|
||||||
* Assembles all sections: Navbar, Hero, About, Projects, Skills, Contact, Footer
|
|
||||||
*/
|
|
||||||
|
|
||||||
import Navbar from "@/components/Navbar";
|
|
||||||
import HeroSection from "@/components/HeroSection";
|
|
||||||
import AboutSection from "@/components/AboutSection";
|
|
||||||
import ProjectsSection from "@/components/ProjectsSection";
|
|
||||||
import SkillsSection from "@/components/SkillsSection";
|
|
||||||
import ContactSection from "@/components/ContactSection";
|
|
||||||
import Footer from "@/components/Footer";
|
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="min-h-screen"
|
|
||||||
style={{ background: "#080808", color: "#f0f0f0" }}
|
|
||||||
>
|
|
||||||
<Navbar />
|
|
||||||
<HeroSection />
|
|
||||||
<AboutSection />
|
|
||||||
<ProjectsSection />
|
|
||||||
<SkillsSection />
|
|
||||||
<ContactSection />
|
|
||||||
<Footer />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
|
||||||
import { AlertCircle, Home } from "lucide-react";
|
|
||||||
import { useLocation } from "wouter";
|
|
||||||
|
|
||||||
export default function NotFound() {
|
|
||||||
const [, setLocation] = useLocation();
|
|
||||||
|
|
||||||
const handleGoHome = () => setLocation("/");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen w-full flex items-center justify-center bg-background">
|
|
||||||
<Card className="w-full max-w-lg mx-4 shadow-lg border-none">
|
|
||||||
<CardContent className="pt-8 pb-8 text-center">
|
|
||||||
<div className="flex justify-center mb-6">
|
|
||||||
<div className="relative">
|
|
||||||
<div className="absolute inset-0 rounded-full animate-pulse bg-destructive/20" />
|
|
||||||
<AlertCircle className="relative h-16 w-16 text-destructive" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h1 className="text-4xl font-bold text-foreground mb-2">404</h1>
|
|
||||||
|
|
||||||
<h2 className="text-xl font-semibold text-muted-foreground mb-4">
|
|
||||||
Page Not Found
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mb-8 leading-relaxed">
|
|
||||||
Sorry, the page you are looking for doesn't exist.
|
|
||||||
<br />
|
|
||||||
It may have been moved or deleted.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
|
||||||
<Button variant="default" size="lg" onClick={handleGoHome}>
|
|
||||||
<Home className="w-4 h-4 mr-2" />
|
|
||||||
Go Home
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
|
||||||
const plugin = require('tailwindcss/plugin');
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
darkMode: 'class', // supports .dark class
|
|
||||||
content: ['./src/**/*.{js,ts,jsx,tsx,mdx}'],
|
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
colors: {
|
|
||||||
background: 'var(--color-background)',
|
|
||||||
foreground: 'var(--color-foreground)',
|
|
||||||
card: 'var(--color-card)',
|
|
||||||
'card-foreground': 'var(--color-card-foreground)',
|
|
||||||
popover: 'var(--color-popover)',
|
|
||||||
'popover-foreground': 'var(--color-popover-foreground)',
|
|
||||||
primary: 'var(--color-primary)',
|
|
||||||
'primary-foreground': 'var(--color-primary-foreground)',
|
|
||||||
secondary: 'var(--color-secondary)',
|
|
||||||
'secondary-foreground': 'var(--color-secondary-foreground)',
|
|
||||||
muted: 'var(--color-muted)',
|
|
||||||
'muted-foreground': 'var(--color-muted-foreground)',
|
|
||||||
accent: 'var(--color-accent)',
|
|
||||||
'accent-foreground': 'var(--color-accent-foreground)',
|
|
||||||
destructive: 'var(--color-destructive)',
|
|
||||||
'destructive-foreground': 'var(--color-destructive-foreground)',
|
|
||||||
border: 'var(--color-border)',
|
|
||||||
input: 'var(--color-input)',
|
|
||||||
ring: 'var(--color-ring)',
|
|
||||||
},
|
|
||||||
borderRadius: {
|
|
||||||
sm: 'var(--radius-sm)',
|
|
||||||
md: 'var(--radius-md)',
|
|
||||||
lg: 'var(--radius-lg)',
|
|
||||||
xl: 'var(--radius-xl)',
|
|
||||||
},
|
|
||||||
fontFamily: {
|
|
||||||
heading: ['Syne', 'sans-serif'],
|
|
||||||
body: ['Geist', 'DM Sans', 'sans-serif'],
|
|
||||||
mono: ['JetBrains Mono', 'Fira Code', 'monospace'],
|
|
||||||
},
|
|
||||||
backdropBlur: {
|
|
||||||
sm: '12px',
|
|
||||||
md: '20px',
|
|
||||||
},
|
|
||||||
keyframes: {
|
|
||||||
blink: {
|
|
||||||
'0%, 100%': { opacity: '1' },
|
|
||||||
'50%': { opacity: '0' },
|
|
||||||
},
|
|
||||||
float: {
|
|
||||||
'0%, 100%': { transform: 'translateY(0px)' },
|
|
||||||
'50%': { transform: 'translateY(-8px)' },
|
|
||||||
},
|
|
||||||
fadeInUp: {
|
|
||||||
'0%': { opacity: '0', transform: 'translateY(30px)' },
|
|
||||||
'100%': { opacity: '1', transform: 'translateY(0)' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
animation: {
|
|
||||||
blink: 'blink 1s step-end infinite',
|
|
||||||
float: 'float 4s ease-in-out infinite',
|
|
||||||
fadeInUp: 'fadeInUp 0.7s ease forwards',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plugins: [
|
|
||||||
require('tw-animate-css'),
|
|
||||||
plugin(function({ addUtilities }) {
|
|
||||||
addUtilities({
|
|
||||||
'.glass': {
|
|
||||||
background: 'rgba(255, 255, 255, 0.04)',
|
|
||||||
'backdrop-filter': 'blur(12px)',
|
|
||||||
'-webkit-backdrop-filter': 'blur(12px)',
|
|
||||||
border: '1px solid rgba(255, 255, 255, 0.08)',
|
|
||||||
},
|
|
||||||
'.glass-strong': {
|
|
||||||
background: 'rgba(255, 255, 255, 0.07)',
|
|
||||||
'backdrop-filter': 'blur(20px)',
|
|
||||||
'-webkit-backdrop-filter': 'blur(20px)',
|
|
||||||
border: '1px solid rgba(255, 255, 255, 0.12)',
|
|
||||||
},
|
|
||||||
'.navbar-glass': {
|
|
||||||
background: 'rgba(8, 8, 8, 0.6)',
|
|
||||||
'backdrop-filter': 'blur(20px)',
|
|
||||||
'-webkit-backdrop-filter': 'blur(20px)',
|
|
||||||
'border-bottom': '1px solid rgba(255, 255, 255, 0.06)',
|
|
||||||
},
|
|
||||||
'.navbar-glass-solid': {
|
|
||||||
background: 'rgba(8, 8, 8, 0.92)',
|
|
||||||
'backdrop-filter': 'blur(20px)',
|
|
||||||
'-webkit-backdrop-filter': 'blur(20px)',
|
|
||||||
'border-bottom': '1px solid rgba(255, 255, 255, 0.08)',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
|
||||||
"target": "ES2023",
|
|
||||||
"useDefineForClassFields": true,
|
|
||||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
|
||||||
"module": "ESNext",
|
|
||||||
"types": ["vite/client"],
|
|
||||||
"skipLibCheck": true,
|
|
||||||
|
|
||||||
/* Bundler mode */
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"allowImportingTsExtensions": true,
|
|
||||||
"verbatimModuleSyntax": true,
|
|
||||||
"moduleDetection": "force",
|
|
||||||
"noEmit": true,
|
|
||||||
"jsx": "react-jsx",
|
|
||||||
|
|
||||||
/* Linting */
|
|
||||||
"strict": true,
|
|
||||||
"noUnusedLocals": true,
|
|
||||||
"noUnusedParameters": true,
|
|
||||||
"erasableSyntaxOnly": true,
|
|
||||||
"noFallthroughCasesInSwitch": true,
|
|
||||||
"noUncheckedSideEffectImports": true,
|
|
||||||
|
|
||||||
"paths": {
|
|
||||||
"@/*": ["./src/*"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"include": ["src"]
|
|
||||||
}
|
|
||||||
+32
-5
@@ -1,7 +1,34 @@
|
|||||||
{
|
{
|
||||||
"files": [],
|
"compilerOptions": {
|
||||||
"references": [
|
"target": "ES2017",
|
||||||
{ "path": "./tsconfig.app.json" },
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
{ "path": "./tsconfig.node.json" }
|
"allowJs": true,
|
||||||
]
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts",
|
||||||
|
"**/*.mts"
|
||||||
|
],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
|
||||||
"target": "ES2023",
|
|
||||||
"lib": ["ES2023"],
|
|
||||||
"module": "ESNext",
|
|
||||||
"types": ["node"],
|
|
||||||
"skipLibCheck": true,
|
|
||||||
|
|
||||||
/* Bundler mode */
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"allowImportingTsExtensions": true,
|
|
||||||
"verbatimModuleSyntax": true,
|
|
||||||
"moduleDetection": "force",
|
|
||||||
"noEmit": true,
|
|
||||||
|
|
||||||
/* Linting */
|
|
||||||
"strict": true,
|
|
||||||
"noUnusedLocals": true,
|
|
||||||
"noUnusedParameters": true,
|
|
||||||
"erasableSyntaxOnly": true,
|
|
||||||
"noFallthroughCasesInSwitch": true,
|
|
||||||
"noUncheckedSideEffectImports": true,
|
|
||||||
|
|
||||||
"paths": {
|
|
||||||
"@/*": ["./src/*"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"include": ["vite.config.ts"]
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import { defineConfig } from 'vite'
|
|
||||||
import react from '@vitejs/plugin-react'
|
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
|
||||||
import path from 'path'
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [react(), tailwindcss()],
|
|
||||||
resolve: {
|
|
||||||
alias: {
|
|
||||||
"@": path.resolve(__dirname, "./src"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
Reference in New Issue
Block a user