10 Commits

100 changed files with 11057 additions and 13142 deletions

View File

@@ -0,0 +1,9 @@
# This is an example agent configuration file
# It is used to define custom AI agents within Continue
# Each agent file can be accessed by selecting it from the agent dropdown
# To learn more, see the full config.yaml reference: https://docs.continue.dev/reference
name: Example Agent
version: 1.0.0
schema: v1

View File

@@ -1,68 +0,0 @@
{
"parser": "@typescript-eslint/parser",
"parserOptions": {
"project": "./tsconfig.json"
},
"plugins": [
"@typescript-eslint"
],
"extends": [
"plugin:import/recommended",
"plugin:@typescript-eslint/recommended",
"plugin:@typescript-eslint/recommended-requiring-type-checking",
"next/core-web-vitals"
],
"rules": {
"semi": [
"error",
"always"
],
"arrow-spacing": [
"error",
{
"before": true,
"after": true
}
],
"block-spacing": [
"error"
],
"semi-spacing": [
"error"
],
"computed-property-spacing": [
"error"
],
"comma-spacing": [
"error"
],
"keyword-spacing": [
"error"
],
"func-call-spacing": [
"error"
],
"template-curly-spacing": [
"error"
],
"array-bracket-spacing": [
"error"
],
"@typescript-eslint/explicit-function-return-type": [
"error"
],
"indent": [
"error",
2
],
"@typescript-eslint/no-inferrable-types": [
"off"
],
"@typescript-eslint/no-empty-function": [
"off"
],
"jsx-a11y/alt-text": [
"off"
]
}
}

14
.gitignore vendored
View File

@@ -4,14 +4,19 @@
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# testing
/coverage
# database
/prisma/db.sqlite
/prisma/db.sqlite-journal
db.sqlite
# next.js
/.next/
/out/
next-env.d.ts
# production
/build
@@ -24,8 +29,11 @@
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
# do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables
.env
.env*.local
# vercel
@@ -33,4 +41,6 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
# idea files
.idea

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
24

62
.vscode/launch.json vendored
View File

@@ -1,32 +1,32 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Next.js: debug server-side",
"type": "node-terminal",
"request": "launch",
"command": "npm run dev"
},
{
"name": "Next.js: debug client-side",
"type": "chrome",
"request": "launch",
"url": "https://3000.vscode.home.joemonk.co.uk/"
},
{
"name": "Next.js: debug full stack",
"type": "node",
"request": "launch",
"program": "${workspaceFolder}/node_modules/.bin/next",
"runtimeArgs": ["--inspect"],
"skipFiles": ["<node_internals>/**"],
"serverReadyAction": {
"action": "debugWithEdge",
"killOnServerStop": true,
"pattern": "- Local:.+(https?://.+)",
"uriFormat": "%s",
"webRoot": "${workspaceFolder}"
}
}
]
}
"version": "0.2.0",
"configurations": [
{
"name": "Next.js: debug server-side",
"type": "node-terminal",
"request": "launch",
"command": "npm run dev"
},
{
"name": "Next.js: debug client-side",
"type": "chrome",
"request": "launch",
"url": "http://3000.vscode.localhost/"
},
{
"name": "Next.js: debug full stack",
"type": "node",
"request": "launch",
"program": "${workspaceFolder}/node_modules/.bin/next",
"runtimeArgs": ["--inspect"],
"skipFiles": ["<node_internals>/**"],
"serverReadyAction": {
"action": "debugWithEdge",
"killOnServerStop": true,
"pattern": "- Local:.+(https?://.+)",
"uriFormat": "%s",
"webRoot": "${workspaceFolder}"
}
}
]
}

24
.vscode/settings.json vendored
View File

@@ -1,6 +1,20 @@
{
"editor.tabSize": 2,
"yaml.schemas": {
"https://json.schemastore.org/github-workflow.json": "file:///workspace/next-portfolio/.gitea/workflows/deploy.yaml"
},
}
"editor.tabSize": 2,
"yaml.schemas": {
"https://json.schemastore.org/github-workflow.json": "file:///workspace/next-portfolio/.gitea/workflows/deploy.yaml"
},
"editor.formatOnSave": true,
"typescript.format.enable": true,
"biome.enabled": true,
"editor.codeActionsOnSave": {
"source.action.organizeImports.biome": "explicit",
"source.action.useSortedAttributes.biome": "explicit",
"source.action.useSortedKeys.biome": "explicit",
"source.fixAll.biome": "explicit",
"source.organizeImports.biome": "explicit"
},
"editor.defaultFormatter": "biomejs.biome",
"files.associations": {
"*.css": "tailwindcss"
}
}

View File

@@ -1,4 +1,4 @@
FROM node:22 AS base
FROM node:24 AS base
# Install dependencies only when needed
FROM base AS deps

View File

@@ -1,36 +1,29 @@
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
# Create T3 App
## Getting Started
This is a [T3 Stack](https://create.t3.gg/) project bootstrapped with `create-t3-app`.
First, run the development server:
## What's next? How do I make an app with this?
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
We try to keep this project as simple as possible, so you can start with just the scaffolding we set up for you, and add additional things later when they become necessary.
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
If you are not familiar with the different technologies used in this project, please refer to the respective docs. If you still are in the wind, please join our [Discord](https://t3.gg/discord) and ask for help.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
- [Next.js](https://nextjs.org)
- [NextAuth.js](https://next-auth.js.org)
- [Prisma](https://prisma.io)
- [Drizzle](https://orm.drizzle.team)
- [Tailwind CSS](https://tailwindcss.com)
- [tRPC](https://trpc.io)
## Learn More
To learn more about Next.js, take a look at the following resources:
To learn more about the [T3 Stack](https://create.t3.gg/), take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
- [Documentation](https://create.t3.gg/)
- [Learn the T3 Stack](https://create.t3.gg/en/faq#what-learning-resources-are-currently-available) — Check out these awesome tutorials
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
You can check out the [create-t3-app GitHub repository](https://github.com/t3-oss/create-t3-app) your feedback and contributions are welcome!
## Deploy on Vercel
## How do I deploy this?
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
Follow our deployment guides for [Vercel](https://create.t3.gg/en/deployment/vercel), [Netlify](https://create.t3.gg/en/deployment/netlify) and [Docker](https://create.t3.gg/en/deployment/docker) for more information.

40
biome.json Normal file
View File

@@ -0,0 +1,40 @@
{
"$schema": "https://biomejs.dev/schemas/2.2.5/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": false
},
"formatter": {
"enabled": true,
"indentStyle": "tab",
"lineWidth": 320
},
"linter": {
"enabled": true,
"rules": {
"recommended": true
},
"domains": {
"next": "recommended",
"react": "recommended",
"project": "recommended"
}
},
"javascript": {
"formatter": {
"quoteStyle": "double"
}
},
"assist": {
"enabled": true,
"actions": {
"source": {
"organizeImports": "on"
}
}
}
}

BIN
db.sql

Binary file not shown.

23
docker-compose.yaml Normal file
View File

@@ -0,0 +1,23 @@
services:
traefik:
container_name: traefik
image: traefik:latest
ports:
- 80:80
command:
- --api.insecure=true
- --providers.docker=true
- --providers.docker.watch=true
- --providers.docker.exposedbydefault=false
- --providers.file.directory=/config
- --providers.file.watch=true
- --entryPoints.http.address=:80
- --accesslog
- --accesslog.format=json
volumes:
- ./docker/traefik/config:/config
- /var/run/docker.sock:/var/run/docker.sock

View File

@@ -0,0 +1,11 @@
http:
routers:
vscode:
entryPoints: http
rule: "Host(`3000.vscode.localhost`)"
service: vscode
services:
vscode:
loadBalancer:
servers:
- url: http://host.docker.internal:3000

View File

@@ -0,0 +1,11 @@
http:
routers:
traefik:
entryPoints: http
rule: "Host(`traefik.localhost`)"
service: traefik
services:
traefik:
loadBalancer:
servers:
- url: http://traefik:8080

View File

@@ -1,10 +1,11 @@
import { defineConfig } from 'drizzle-kit';
import type { Config } from "drizzle-kit";
export default defineConfig({
out: './drizzle',
schema: './src/db/schema',
dialect: 'sqlite',
dbCredentials: {
url: `${process.cwd()}/db.sql`,
},
});
import { env } from "@/env";
export default {
schema: "./src/server/db/schema.ts",
dialect: "sqlite",
dbCredentials: {
url: env.DATABASE_URL,
},
} satisfies Config;

View File

@@ -0,0 +1,18 @@
CREATE TABLE `photo` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`src` text(256) NOT NULL,
`width` integer NOT NULL,
`height` integer NOT NULL,
`blur` blob NOT NULL,
`camera` text(128),
`title` text(128),
`description` text,
`exposureBiasValue` integer,
`fNumber` real,
`isoSpeedRatings` integer,
`focalLength` integer,
`takenAt` integer,
`lensModel` text(128)
);
--> statement-breakpoint
CREATE UNIQUE INDEX `photo_src_unique` ON `photo` (`src`);

View File

@@ -0,0 +1,134 @@
{
"version": "6",
"dialect": "sqlite",
"id": "2a201e4f-713d-4ee8-bf6f-2126752f16d4",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"photo": {
"name": "photo",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"src": {
"name": "src",
"type": "text(256)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"width": {
"name": "width",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"height": {
"name": "height",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"blur": {
"name": "blur",
"type": "blob",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"camera": {
"name": "camera",
"type": "text(128)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text(128)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"exposureBiasValue": {
"name": "exposureBiasValue",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"fNumber": {
"name": "fNumber",
"type": "real",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"isoSpeedRatings": {
"name": "isoSpeedRatings",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"focalLength": {
"name": "focalLength",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"takenAt": {
"name": "takenAt",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"lensModel": {
"name": "lensModel",
"type": "text(128)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"photo_src_unique": {
"name": "photo_src_unique",
"columns": [
"src"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1759536349615,
"tag": "0000_adorable_golden_guardian",
"breakpoints": true
}
]
}

35
next.config.js Normal file
View File

@@ -0,0 +1,35 @@
/**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially useful
* for Docker builds.
*/
import "./src/env.js";
import createMDX from "@next/mdx";
/** @type {import("next").NextConfig} */
const config = {
pageExtensions: ["js", "jsx", "md", "mdx", "ts", "tsx"],
allowedDevOrigins: ["*.vscode.home.joemonk.co.uk", "*.vscode.localhost"],
experimental: {
reactCompiler: true,
},
reactStrictMode: true,
output: "standalone",
images: {
remotePatterns: [
{
protocol: "https",
hostname: "fly.storage.tigris.dev",
},
{
protocol: "https",
hostname: "img.daisyui.com",
},
],
},
};
const withMDX = createMDX({
// Add markdown plugins here, as desired
});
export default withMDX(config);

View File

@@ -1,33 +0,0 @@
import million from "million/compiler";
import createMDX from "@next/mdx";
/** @type {import('next').NextConfig} */
const nextConfig = {
pageExtensions: ["js", "jsx", "md", "mdx", "ts", "tsx"],
experimental: {
reactCompiler: true,
ppr: "incremental",
},
serverExternalPackages: ["typeorm", "better-sqlite3"],
reactStrictMode: true,
output: "standalone",
images: {
remotePatterns: [
{
protocol: "https",
hostname: "fly.storage.tigris.dev",
},
],
},
};
const millionConfig = {
auto: { rsc: true },
rsc: true,
};
const withMDX = createMDX({
// Add markdown plugins here, as desired
});
export default withMDX(million.next(nextConfig, millionConfig));

20142
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,61 +1,75 @@
{
"name": "next-portfolio",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"build:analyse": "ANALYZE=true npm run build",
"start": "next start",
"lint": "next lint",
"lint:fix": "next lint --fix"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.712.0",
"@heroicons/react": "^2.2.0",
"@mdx-js/loader": "^3.1.0",
"@mdx-js/react": "^3.1.0",
"@next/bundle-analyzer": "^15.1.0",
"@next/mdx": "^15.1.0",
"@tailwindcss/typography": "^0.5.15",
"@tanstack/react-query": "^5.62.7",
"@tanstack/react-virtual": "^3.11.1",
"@trpc/client": "^11.0.0-rc.660",
"@trpc/react-query": "^11.0.0-rc.660",
"@trpc/server": "^11.0.0-rc.660",
"@types/better-sqlite3": "^7.6.12",
"@types/mdx": "^2.0.13",
"@types/node": "^22.10.2",
"@types/react": "^19.0.1",
"@types/react-dom": "^19.0.2",
"@typescript-eslint/eslint-plugin": "^8.18.0",
"autoprefixer": "^10.4.20",
"babel-plugin-react-compiler": "beta",
"better-sqlite3": "^11.7.0",
"client-only": "^0.0.1",
"drizzle-kit": "^0.30.1",
"drizzle-orm": "^0.38.2",
"eslint": "^9.17.0",
"eslint-config-next": "^15.1.0",
"exif-reader": "^2.0.1",
"framer-motion": "^11.14.4",
"glob": "^11.0.0",
"million": "^3.1.11",
"next": "15.1.1-canary.5",
"next-auth": "5.0.0-beta.25",
"postcss": "^8.4.49",
"radash": "^12.1.0",
"react": "19.0.0",
"react-dom": "19.0.0",
"react-zoom-pan-pinch": "^3.6.1",
"reflect-metadata": "^0.2.2",
"server-only": "^0.0.1",
"sharp": "^0.33.5",
"superjson": "^2.2.2",
"tailwind-scrollbar": "^3.1.0",
"tailwindcss": "^3.4.16",
"typescript": "^5.7.2",
"yet-another-react-lightbox": "^3.21.7",
"zod": "^3.24.1"
}
"name": "next-portfolio",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"build": "next build",
"check": "biome check .",
"check:unsafe": "biome check --fix --unsafe .",
"check:write": "biome check --write .",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio",
"dev": "next dev --hostname 0.0.0.0 --turbopack",
"preview": "next build && next start",
"start": "next start",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@auth/drizzle-adapter": "^1.10.0",
"@aws-sdk/client-s3": "^3.896.0",
"@heroicons/react": "^2.2.0",
"@hookform/resolvers": "^5.2.1",
"@libsql/client": "^0.15.9",
"@mdx-js/loader": "^3.1.0",
"@mdx-js/react": "^3.1.0",
"@next/mdx": "^15.5.4",
"@t3-oss/env-nextjs": "^0.13.8",
"@tailwindcss/typography": "^0.5.19",
"@tanstack/react-query": "^5.90.2",
"@tiptap/extension-typography": "^3.5.1",
"@tiptap/pm": "^3.5.1",
"@tiptap/react": "^3.5.1",
"@tiptap/starter-kit": "^3.5.1",
"@total-typescript/ts-reset": "^0.6.1",
"@trpc/client": "^11.4.3",
"@trpc/react-query": "^11.4.3",
"@trpc/server": "^11.4.3",
"@types/mdx": "^2.0.13",
"babel-plugin-react-compiler": "^19.1.0-rc.2",
"daisyui": "^5.1.18",
"drizzle-orm": "^0.44.2",
"exif-reader": "^2.0.2",
"framer-motion": "^12.23.21",
"glob": "^11.0.3",
"next": "^15.5.4",
"next-auth": "beta",
"radash": "^12.1.1",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-error-boundary": "^6.0.0",
"react-hook-form": "^7.62.0",
"react-zoom-pan-pinch": "^3.7.0",
"server-only": "^0.0.1",
"sharp": "^0.34.4",
"superjson": "^2.2.2",
"yet-another-react-lightbox": "^3.23.4",
"zod": "^4.1.11"
},
"devDependencies": {
"@biomejs/biome": "2.2.5",
"@tailwindcss/postcss": "^4.1.11",
"@types/node": "24.5.2",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"drizzle-kit": "^0.31.4",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.11",
"typescript": "^5.8.3"
},
"ct3aMetadata": {
"initVersion": "7.39.3"
}
}

View File

@@ -1,6 +1,5 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};

View File

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

1
reset.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
import "@total-typescript/ts-reset";

View File

@@ -1,24 +0,0 @@
import { signIn } from "@/lib/auth";
import type React from "react";
export default function Auth(props: {
searchParams: Promise<{ callbackUrl: string | undefined }>
}): React.JSX.Element {
return (
<form
className="w-40 mx-auto"
action={async () => {
"use server";
await signIn("authelia", {
redirectTo: (await props.searchParams)?.callbackUrl ?? "",
});
}}
>
<button type="submit"
className={`rounded-lg dark:bg-dracula-bg-light transition-colors duration-100 dark:text-white px-2 py-2 font-normal border-transparent`}
>
<span>Sign in with Authelia</span>
</button>
</form>
);
}

View File

@@ -1,15 +1,16 @@
import React from 'react';
import Cv from '@/components/cv';
import type React from "react";
import Cv from "@/app/_components/cv";
export default function CvPage(): React.JSX.Element {
return (
<div>
<div className='flex flex-row justify-center pb-4'>
<button className='py-2 px-4 border'>
Download
</button>
</div>
<Cv/>
</div>
);
return (
<div>
<div className="flex flex-row justify-center">
<button type="button" className="btn btn-primary">
Download
</button>
</div>
<div className="divider divider-primary"></div>
<Cv />
</div>
);
}

18
src/app/(root)/error.tsx Normal file
View File

@@ -0,0 +1,18 @@
"use client"; // Error boundaries must be Client Components
import { useEffect } from "react";
// biome-ignore lint/suspicious/noShadowRestrictedNames: This is a NextJS standard
export default function Error({ error }: { error: Error & { digest?: string } }) {
useEffect(() => {
// Log the error to an error reporting service
console.error(error);
}, [error]);
return (
<div className="text-center">
<h1>Sorry, something went wrong!</h1>
<p>The error has been reported, try reloading but please reach out with information.</p>
</div>
);
}

View File

@@ -1,22 +1,16 @@
import NavBar from '@/components/navbar';
import Footer from '@/components/footer';
import "../globals.css";
import Footer from "@/app/_components/footer";
import NavBar from "@/app/_components/navbar";
export default function RootLayout({
children,
children,
}: Readonly<{
children: React.ReactNode;
children: React.ReactNode;
}>): React.JSX.Element {
return (
<>
<NavBar/>
<main className="px-6 py-4 w-full flex-1 align-middle overflow-y-scroll scrollbar scrollbar-thumb-dracula-purple scrollbar-track-dracula-bg-light">
<div className="mx-auto w-full align-middle lg:max-w-5xl ">
{children}
</div>
</main>
<Footer/>
</>
);
return (
<>
<NavBar />
<main className="mx-auto w-full flex-1 px-6 pt-8 pb-12 align-middle lg:max-w-5xl">{children}</main>
<Footer />
</>
);
}

View File

@@ -0,0 +1,8 @@
export default function DirSvg(): React.JSX.Element {
return (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" className="h-4 w-4">
<title>Directory</title>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z" />
</svg>
);
}

View File

@@ -0,0 +1,12 @@
export default function ImageSvg(): React.JSX.Element {
return (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" className="h-4 w-4">
<title>Item</title>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 001.5-1.5V6a1.5 1.5 0 00-1.5-1.5H3.75A1.5 1.5 0 002.25 6v12a1.5 1.5 0 001.5 1.5zm10.5-11.25h.008v.008h-.008V8.25zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z"
/>
</svg>
);
}

View File

@@ -0,0 +1,58 @@
"use client";
import Bold from "@tiptap/extension-bold";
import Document from "@tiptap/extension-document";
import Italic from "@tiptap/extension-italic";
import Paragraph from "@tiptap/extension-paragraph";
import Text from "@tiptap/extension-text";
import Typography from "@tiptap/extension-typography";
import { Placeholder, UndoRedo } from "@tiptap/extensions";
import { type Content, type Editor, EditorContent, useEditor, useEditorState } from "@tiptap/react";
import { useEffect } from "react";
export default function Tiptap({ onChange, initContent, editorRef }: { onChange: (args: unknown) => void; initContent?: Content; editorRef: React.RefObject<Editor | null> }) {
const editor = useEditor({
extensions: [
Text,
Document,
Paragraph,
Bold,
Italic,
UndoRedo,
Typography,
Placeholder.configure({
placeholder: "Add a photo description",
}),
],
// Don't render immediately on the server to avoid SSR issues
immediatelyRender: false,
editorProps: {
attributes: {
class: "py-1 px-2",
},
},
content: initContent,
});
editorRef.current = editor;
const editorState = useEditorState({
editor,
// the selector function is used to select the state you want to react to
selector: ({ editor }) => {
if (!editor) {
return {
currentContent: null,
};
}
return {
currentContent: editor.getJSON(),
};
},
});
useEffect(() => {
onChange(editorState?.currentContent);
}, [editorState?.currentContent, onChange]);
return <EditorContent className="border border-base-300" editor={editor} />;
}

View File

@@ -0,0 +1,293 @@
"use client";
import { ArrowPathIcon } from "@heroicons/react/24/outline";
import { zodResolver } from "@hookform/resolvers/zod";
import type { Editor } from "@tiptap/react";
import Image from "next/image";
import type React from "react";
import { type JSX, useRef, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import z from "zod";
import type { PhotoData } from "@/server/api/routers/photos/list";
import { api } from "@/trpc/react";
import DirSvg from "./dir-svg";
import ImageSvg from "./file-svg";
import Tiptap from "./photo-editor";
// - TODO - Pull this from trpc
const FormSchema = z.object({
title: z.string().min(3, "Title should be over 3 characters").max(128, "Title cannot be over 128 characters"),
description: z.object({
type: z.string(),
content: z.array(z.unknown()),
}),
});
type IFormInput = z.infer<typeof FormSchema>;
interface DirectoryTree {
[key: string]: DirectoryTree;
}
function buildDirectoryTree(filePaths: string[]): DirectoryTree {
const root: DirectoryTree = {};
filePaths.forEach((path) => {
const parts = path.split("/").filter((p) => p.length > 0);
let current = root;
// Traverse or create nodes for each part of the path
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
if (part) {
if (!current[part]) {
current[part] = {};
}
current = current[part];
}
}
});
return root;
}
type Item = {
type: "directory" | "file";
name: string;
fullPath: string;
children?: Item[];
};
function renderTree(node: DirectoryTree, pathSoFar = ""): Item[] {
const entries = Object.entries(node);
const items: Item[] = [];
for (const [name, children] of entries) {
const fullPath = pathSoFar ? `${pathSoFar}/${name}` : name;
const isLeaf = Object.keys(children).length === 0;
if (isLeaf) {
// It's a file
items.push({ type: "file", name, fullPath });
} else {
// It's a directory
items.push({
type: "directory",
name,
fullPath,
children: renderTree(children, fullPath),
});
}
}
return items;
}
function RenderLeaf(leaf: Item[], selectImageTab: (path: string) => void, selectedImage: PhotoData | undefined) {
return leaf.map((leaf) => {
const selectedLeaf = `https://fly.storage.tigris.dev/joemonk-photos/${leaf.fullPath}` === selectedImage?.src;
if (leaf.children?.length) {
return (
<li key={leaf.fullPath}>
<details open>
<summary>
<DirSvg />
{leaf.name}
</summary>
<ul>{RenderLeaf(leaf.children, selectImageTab, selectedImage)}</ul>
</details>
</li>
);
}
return (
<li key={leaf.fullPath}>
<button type="button" className={selectedLeaf ? "active" : ""} onClick={() => selectImageTab(leaf.fullPath)}>
<ImageSvg />
{leaf.name}
</button>
</li>
);
});
}
function UpdatePhotos(): JSX.Element {
const updateQuery = api.photos.update.useQuery(undefined, {
enabled: false,
});
return (
<button type="button" disabled={updateQuery.isFetching} onClick={() => updateQuery.refetch()} className={`button btn btn-secondary btn-outline w-full border-1 group flex justify-between h-8 transition-colors disabled:border-info`}>
Load S3 into DB
<ArrowPathIcon
className="w-4 h-4
animate-spin pause group-disabled:play group-disabled:stroke-info transition-colors"
/>
</button>
);
}
export function PhotoTab(): React.JSX.Element {
const [selectedImage, setSelectedImage] = useState<PhotoData>();
const editorRef = useRef<Editor>(null);
const titleRef = useRef<HTMLInputElement>(null);
const countQuery = api.photos.count.useQuery();
const listQuery = api.photos.list.useInfiniteQuery(
{},
{
getNextPageParam: (lastPage) => lastPage.next,
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchOnReconnect: false,
},
);
const modifyMutate = api.photos.modify.useMutation();
const {
register,
handleSubmit,
setValue,
control,
formState: { errors },
} = useForm<IFormInput>({
resolver: zodResolver(FormSchema),
mode: "onSubmit",
});
if (listQuery.isLoading) {
return <p>Loading</p>;
}
if (listQuery.error) {
return <p>{listQuery.error.message}</p>;
}
const images = listQuery.data?.pages.flatMap((data) => data.data);
if (listQuery.hasNextPage) {
listQuery.fetchNextPage();
}
const selectImage = (path: string) => {
if (images) {
const img = images.find((img) => img.src === `https://fly.storage.tigris.dev/joemonk-photos/${path}`);
setSelectedImage(img);
modifyMutate.reset();
editorRef.current?.commands.setContent(img?.description ?? null);
setValue("title", img?.title ?? "", { shouldTouch: true });
}
};
const tree = buildDirectoryTree(images?.map((img) => img.src.substring("https://fly.storage.tigris.dev/joemonk-photos/".length)) ?? []);
const renderedTree = renderTree(tree);
return (
<div className="relative">
<div className="flex w-full gap-4 md:gap-2 flex-col md:flex-row">
<ul className="menu menu-xs bg-base-200 box w-full md:w-1/4">
{countQuery.data ? <p className="w-full pb-2 text-center">Photos in DB: {countQuery.data}</p> : <p>Couldn't get photo count from DB</p>}
<UpdatePhotos />
<span className="divider m-2" />
{!images || images?.length === 0 ? (
<p>No images to load</p>
) : (
<>
{listQuery.hasNextPage || listQuery.isLoading ? <progress className="progress w-full"></progress> : null}
{RenderLeaf(renderedTree, selectImage, selectedImage)}
</>
)}
</ul>
<div className="md:w-3/4 box border border-base-300 p-2 w-full">
{selectedImage?.src ? (
<form
onSubmit={handleSubmit((data) =>
modifyMutate.mutate({
title: data.title,
description: data.description,
src: selectedImage.src,
}),
)}
>
<label className={`floating-label input text-lg mb-2 w-full ${errors.title ? "input-error" : null}`}>
<span>{`Title ${errors.title ? ` - ${errors.title.message}` : ""}`}</span>
<input {...register("title", { value: selectedImage?.title })} ref={titleRef} type="text" placeholder="Title" />
</label>
<Image src={selectedImage.src} title={selectedImage?.title} alt={selectedImage?.title ?? "Image to modify data for"} width={selectedImage.width} height={selectedImage.height} blurDataURL={selectedImage.blur} placeholder="blur" />
<div className="mt-2 grid grid-cols-3">
{[
{
title: "F-Stop",
value: selectedImage.exif.fNumber?.toString(),
},
{
title: "ISO",
value: selectedImage.exif.isoSpeedRatings?.toString(),
},
{
title: "Exposure",
value: selectedImage.exif.exposureBiasValue?.toString(),
},
].map((setting) => {
return (
<div key={setting.title} className="w-full border">
<span className="px-2 w-20 inline-block">{setting.title}</span>
<span className="px-2">{setting.value}</span>
</div>
);
})}
</div>
<div className="mt-2 grid grid-cols-3">
{[
{
title: "Taken",
value: selectedImage.exif.takenAt?.toLocaleDateString(),
},
{
title: "Lens",
value: selectedImage.exif.LensModel,
},
{
title: "Camera",
value: selectedImage.camera,
},
].map((setting) => {
return (
<div key={setting.title} className="w-full border">
<span className="px-2 w-20 inline-block">{setting.title}</span>
<span className="px-2">{setting.value}</span>
</div>
);
})}
</div>
<div className="mt-2 grid grid-cols-2">
{[
{
title: "Height",
value: selectedImage.height.toString(),
},
{
title: "Width",
value: selectedImage.width.toString(),
},
].map((setting) => {
return (
<div key={setting.title} className="w-full border">
<span className="px-2 w-20 inline-block">{setting.title}</span>
<span className="px-2">{setting.value}</span>
</div>
);
})}
</div>
<div className="mt-2 px-2 pb-2 border">
<span>Description</span>
<Controller control={control} name="description" render={({ field: { onChange } }) => <Tiptap onChange={onChange} initContent={selectedImage.description} editorRef={editorRef} />} />
</div>
<div className="flex flex-row items-center">
<button className="btn btn-primary flex self-center m-4" type="submit">
Save
</button>
{modifyMutate.isSuccess ? <p className="badge badge-success">Updated</p> : modifyMutate.isError ? <p className="badge badge-error">Error: {modifyMutate.error.message}</p> : modifyMutate.isPending ? <p className="badge badge-info">Updating</p> : null}
</div>
</form>
) : null}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,17 @@
import { PhotoTab } from "./_components/photo-tab";
export default async function Photos(): Promise<React.JSX.Element> {
return (
<div className="mx-auto">
<div role="tablist" className="tabs tabs-lift">
<input type="radio" name="admin_tabs" className="tab" aria-label="Posts" />
<div className="tab-content bg-base-100 border-base-300 p-4"></div>
<input type="radio" name="admin_tabs" className="tab" aria-label="Photos" defaultChecked />
<div className="tab-content bg-base-100 border-base-300 p-4">
<PhotoTab />
</div>
</div>
</div>
);
}

View File

@@ -1,9 +1,5 @@
import HomeMdx from '@/markdown/page.mdx';
import HomeMdx from "@/markdown/page.mdx";
export default function Home(): React.JSX.Element {
return (
<>
<HomeMdx/>
</>
);
return <HomeMdx />;
}

View File

@@ -1,31 +1,17 @@
import Image from "next/image";
import FilteredLightbox from "@/components/lightbox";
import { trpc } from "@/trpc/server";
import { TRPCProvider } from "@/trpc/client";
import FilteredLightbox from "@/app/_components/lightbox";
import { api } from "@/trpc/server";
export default async function Photos(): Promise<React.JSX.Element> {
const { data: images } = await trpc.photos.list();
const { data: images } = await api.photos.list();
return (
<div className="mx-auto">
<TRPCProvider>
<FilteredLightbox imageData={images}>
{images.map((image) => (
<Image
key={image.src}
alt={image.src}
src={image.src}
className="object-contain h-60 w-80"
sizes="100vw"
loading="lazy"
width={image.width}
height={image.height}
blurDataURL={image.blur}
placeholder="blur"
/>
))}
</FilteredLightbox>
</TRPCProvider>
</div>
);
return (
<div className="mx-auto">
<FilteredLightbox photoData={images}>
{images.map((image) => (
<Image key={image.src} alt={image.src} src={image.src} className="h-60 w-80 object-contain" sizes="100vw" loading="lazy" width={image.width} height={image.height} blurDataURL={image.blur} placeholder="blur" />
))}
</FilteredLightbox>
</div>
);
}

View File

@@ -1,32 +1,32 @@
import { glob } from "glob";
import dynamic, { LoaderComponent } from "next/dynamic";
import React from "react";
import dynamic from "next/dynamic";
import type React from "react";
export const dynamicParams = false;
export async function generateStaticParams(): Promise<{ slug: string[] }[]> {
const posts = await glob(
`${process.cwd()}/src/markdown/posts/[[]...slug[]]/**/*.mdx`,
{
nodir: true,
}
);
export async function generateStaticParams(): Promise<
{
slug: string[];
}[]
> {
const posts = await glob(`${process.cwd()}/src/markdown/posts/[[]...slug[]]/**/*.mdx`, {
nodir: true,
});
const slugs = posts.map((post) => ({
slug: [post.split("/").at(-1)!.slice(0, -4)],
}));
const postData = posts.map((post) => ({
slug: [post.split("/").at(-1)?.slice(0, -4) ?? ""],
}));
return slugs;
return await Promise.all(postData);
}
export default async function Post({
params,
params,
}: {
params: Promise<{ slug: string[] }>;
params: {
slug: string[];
};
}): Promise<React.JSX.Element> {
const mdxFile = await import(
`../../../../markdown/posts/[...slug]/${(await params).slug.join("/")}.mdx`
) as LoaderComponent<unknown>;
const Post = dynamic(() => mdxFile);
return <Post />;
const Post = dynamic(async () => import(`../../../../markdown/posts/[...slug]/${(await params).slug.join("/")}.mdx`));
return <Post />;
}

View File

@@ -1,9 +0,0 @@
import React from 'react';
export default function Post({children}: {children: React.JSX.Element}): React.JSX.Element {
return (
<>
{children}
</>
);
}

View File

@@ -1,74 +1,70 @@
import { glob } from "glob";
import { getCurrentUrl } from "@/lib/current-url";
import { unstable_cache } from "next/cache";
import Link from "next/link";
type postDetails = {
link: string;
metadata: {
title: string;
date: string;
coverImage: string;
blurb: string;
shortBlurb: string;
tags: string[];
};
link: string;
metadata: {
title: string;
date: string;
blurb: string;
tags: string[];
};
};
async function loadPostDetails(): Promise<postDetails[]> {
const posts = await glob(
`${process.cwd()}/src/markdown/posts/[[]...slug[]]/**/*.mdx`,
{
nodir: true,
}
);
const posts = await glob(`${process.cwd()}/src/markdown/posts/[[]...slug[]]/**/*.mdx`, {
nodir: true,
});
const loadPostData = posts.map(async (post) => {
const slug = [post.split("/").at(-1)!.slice(0, -4)];
const mdxFile = await import(
`../../../../src/markdown/posts/[...slug]/${slug.join("/")}.mdx`
) as postDetails;
return {
link: getCurrentUrl() + "/posts/" + slug.join("/"),
metadata: mdxFile.metadata,
};
});
const loadPostData = posts.map(async (post: string) => {
const slug = [post.split("/").at(-1)?.slice(0, -4)];
const mdxFile = (await import(`../../../../src/markdown/posts/[...slug]/${slug.join("/")}.mdx`)) as postDetails;
return {
link: `/posts/${slug.join("/")}`,
metadata: mdxFile.metadata,
};
});
const postData = await Promise.all(loadPostData);
return postData;
const postData = await Promise.all(loadPostData);
return postData.sort((postA, postB) => Date.parse(postB.metadata.date) - Date.parse(postA.metadata.date));
}
const getPosts = unstable_cache(loadPostDetails, ["posts"], {
revalidate: false,
revalidate: false,
});
export default async function Posts(): Promise<React.JSX.Element> {
const postDetails = await getPosts();
return (
<div className="flex flex-col gap-6">
{postDetails.map((post) => {
return (
<div key={post.link}>
<div className="prose dark:prose-invert mx-auto">
<h2>
<Link href={post.link}>{post.metadata.title}</Link>
</h2>
<div className="flex flex-row">
{post.metadata.tags.map((tag) => {
return (
<div key={`${post.link}_${tag}`}>
<span className="select-none text-sm me-2 px-2.5 py-1 rounded border border-dracula-pink dark:bg-dracula-bg-darker dark:text-dracula-pink">
{tag}
</span>
</div>
);
})}
</div>
<p>{post.metadata.blurb}</p>
</div>
</div>
);
})}
</div>
);
const postDetails = await getPosts();
return (
<div className="flex flex-wrap sm:grid-cols-2">
{postDetails.map((post) => {
return (
<div key={post.link} className="sm:max-w-1/2 grow p-2">
<div className="card card-border bg-base-300 shadow-md w-full h-full">
<div className="card-body">
<h1 className="card-title">{post.metadata.title}</h1>
<time dateTime={post.metadata.date}>{post.metadata.date}</time>
<div className="flex flex-row flex-wrap gap-2 pb-2">
{post.metadata.tags.map((tag) => {
return (
<div key={`${post.link}_${tag}`}>
<div className="badge badge-soft badge-info">{tag}</div>
</div>
);
})}
</div>
<p>{post.metadata.blurb}</p>
<div className="card-actions justify-end pt-2">
<Link className="btn btn-primary" href={post.link}>
Read
</Link>
</div>
</div>
</div>
</div>
);
})}
</div>
);
}

View File

@@ -1,9 +1,7 @@
import React from 'react';
import type React from "react";
import Cv from '@/components/cv';
import Cv from "@/app/_components/cv";
export default function CvPrint(): React.JSX.Element {
return (
<Cv/>
);
return <Cv />;
}

View File

@@ -0,0 +1,28 @@
import UserIcon from "@heroicons/react/24/outline/UserIcon";
import { getBaseUrl } from "@/lib/base-url";
import { auth, signIn, signOut } from "@/server/auth";
// TODO
export default async function LogIn(): Promise<React.JSX.Element | undefined> {
const session = await auth();
return (
<form
action={async () => {
"use server";
if (session?.user) {
await signOut({
redirectTo: `${getBaseUrl()}/`,
});
} else {
await signIn("authelia");
}
}}
>
<button type="submit" className="btn btn-outline btn-circle hover:bg-primary/25 group border-2 border-primary/75 p-1 transition-colors duration-100">
<UserIcon className={`h-8 w-auto transition-colors ${session?.user ? "stroke-warning" : ""}`} />
<span className="sr-only">{session?.user ? "Log out" : "Log in"}</span>
</button>
</form>
);
}

139
src/app/_components/cv.tsx Normal file
View File

@@ -0,0 +1,139 @@
import { PaperAirplaneIcon } from "@heroicons/react/24/outline";
import type React from "react";
type ExperienceContent = {
startDate: string;
endDate: string;
title: string;
tech: string;
company: string;
content: string | React.JSX.Element;
};
let printBreakCount = 0;
function PrintBreak({ count }: { count?: number }): React.JSX.Element {
return (
<>
{Array.from({ length: count ?? 1 }).map(() => (
<br key={`break-${printBreakCount++}`} className="hidden print:block" />
))}
</>
);
}
const content: ExperienceContent[] = [
{
company: "Tes",
endDate: "Present",
startDate: "Feb 2023",
tech: "TS/NodeJS/React/DotNet/AWS/K8s/GitOps",
title: "Technical Lead",
content: (
<>
As a technical lead, my role moved mostly into communicating with the wider business to ensure my team has clear, achievable objectives, then helping them release those objectives. I have been particularly involved with cross-team collaboration to continue pushing improvements to our development process, being
part of frontend and backend guilds as well as having a constant input into our service architecture to set development-wide architectural decisions. During this time, I have worked in multiple tech stacks, swiftly becoming proficient with frameworks and languages in order to upskill my team.
<br />
Projects I have led include: rebuilding of some of the most used pages we have, releasing new web apps to millions of monthly users which included complex searching with filters and via a map, and user specific context while retaining high SEO scores and high levels of accessibility compliance; creating a new
email workflow, that can utilizes multiple accounts and services to protect reputation; and creating a genetic algorithm built to scale with which users can track their progress when crunching complex sets of data.
</>
),
},
{
company: "Tes",
endDate: "Feb 2023",
startDate: "Jun 2022",
tech: "TS/JS/NodeJS/React",
title: "Senior Developer",
content:
"Changing fields into web development, I utilised my previous knowledge to pivot quickly into the technologies needed for full stack development. I have since worked on and improved many products across multiple teams, while using my experience to provide individual support within my team. I have also created internal initiatives to improve our developer experience as well as getting involved in architecture discussions to keep pushing our development forwards.",
},
{
company: "Live 5",
endDate: "Jun 2022",
startDate: "Oct 2019",
tech: "TS/JS/WebGL/NodeJS",
title: "Development Manager",
content: (
<>
As development manager, I oversaw all of the developers at Live 5 and had a responsibility to oversee production of over 20 games a year from my teams. I kept each stage of game development on track to meet both internal and external deadlines, able to work with my teams to either change the scope of the
project or move developers to get the games back on target. <PrintBreak count={4} /> By implementing a proper code review process, frequent stand ups and additional tooling for developers, qa and artists, we produced far more complex games in less time with fewer bugs. In addition, I mentored both junior and
senior members of my team to develop their technical skills, knowledge and soft skills.
<br />
While managing the team was my foremost responsibility, I was still heavily involved with development. I tackled any particularly difficult coding problems for the team and architecture large-scale changes within the codebase. For example, I integrated new business vital services and rebuilt our base renderer
and loading core in TypeScript. One of the more interesting projects I directed was to rebuild our backend, focusing on providing local and remote interfaces to the data generation that allowed for faster development of more reliable game backends. The deployment process was also rebuilt to allow deploying into
AWS for browser game access, as a package for a separate serverless game build and to run a statistical analysis on a bare metal local kubernetes cluster which I also administered.
</>
),
},
{
company: "Live 5",
endDate: "Oct 2019",
startDate: "Sept 2018",
tech: "TS/JS/WebGL/NodeJS",
title: "Game Developer",
content:
"I was hired to continue as a C++ Developer, but soon transitioned to the mobile team due to company priorities. Despite having no prior experience with JavaScript, I quickly became proficient in the language and its ecosystem, which enabled me to promptly integrate into my new role. I started with creating games, but similar to my time at Inspired, I wrote extra scripts and improved the game libraries to assist development across the team.",
},
{
company: "Inspired Gaming",
endDate: "Sept 2018",
startDate: "Mar 2016",
tech: "C++/DirectX/Python",
title: "Engine/Game Developer",
content:
"My initial responsibilities involved converting existing games to work on a variety of hardware, though I quickly moved up to work on building some of the more complex games and tooling myself, before going on to mentor new starters. Soon after, I advanced into the game engine development team, which explored ways of improving the development cycle and coding efficiency for other developers. We improved the libraries, build steps and used middleware such as Conan and custom VS plugins to provide prebuilt binaries and improve cohesion and standards across the teams.",
},
];
function Experience({ content }: { content: ExperienceContent }): React.JSX.Element {
return (
<div className="flex flex-row gap-4 border-b-2 border-b-accent last:border-b-0">
<div className="w-20 justify-center text-center">
{content.endDate}
<br />
-
<br />
{content.startDate}
</div>
<div className="flex w-full flex-col">
<div className="mb-2 flex w-full flex-row border-b pb-1">
<div className="self-start text-left">{content.title}</div>
<div className="flex-grow self-start text-right">{content.tech}</div>
<div className="ml-3 w-20 border-dracula-bg-light border-l pr-2 text-right">{content.company}</div>
</div>
<div className="pr-2 pb-2 text-justify">{content.content}</div>
</div>
</div>
);
}
// TODO
export default function Cv(): React.JSX.Element {
return (
<div className="mx-auto max-w-[20cm] print:w-[20cm] print:pt-[0.5cm] border-accent">
<div className="flex flex-col justify-center">
<h1 className="py-1 text-center font-medium text-2xl uppercase">Joe Lewis Monk</h1>
<div className="flex flex-col gap-2 p-2">
<div className="grid grid-cols-3 border-b-2 pb-2">
<span className="border-r text-left">joemonk.co.uk</span>
<span className="border-x text-center">07757 017587</span>
<span className="border-l text-right">joemonk@hotmail.co.uk</span>
</div>
<p className="text-justify">
As a highly motivated and adaptive developer, my enthusiasm for learning new technologies, along with years of rapid game and web development, has driven my proficiency with many languages and tools. This allows me to be flexible when tackling problems. Over the last few years I have enjoyed expanding my
role to include management of multiple teams, large scale architecture.
</p>
</div>
<div className="flex flex-row align-middle gap-2 px-2 py-1">
<PaperAirplaneIcon className="my-0.5 h-5" />
<h2 className="font-semibold text-xl">Experience</h2>
</div>
<div className="flex flex-col gap-4 py-2">
{content.map((expContent) => (
<Experience content={expContent} key={`${expContent.company}_${expContent.title}`} />
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,12 @@
// TODO
export default function NavBar(): React.JSX.Element {
return (
<footer className="border-t-2 border-accent bg-base-200">
<div className="mx-auto max-w-5xl px-4 py-2">
<div className="relative flex h-12 flex-row-reverse items-center justify-between">
<span className="select-none">© Joe Monk 2025</span>
</div>
</div>
</footer>
);
}

View File

@@ -0,0 +1,46 @@
"use client";
import Image from "next/image";
import type React from "react";
import { isImageFitCover, isImageSlide, useLightboxProps, useLightboxState } from "yet-another-react-lightbox";
import "yet-another-react-lightbox/styles.css";
import "yet-another-react-lightbox/plugins/thumbnails.css";
import "yet-another-react-lightbox/plugins/captions.css";
import type { RouterOutputs } from "@/trpc/react";
type PhotoData = RouterOutputs["photos"]["list"]["data"][number];
export default function LightboxImage({ slide, offset, rect, unoptimized = false }: { slide: PhotoData; offset: number; rect: { width: number; height: number }; unoptimized: boolean }): React.JSX.Element {
const {
on: { click },
carousel: { imageFit },
} = useLightboxProps();
const { currentIndex } = useLightboxState();
const cover = isImageSlide(slide) && isImageFitCover(slide, imageFit);
const width = !cover ? Math.round(Math.min(rect.width, (rect.height / slide.height) * slide.width)) : rect.width;
const height = !cover ? Math.round(Math.min(rect.height, (rect.width / slide.width) * slide.height)) : rect.height;
return (
<div style={{ position: "relative", width, height }}>
<Image
fill
alt=""
src={slide}
loading="lazy"
unoptimized={unoptimized}
draggable={false}
blurDataURL={slide.blur}
placeholder="blur"
style={{
objectFit: cover ? "cover" : "contain",
cursor: click ? "pointer" : undefined,
}}
sizes={`${Math.ceil((width / window.innerWidth) * 100)}vw`}
onClick={offset === 0 ? (): void => click?.({ index: currentIndex }) : undefined}
/>
</div>
);
}

View File

@@ -0,0 +1,95 @@
"use client";
import Image from "next/image";
import type React from "react";
import { useState } from "react";
import YARL from "yet-another-react-lightbox";
import Captions from "yet-another-react-lightbox/plugins/captions";
import Thumbnails from "yet-another-react-lightbox/plugins/thumbnails";
import Zoom from "yet-another-react-lightbox/plugins/zoom";
import LightboxImage from "./lightbox-image";
import "yet-another-react-lightbox/styles.css";
import "yet-another-react-lightbox/plugins/thumbnails.css";
import "yet-another-react-lightbox/plugins/captions.css";
import { api, type RouterOutputs } from "@/trpc/react";
type PhotoData = RouterOutputs["photos"]["list"]["data"][number];
export function Lightbox({ photoData, children }: { photoData: PhotoData[]; children: React.JSX.Element[] }): React.JSX.Element {
const [active, setActive] = useState<number | null>(null);
return (
<div className="mx-auto">
<div className="flex flex-row flex-wrap justify-center gap-8">
{children.map((image, index) => {
return (
<button
type="button"
key={`lightbox_${image.key}`}
className="cursor-pointer"
onClick={() => {
setActive(index);
}}
>
{image}
</button>
);
})}
</div>
<YARL
open={typeof active === "number"}
close={() => setActive(null)}
index={active ?? undefined}
slides={photoData}
render={{
// @ts-expect-error - Todo
slide: (args) => LightboxImage({ ...args, unoptimized: true }),
// @ts-expect-error - Todo - This just passes the slide through, but it doesn't know the type
thumbnail: LightboxImage,
}}
plugins={[Thumbnails, Zoom, Captions]}
/>
</div>
);
}
export default function FilteredLightbox(props: { photoData: PhotoData[]; children: React.JSX.Element[] }): React.JSX.Element {
const photoQuery = api.photos.list.useInfiniteQuery(
{
limit: 1,
},
{
initialData: {
pages: [
{
data: props.photoData,
next: props.photoData.length,
},
],
pageParams: [0],
},
getNextPageParam: (lastPage) => {
console.log(lastPage);
return lastPage.next ? lastPage.next > 0 : null;
},
},
);
function handleNextPage(): void {
if (!photoQuery.isLoading) {
void photoQuery.fetchNextPage();
}
}
const photoData = photoQuery.data.pages.flatMap((data) => data.data);
const children = photoData.map((data) => <Image key={data.src} alt={data.src} src={data.src} className="h-60 w-80" loading="lazy" width={data.width} height={data.height} blurDataURL={data.blur} placeholder="blur" />);
return (
<>
<Lightbox photoData={photoData}>{...children}</Lightbox>
<button type="button" className="btn btn-primary mx-auto p-4 mt-8 flex" onClick={handleNextPage}>
{photoQuery.isLoading ? "Loading" : photoQuery.hasNextPage ? "Load next page" : "No more photos"}
</button>
</>
);
}

View File

@@ -0,0 +1,85 @@
"use client";
import { Bars3Icon, HomeModernIcon, XMarkIcon } from "@heroicons/react/24/outline";
import { AnimatePresence, domAnimation, LazyMotion, motion } from "framer-motion";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useMemo, useState } from "react";
import ThemeSwitcher from "./theme-switcher";
type NavBarClientProps = {
LogIn: React.JSX.Element;
navigation: {
name: string;
href: string;
current: boolean;
}[];
};
// TODO
export default function NavBarClient({ LogIn, navigation }: NavBarClientProps): React.JSX.Element {
const [open, setOpen] = useState(false);
const pathname = usePathname();
const activeNavigation = useMemo((): typeof navigation => {
const nav = structuredClone(navigation);
const current = nav.find((nav) => nav.href === pathname);
if (current) {
current.current = true;
}
return nav;
}, [pathname, navigation]);
return (
<nav className="border-accent border-b-2 shadow-md dark:shadow-none dark:bg-base-300">
<LazyMotion features={domAnimation}>
<div className="mx-auto max-w-5xl px-4">
<div className="relative flex h-16 items-center justify-between">
<div className="flex">
<button
type="button"
className="btn btn-outline rounded-sm
border-2 border-primary/75 p-1 transition-colors duration-100 sm:hidden"
onClick={() => setOpen(!open)}
>
{open ? <XMarkIcon className="h-8 w-auto rounded-sm" /> : <Bars3Icon className="h-8 w-auto rounded-sm" />}
</button>
<Link className="btn btn-outline hidden items-center rounded border-2 border-primary/75 p-1 transition-colors hover:bg-primary/25 sm:flex" href="/">
<HomeModernIcon className="h-8 w-auto rounded-sm" />
</Link>
<div className="ml-12 hidden gap-4 sm:flex">
{activeNavigation.map((item) => (
<Link
key={item.name}
href={item.href}
className={`btn btn-outline min-w-20 rounded-lg rounded-b-none border-transparent border-b-2 px-2 py-1 pt-1.5 text-center text-lg font-medium hover:border-primary hover:bg-primary/25 ${item.current ? "border-b-accent/75" : ""}`}
aria-current={item.current ? "page" : undefined}
>
{item.name}
</Link>
))}
</div>
</div>
<div className="flex gap-4 items-center-safe">
<ThemeSwitcher />
{LogIn}
</div>
</div>
</div>
<AnimatePresence>
{open ? (
<motion.div initial={{ height: 0 }} animate={{ height: "auto" }} transition={{ duration: 0.15, ease: "linear" }} exit={{ height: 0 }} className="overflow-hidden sm:hidden">
<div className="flex flex-col space-y-1 py-1">
{activeNavigation.map((item) => (
<Link key={item.name} href={item.href} className={`btn btn-outline border-transparent border-l-4 px-4 py-2 transition-colors duration-100 hover:border-primary hover:bg-primary/25 ${item.current ? "" : "border-primary"}`} aria-current={item.current ? "page" : undefined}>
{item.name}
</Link>
))}
</div>
</motion.div>
) : null}
</AnimatePresence>
</LazyMotion>
</nav>
);
}

View File

@@ -0,0 +1,24 @@
"use server";
import { auth } from "@/server/auth";
import LogIn from "./auth/login";
import NavBarClient from "./navbar-client";
const defaultNavigation = [
{ name: "Posts", href: "/posts", current: false },
{ name: "Photos", href: "/photos", current: false },
{ name: "CV", href: "/cv", current: false },
];
const authedNavigation = [{ name: "Manage", href: "/manage", current: false }];
export default async function NavBar(): Promise<React.JSX.Element> {
let nav = structuredClone(defaultNavigation);
const session = await auth();
if (session?.user) {
nav = nav.concat(structuredClone(authedNavigation));
}
return <NavBarClient LogIn={<LogIn />} navigation={nav} />;
}

View File

@@ -0,0 +1,33 @@
type postMetadata = {
title: string;
date: string;
coverImage: string;
blurb: string;
tags: string[];
};
type PostHeaderProps = {
metadata: postMetadata;
};
// TODO
export default function PostHeader({ metadata }: PostHeaderProps): React.JSX.Element {
return (
<>
<h1 className="mb-2">{metadata.title}</h1>
<div className="mb-2">
<time dateTime={metadata.date}>{metadata.date}</time>
</div>
<div className="mb-2 flex gap-2">
{metadata.tags.map((tag) => {
return (
<div key={`${metadata.title}_tag_${tag}`}>
<div className="badge badge-soft badge-info">{tag}</div>
</div>
);
})}
</div>
<div className="divider divider-accent" />
</>
);
}

View File

@@ -0,0 +1,24 @@
"use client";
import { MoonIcon, SunIcon } from "@heroicons/react/24/outline";
import type React from "react";
export default function ThemeSwitcher(): React.JSX.Element {
const toggleTheme = (): void => {
const currentTheme = document.documentElement.getAttribute("data-theme");
if (currentTheme === "alucard") {
localStorage.theme = "dracula-soft";
document.documentElement.setAttribute("data-theme", "dracula-soft");
} else {
localStorage.theme = "alucard";
document.documentElement.setAttribute("data-theme", "alucard");
}
};
return (
<button type="button" className="btn btn-outline w-9 h-9 btn-circle border-2 hover:bg-primary/25 border-primary/75 p-1 transition-colors duration-100" onClick={toggleTheme}>
<MoonIcon className="block dark:hidden" />
<SunIcon className="hidden dark:block" />
</button>
);
}

View File

@@ -1,22 +1,22 @@
import { NextRequest } from "next/server";
import { handlers } from "@/lib/auth";
import { handlers } from "@/server/auth";
const reqWithTrustedOrigin = (req: NextRequest): NextRequest => {
const proto = req.headers.get('x-forwarded-proto');
const host = req.headers.get('x-forwarded-host');
if (!proto || !host) {
console.warn("Missing x-forwarded-proto or x-forwarded-host headers.");
return req;
}
const envOrigin = `${proto}://${host}`;
const { href, origin } = req.nextUrl;
return new NextRequest(href.replace(origin, envOrigin), req);
const proto = req.headers.get("x-forwarded-proto");
const host = req.headers.get("x-forwarded-host");
if (!proto || !host) {
console.warn("Missing x-forwarded-proto or x-forwarded-host headers.");
return req;
}
const envOrigin = `${proto}://${host}`;
const { href, origin } = req.nextUrl;
return new NextRequest(href.replace(origin, envOrigin), req);
};
export const GET = (req: NextRequest): Promise<Response> => {
return handlers.GET(reqWithTrustedOrigin(req));
return handlers.GET(reqWithTrustedOrigin(req));
};
export const POST = (req: NextRequest): Promise<Response> => {
return handlers.POST(reqWithTrustedOrigin(req));
};
return handlers.POST(reqWithTrustedOrigin(req));
};

View File

@@ -1,5 +1,5 @@
import { NextResponse } from 'next/server';
import { NextResponse } from "next/server";
export function GET(): Response {
return NextResponse.json({ status: 200 }, { status: 200 });
}
return NextResponse.json({ status: 200 }, { status: 200 });
}

View File

@@ -1,11 +1,31 @@
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { createTRPCContext } from '@/trpc/init';
import { appRouter } from '@/trpc/routers/_app';
const handler = (req: Request): Promise<Response> =>
fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: appRouter,
createContext: createTRPCContext,
});
export { handler as GET, handler as POST };
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import type { NextRequest } from "next/server";
import { appRouter } from "@/server/api/root";
import { createTRPCContext } from "@/server/api/trpc";
/**
* This wraps the `createTRPCContext` helper and provides the required context for the tRPC API when
* handling a HTTP request (e.g. when you make requests from Client Components).
*/
const createContext = async (req: NextRequest) => {
return createTRPCContext({
headers: req.headers,
});
};
const handler = (req: NextRequest) =>
fetchRequestHandler({
endpoint: "/api/trpc",
req,
router: appRouter,
createContext: () => createContext(req),
onError:
process.env.NODE_ENV === "development"
? ({ path, error }) => {
console.error(`❌ tRPC failed on ${path ?? "<no-path>"}: ${error.message}`);
}
: undefined,
});
export { handler as GET, handler as POST };

25
src/app/error.tsx Normal file
View File

@@ -0,0 +1,25 @@
"use client"; // Error boundaries must be Client Components
import { useEffect } from "react";
export default function Error({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
useEffect(() => {
// Log the error to an error reporting service
console.error(error);
}, [error]);
return (
<div>
<h1>Something went wrong!</h1>
<button
className="button "
onClick={
// Attempt to recover by trying to re-render the segment
() => reset()
}
>
Try again
</button>
</div>
);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -1,9 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer utilities {
.text-balance {
text-wrap: balance;
}
}

View File

@@ -1,41 +1,49 @@
import "reflect-metadata";
import "@/styles/globals.css";
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
const inter = Inter({
subsets: ['latin'],
variable: '--font-inter',
});
import { TRPCReactProvider } from "@/trpc/react";
import { HydrateClient } from "@/trpc/server";
export const metadata: Metadata = {
title: "Joe Monk",
description: "A portfolio page showing some of the things I've done",
title: "Joe Monk",
description: "A portfolio page showing some of the things I've done",
icons: [{ rel: "icon", url: "/favicon.ico" }],
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>): React.JSX.Element {
return (
// Use suppress hydration warnings to add the dark theme class on client
<html className={`${inter.variable} font-sans`} lang="en" suppressHydrationWarning>
<head>
<script id="SetTheme"
dangerouslySetInnerHTML={{
__html: `
if (localStorage.theme !== 'dark' || (!('theme' in localStorage) && !window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.remove('dark');
const inter = Inter({
subsets: ["latin"],
variable: "--font-inter",
weight: ["300", "400", "500", "600"],
});
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="en" className={`${inter.variable} w-screen overflow-x-hidden`} suppressHydrationWarning>
<head>
<script
id="SetTheme"
// biome-ignore lint/security/noDangerouslySetInnerHtml: Doing some pre-render theming
dangerouslySetInnerHTML={{
__html: `
if ('theme' in localStorage) {
document.documentElement.setAttribute('data-theme', localStorage.theme)
} else {
document.documentElement.classList.add('dark');
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.setAttribute('data-theme', 'dracula-soft')
} else {
document.documentElement.setAttribute('data-theme', 'alucard')
}
}`,
}}>
</script>
</head>
<body className="min-h-screen flex flex-col bg-dracula-bg-lightest dark:bg-dracula-bg print:white max-h-screen">
{children}
</body>
</html>
);
}}
/>
</head>
<body className="flex min-h-screen flex-col">
<TRPCReactProvider>
<HydrateClient>{children}</HydrateClient>
</TRPCReactProvider>
</body>
</html>
);
}

View File

@@ -1,29 +0,0 @@
import { auth, signIn, signOut } from "@/lib/auth";
import { getCurrentUrl } from "@/lib/current-url";
import UserCircleIcon from "@heroicons/react/24/outline/UserCircleIcon";
export default async function LogIn(): Promise<React.JSX.Element | undefined> {
const session = await auth();
return (
<form
action={async () => {
"use server";
if (session?.user) {
await signOut({
redirectTo: `${getCurrentUrl()}/`
});
} else {
await signIn("authelia");
}
}}
>
<button type="submit" className="p-1 dark:hover:bg-dracula-bg-light rounded-3xl transition-colors group">
<UserCircleIcon className={`h-8 w-auto transition-colors ${
session?.user ? "dark:stroke-dracula-red dark:group-hover:stroke-dracula-green" : "dark:stroke-dracula-cyan dark:group-hover:stroke-dracula-orange"
}`}/>
<span className="sr-only">{session?.user ? "Log out" : "Log in"}</span>
</button>
</form>
);
}

View File

@@ -1,129 +0,0 @@
import { PaperAirplaneIcon } from "@heroicons/react/24/outline";
import React from "react";
type ExperienceContent = {
startDate: string,
endDate: string,
title: string,
tech: string,
company: string,
content: string | React.JSX.Element
}
function PrintBreak({count}: {count?: number}): React.JSX.Element {
return (
<>
{Array.from({length: count ?? 1}).map((_, i) => <br key={i} className="hidden print:block"/>)}
</>
);
}
const content: ExperienceContent[] = [
{
company: "Tes",
endDate: "Present",
startDate: "Feb 2023",
tech: "TS/NodeJS/React/DotNet/AWS/K8s/GitOps",
title: "Technical Lead",
content: <>
As a technical lead, my role moved mostly into communicating with the wider business to ensure my team has clear, achievable objectives, then helping them release those objectives. I have been particularly involved with cross-team collaboration to continue pushing improvements to our development process, being part of frontend and backend guilds as well as having a constant input into our service architecture to set development-wide architectural decisions. During this time, I have worked in multiple tech stacks, swiftly becoming proficient with frameworks and languages in order to upskill my team.
<br/>
Projects I have led include: rebuilding of some of the most used pages we have, releasing new web apps to millions of monthly users which included complex searching with filters and via a map, and user specific context while retaining high SEO scores and high levels of accessibility compliance; creating a new email workflow, that can utilizes multiple accounts and services to protect reputation; and creating a genetic algorithm built to scale with which users can track their progress when crunching complex sets of data.
</>
},
{
company: "Tes",
endDate: "Feb 2023",
startDate: "Jun 2022",
tech: "TS/JS/NodeJS/React",
title: "Senior Developer",
content: "Changing fields into web development, I utilised my previous knowledge to pivot quickly into the technologies needed for full stack development. I have since worked on and improved many products across multiple teams, while using my experience to provide individual support within my team. I have also created internal initiatives to improve our developer experience as well as getting involved in architecture discussions to keep pushing our development forwards."
},
{
company: "Live 5",
endDate: "Jun 2022",
startDate: "Oct 2019",
tech: "TS/JS/WebGL/NodeJS",
title: "Development Manager",
content: <>
As development manager, I oversaw all of the developers at Live 5 and had a responsibility to oversee production of over 20 games a year from my teams. I kept each stage of game development on track to meet both internal and external deadlines, able to work with my teams to either change the scope of the project or move developers to get the games back on target. <PrintBreak count={4}/> By implementing a proper code review process, frequent stand ups and additional tooling for developers, qa and artists, we produced far more complex games in less time with fewer bugs. In addition, I mentored both junior and senior members of my team to develop their technical skills, knowledge and soft skills.
<br/>
While managing the team was my foremost responsibility, I was still heavily involved with development. I tackled any particularly difficult coding problems for the team and architecture large-scale changes within the codebase. For example, I integrated new business vital services and rebuilt our base renderer and loading core in TypeScript. One of the more interesting projects I directed was to rebuild our backend, focusing on providing local and remote interfaces to the data generation that allowed for faster development of more reliable game backends. The deployment process was also rebuilt to allow deploying into AWS for browser game access, as a package for a separate serverless game build and to run a statistical analysis on a bare metal local kubernetes cluster which I also administered.
</>
},
{
company: "Live 5",
endDate: "Oct 2019",
startDate: "Sept 2018",
tech: "TS/JS/WebGL/NodeJS",
title: "Game Developer",
content: "I was hired to continue as a C++ Developer, but soon transitioned to the mobile team due to company priorities. Despite having no prior experience with JavaScript, I quickly became proficient in the language and its ecosystem, which enabled me to promptly integrate into my new role.I started with creating games, but similar to my time at Inspired, I wrote extra scripts and improved the game libraries to assist development across the team."
},
{
company: "Inspired Gaming",
endDate: "Sept 2018",
startDate: "Mar 2016",
tech: "C++/DirectX/Python",
title: "Engine/Game Developer",
content: "My initial responsibilities involved converting existing games to work on a variety of hardware, though I quickly moved up to work on building some of the more complex games and tooling myself, before going on to mentor new starters.Soon after, I advanced into the game engine development team, which explored ways of improving the development cycle and coding efficiency for other developers. We improved the libraries, build steps and used middleware such as Conan and custom VS plugins to provide prebuilt binaries and improve cohesion and standards across the teams."
}
];
function Experience({content}: {content: ExperienceContent}): React.JSX.Element {
return (
<div className="flex flex-row gap-4 dark:border-b-dracula-orange border-b-dracula-bg-light border-b-2 last:border-b-0">
<div className="w-20 justify-center text-center">
{content.endDate}
<br/>
-
<br/>
{content.startDate}
</div>
<div className="flex flex-col w-full">
<div className="flex flex-row w-full pb-1 mb-2 border-b-[1px] border-dracula-bg-light">
<div className="text-left self-start">
{content.title}
</div>
<div className="text-right flex-grow self-start">
{content.tech}
</div>
<div className="w-20 ml-3 text-right border-l-[1px] border-dracula-bg-light pr-2">
{content.company}
</div>
</div>
<div className="text-justify pb-2 pr-2">
{content.content}
</div>
</div>
</div>
);
}
export default function Cv(): React.JSX.Element {
return (
<div className='w-[20cm] print:pt-[0.5cm] mx-auto dark:text-white'>
<div className='flex flex-col justify-center'>
<h1 className='text-center py-1 text-2xl font-medium uppercase text-white bg-dracula-bg-light'>Joe Lewis Monk</h1>
<div className="p-2 flex flex-col gap-2">
<div className="grid grid-cols-3 border-b-2 border-dracula-bg-light pb-2">
<span className="border-r-[1px] border-dracula-bg-light text-left">joemonk.co.uk</span>
<span className="border-x-[1px] border-dracula-bg-light text-center">07757 017587</span>
<span className="border-l-[1px] border-dracula-bg-light text-right">joemonk@hotmail.co.uk</span>
</div>
<p className="text-justify">
As a highly motivated and adaptive developer, my enthusiasm for learning new technologies, along with years of rapid game and web development, has driven my proficiency with many languages and tools. This allows me to be flexible when tackling problems. Over the last few years I have enjoyed expanding my role to include management of multiple teams, and have picked up new tech stacks while moving between roles.
</p>
</div>
<div className="bg-dracula-bg-light flex flex-row px-2 py-1 gap-2 text-white">
<PaperAirplaneIcon className="h-5 my-[2px]"/>
<h2 className="font-medium">Experience</h2>
</div>
<div className="flex flex-col gap-4 py-2">
{content.map((expContent) => (
<Experience content={expContent} key={`${expContent.company}_${expContent.title}`}/>
))}
</div>
</div>
</div>
);
}

View File

@@ -1,11 +0,0 @@
export default function NavBar(): React.JSX.Element {
return (
<footer className="dark:bg-dracula-bg-darker border-t-2 dark:border-dracula-purple">
<div className="mx-auto max-w-7xl px-4">
<div className="relative flex flex-row-reverse h-12 items-center justify-between">
<span className='dark:text-white select-none'>© Joe Monk 2024</span>
</div>
</div>
</footer>
);
}

View File

@@ -1,209 +0,0 @@
"use client";
import React, { useState } from "react";
import Image from "next/image";
import YARL, {
isImageFitCover,
isImageSlide,
useLightboxProps,
useLightboxState,
} from "yet-another-react-lightbox";
import Thumbnails from "yet-another-react-lightbox/plugins/thumbnails";
import Zoom from "yet-another-react-lightbox/plugins/zoom";
import Captions from "yet-another-react-lightbox/plugins/captions";
import "yet-another-react-lightbox/styles.css";
import "yet-another-react-lightbox/plugins/thumbnails.css";
import "yet-another-react-lightbox/plugins/captions.css";
import type { RouterOutput } from "@/trpc/routers/_app";
import { trpc } from "@/trpc/client";
type ImageData = RouterOutput["photos"]["list"]["data"][number];
function NextJsImage({
slide,
offset,
rect,
unoptimized = false,
}: {
slide: ImageData;
offset: number;
rect: { width: number; height: number };
unoptimized: boolean;
}): React.JSX.Element {
const {
on: { click },
carousel: { imageFit },
} = useLightboxProps();
const { currentIndex } = useLightboxState();
const cover = isImageSlide(slide) && isImageFitCover(slide, imageFit);
const width = !cover
? Math.round(
Math.min(rect.width, (rect.height / slide.height) * slide.width)
)
: rect.width;
const height = !cover
? Math.round(
Math.min(rect.height, (rect.width / slide.width) * slide.height)
)
: rect.height;
return (
<div style={{ position: "relative", width, height }}>
<Image
fill
alt=""
src={slide}
loading="eager"
unoptimized={unoptimized}
draggable={false}
blurDataURL={slide.blur}
placeholder="blur"
style={{
objectFit: cover ? "cover" : "contain",
cursor: click ? "pointer" : undefined,
}}
sizes={`${Math.ceil((width / window.innerWidth) * 100)}vw`}
onClick={
offset === 0
? (): void => click?.({ index: currentIndex })
: undefined
}
/>
</div>
);
}
export function Lightbox({
imageData,
children,
}: {
imageData: ImageData[];
children: React.JSX.Element[];
}): React.JSX.Element {
const [active, setActive] = useState<number | null>(null);
return (
<div className="mx-auto">
<div className="flex flex-row flex-wrap justify-center">
{children.map((image, index) => {
return (
<button
key={`lightbox_img_${index}`}
onClick={() => {
setActive(index);
}}
>
<div className="relative">{image}</div>
</button>
);
})}
</div>
<YARL
open={typeof active === "number"}
close={() => setActive(null)}
index={active ?? undefined}
slides={imageData}
render={{
// @ts-expect-error - Todo
slide: (args) => NextJsImage({ ...args, unoptimized: true }),
// @ts-expect-error - Todo - This just passes the slide through, but it doesn't know the type
thumbnail: NextJsImage,
}}
plugins={[Thumbnails, Zoom, Captions]}
/>
</div>
);
}
interface FormElements extends HTMLFormControlsCollection {
src: HTMLInputElement;
}
interface UsernameFormElement extends HTMLFormElement {
readonly elements: FormElements;
}
export default function FilteredLightbox(props: {
imageData: ImageData[];
children: React.JSX.Element[];
}): React.JSX.Element {
//const [imageData, setImageData] = useState(props.imageData);
const [imageData] = useState(props.imageData);
const photoQuery = trpc.photos.list.useInfiniteQuery(
{
limit: 1,
},
{
initialData: {
pages: [
{
data: props.imageData,
next: props.imageData.length,
},
],
pageParams: [0],
},
getNextPageParam: (lastPage) => lastPage.next,
}
);
const refreshQuery = trpc.photos.update.useQuery(undefined, {
enabled: false,
retry: false,
});
function handleSubmit(event: React.FormEvent<UsernameFormElement>): void {
event.preventDefault();
// const imageData = props.imageData;
// setImageData(
// imageData.filter(
// (data) => data.src === event.currentTarget.elements.src.value
// )
// );
void photoQuery.fetchNextPage();
}
const children = photoQuery.data.pages
.flatMap((data) => data.data)
.map((data) => (
<Image
key={data.src}
alt={data.src}
src={data.src}
className="object-contain h-60 w-80"
sizes="100vw"
loading="lazy"
width={data.width}
height={data.height}
blurDataURL={data.blur}
placeholder="blur"
/>
))
.filter((data) => !!data);
return (
<>
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="src">Src:</label>
<input id="src" type="text" />
</div>
<button type="submit">Submit</button>
</form>
<button
onClick={() => {
void refreshQuery.refetch();
}}
>
Refresh
</button>
{refreshQuery.data ? JSON.stringify(refreshQuery.data) : "\nNot"}
{refreshQuery.error ? JSON.stringify(refreshQuery.error) : "\nNo Error"}
<Lightbox imageData={imageData}>{...children}</Lightbox>
</>
);
}

View File

@@ -1,116 +0,0 @@
"use client";
import { useMemo, useState } from "react";
import Link from "next/link";
import {
HomeModernIcon,
Bars3Icon,
XMarkIcon,
} from "@heroicons/react/24/outline";
import {
AnimatePresence,
motion,
LazyMotion,
domAnimation,
} from "framer-motion";
import { usePathname } from "next/navigation";
import ThemeSwitcher from "./theme-switcher";
type NavBarClientProps = {
LogIn: React.JSX.Element;
navigation: {
name: string;
href: string;
current: boolean;
}[];
};
export default function NavBarClient({
LogIn,
navigation,
}: NavBarClientProps): React.JSX.Element {
const [open, setOpen] = useState(false);
const pathname = usePathname();
const activeNavigation = useMemo((): typeof navigation => {
const nav = structuredClone(navigation);
const current = nav.find((nav) => nav.href === pathname);
if (current) {
current.current = true;
}
return nav;
}, [pathname, navigation]);
return (
<nav className="dark:bg-dracula-bg-darker border-b-2 dark:border-dracula-purple">
<LazyMotion features={domAnimation}>
<div className="mx-auto max-w-7xl px-4">
<div className="relative flex h-16 items-center justify-between">
<div className="flex">
<button
className="sm:hidden dark:hover:bg-dracula-bg-light transition-colors duration-100 rounded-sm p-1"
onClick={() => setOpen(!open)}
>
{open ? (
<XMarkIcon className="rounded-sm dark:stroke-dracula-cyan h-8 w-auto" />
) : (
<Bars3Icon className="rounded-sm dark:stroke-dracula-cyan h-8 w-auto" />
)}
</button>
<Link
className="hidden sm:flex items-center p-1 dark:hover:bg-dracula-bg-light transition-colors"
href="/"
>
<HomeModernIcon className="dark:stroke-dracula-cyan rounded-sm h-8 w-auto" />
</Link>
<div className="space-x-5 hidden sm:flex ml-10">
{activeNavigation.map((item) => (
<Link
key={item.name}
href={item.href}
className={`dark:hover:bg-dracula-bg-light transition-colors duration-100 dark:text-white rounded-sm px-3 pt-2 pb-1.5 font-normal border-b-2 border-transparent ${
item.current ? "dark:border-b-dracula-pink" : ""
}`}
aria-current={item.current ? "page" : undefined}
>
{item.name}
</Link>
))}
</div>
</div>
<div className="space-x-4 flex">
<ThemeSwitcher />
{LogIn}
</div>
</div>
</div>
<AnimatePresence>
{open ? (
<motion.div
initial={{ height: 0 }}
animate={{ height: "auto" }}
transition={{ duration: 0.15, ease: "linear" }}
exit={{ height: 0 }}
className="sm:hidden overflow-hidden"
>
<div className="flex flex-col space-y-1 py-1">
{activeNavigation.map((item) => (
<Link
key={item.name}
href={item.href}
className={`dark:hover:bg-dracula-bg-light transition-colors duration-100 dark:text-white px-2 py-2 font-normal border-l-4 border-transparent ${
item.current ? "dark:border-l-dracula-pink" : ""
}`}
aria-current={item.current ? "page" : undefined}
>
{item.name}
</Link>
))}
</div>
</motion.div>
) : null}
</AnimatePresence>
</LazyMotion>
</nav>
);
}

View File

@@ -1,28 +0,0 @@
"use server";
import { auth } from "@/lib/auth";
import NavBarClient from "./navbar-client";
import LogIn from "./auth/login";
const defaultNavigation = [
{ name: 'Posts', href: '/posts', current: false },
{ name: 'Photos', href: '/photos', current: false },
{ name: 'CV', href: '/cv', current: false },
];
const authedNavigation = [
{ name: 'Manage', href: '/manage', current: false },
];
export default async function NavBar(): Promise<React.JSX.Element> {
const session = await auth();
let nav = structuredClone(defaultNavigation);
if (session?.user) {
nav = nav.concat(structuredClone(authedNavigation));
}
return (
<NavBarClient LogIn={<LogIn/>} navigation={nav}/>
);
}

View File

@@ -1,30 +0,0 @@
type postMetadata = {
title: string,
date: string,
coverImage: string,
blurb: string,
shortBlurb: string,
tags: string[]
}
type PostHeaderProps = {
metadata: postMetadata
}
export default function PostHeader({metadata}: PostHeaderProps): React.JSX.Element {
return (
<>
<h1>{metadata.title}</h1>
<div className="mb-2">{metadata.date}</div>
<div className="mb-6">
{metadata.tags.map((tag) => {
return (
<>
<span className="select-none text-sm me-2 px-2.5 py-1 rounded border border-dracula-pink dark:bg-dracula-bg-darker dark:text-dracula-pink">{tag}</span>
</>
);
})}
</div>
</>
);
}

View File

@@ -1,24 +0,0 @@
"use client";
import { MoonIcon, SunIcon } from "@heroicons/react/24/outline";
import React from "react";
export default function ThemeSwitcher(): React.JSX.Element {
const toggleTheme = function(): void {
if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.remove('dark');
localStorage.theme = 'light';
} else {
document.documentElement.classList.add('dark');
localStorage.theme = 'dark';
}
};
return (
<>
<button className="h-8 w-8 m-1" onClick={toggleTheme}>
<MoonIcon className="dark:hidden block"/>
<SunIcon className="hidden dark:block dark:stroke-dracula-cyan"/>
</button>
</>
);
}

View File

@@ -1,3 +0,0 @@
import { drizzle } from 'drizzle-orm/better-sqlite3';
export default drizzle(`${process.cwd()}/db.sql`);

View File

@@ -1,22 +0,0 @@
import { int, sqliteTable, text, blob, real } from "drizzle-orm/sqlite-core";
export const photosTable = sqliteTable(
"photo",
{
id: int().primaryKey({ autoIncrement: true }),
src: text().notNull().unique(),
width: int().notNull(),
height: int().notNull(),
blur: blob().notNull(),
camera: text(),
title: text(),
description: text(),
exposureBiasValue: int(),
fNumber: real(),
isoSpeedRatings: int(),
focalLength: int(),
dateTimeOriginal: int({ mode: 'timestamp' }),
lensModel: text(),
}
);

57
src/env.js Normal file
View File

@@ -0,0 +1,57 @@
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";
export const env = createEnv({
/**
* Specify your server-side environment variables schema here. This way you can ensure the app
* isn't built with invalid env vars.
*/
server: {
AUTH_SECRET: process.env.NODE_ENV === "production" ? z.string() : z.string().optional(),
AUTH_CLIENT_ID: z.string(),
AUTH_CLIENT_SECRET: z.string(),
DATABASE_URL: z.url(),
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
S3_ACCESS_KEY_ID: z.string(),
S3_SECRET_ACCESS_KEY: z.string(),
S3_ENDPOINT: z.string(),
S3_BUCKET: z.string(),
PORT: z.coerce.number().int().default(3000),
},
/**
* Specify your client-side environment variables schema here. This way you can ensure the app
* isn't built with invalid env vars. To expose them to the client, prefix them with
* `NEXT_PUBLIC_`.
*/
client: {
// NEXT_PUBLIC_CLIENTVAR: z.string(),
},
/**
* You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g.
* middlewares) or client-side so we need to destruct manually.
*/
runtimeEnv: {
AUTH_SECRET: process.env.AUTH_SECRET,
AUTH_CLIENT_ID: process.env.AUTH_CLIENT_ID,
AUTH_CLIENT_SECRET: process.env.AUTH_CLIENT_SECRET,
DATABASE_URL: process.env.DATABASE_URL,
NODE_ENV: process.env.NODE_ENV,
S3_ACCESS_KEY_ID: process.env.S3_ACCESS_KEY_ID,
S3_SECRET_ACCESS_KEY: process.env.S3_SECRET_ACCESS_KEY,
S3_ENDPOINT: process.env.S3_ENDPOINT,
S3_BUCKET: process.env.S3_BUCKET,
PORT: process.env.PORT,
},
/**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially
* useful for Docker builds.
*/
skipValidation: !!process.env.SKIP_ENV_VALIDATION,
/**
* Makes it so that empty strings are treated as undefined. `SOME_VAR: z.string()` and
* `SOME_VAR=''` will throw an error.
*/
emptyStringAsUndefined: true,
});

View File

@@ -1,16 +0,0 @@
import "server-only";
import NextAuth from "next-auth";
import { getCurrentUrl } from "./current-url";
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [{
id: "authelia",
name: "Authelia",
type: "oidc",
issuer: "https://auth.home.joemonk.co.uk",
clientId: process.env.AUTH_CLIENT_ID,
clientSecret: process.env.AUTH_CLIENT_SECRET,
}],
trustHost: true,
redirectProxyUrl: `${getCurrentUrl()}/api/auth`,
});

7
src/lib/base-url.ts Normal file
View File

@@ -0,0 +1,7 @@
export function getBaseUrl(): string {
if (process.env.NODE_ENV === "production") {
return "https://joemonk.co.uk";
} else {
return "http://3000.vscode.localhost";
}
}

View File

@@ -1,7 +0,0 @@
export function getCurrentUrl(): string {
if (process.env.NODE_ENV === 'production') {
return "https://joemonk.co.uk";
} else {
return "https://3000.vscode.home.joemonk.co.uk";
}
}

View File

@@ -1,6 +1,39 @@
A small personal site I use to practice and try things out. When I remember I'll use it to log interesting or difficult projects.
Built to try and use multiple modern web tecnologies in tandem to produce a great user experience from real data. Most parts have been built in a way in which I can swap it out with more interesting methods and projects.
Built to try and use multiple modern web technologies in tandem to produce a great user experience from real data. Most parts have been built in a way in which I can swap it out with more interesting methods and projects.
I'm using Next.js with react query to make the pages load nicely and tailwindcss to make them look good easily. Page content is loaded from mdx files, including preview pages locked behind AWS Cognito. The thought being I'll offload them to load from a database instead of direct files, inserting via a wysiwyg editor behind the auth.
Photos are currently loaded from the filesystem, the metadata and EXIF data is read and a small image created which are all passed back as part of the page. This allows the initial page to have a small blur image, correctly sorted, allowing the page to be loaded quickly. When the page is loaded, the images can then be lazy loaded and optimized to reduce the impact on the server and the data to the client. Opening a full image will then load an unoptimized version to allow detail to be viewed.
Photos are currently loaded from the filesystem, the metadata and EXIF data is read and a small image created which are all passed back as part of the page. This allows the initial page to have a small blur image, correctly sorted, allowing the page to be loaded quickly. When the page is loaded, the images can then be lazy loaded and optimized to reduce the impact on the server and the data to the client. Opening a full image will then load an unoptimized version to allow detail to be viewed.
# Header
A small personal site I use to practice and try things out. When I remember I'll use it to log interesting or difficult projects.
Built to try and use multiple modern web technologies in tandem to produce a great user experience from real data. Most parts have been built in a way in which I can swap it out with more interesting methods and projects.
I'm using Next.js with react query to make the pages load nicely and tailwindcss to make them look good easily. Page content is loaded from mdx files, including preview pages locked behind AWS Cognito. The thought being I'll offload them to load from a database instead of direct files, inserting via a wysiwyg editor behind the auth.
Photos are currently loaded from the filesystem, the metadata and EXIF data is read and a small image created which are all passed back as part of the page. This allows the initial page to have a small blur image, correctly sorted, allowing the page to be loaded quickly. When the page is loaded, the images can then be lazy loaded and optimized to reduce the impact on the server and the data to the client. Opening a full image will then load an unoptimized version to allow detail to be viewed.
A small personal site I use to practice and try things out. When I remember I'll use it to log interesting or difficult projects.
Built to try and use multiple modern web technologies in tandem to produce a great user experience from real data. Most parts have been built in a way in which I can swap it out with more interesting methods and projects.
I'm using Next.js with react query to make the pages load nicely and tailwindcss to make them look good easily. Page content is loaded from mdx files, including preview pages locked behind AWS Cognito. The thought being I'll offload them to load from a database instead of direct files, inserting via a wysiwyg editor behind the auth.
Photos are currently loaded from the filesystem, the metadata and EXIF data is read and a small image created which are all passed back as part of the page. This allows the initial page to have a small blur image, correctly sorted, allowing the page to be loaded quickly. When the page is loaded, the images can then be lazy loaded and optimized to reduce the impact on the server and the data to the client. Opening a full image will then load an unoptimized version to allow detail to be viewed.
## Other Header
A small personal site I use to practice and try things out. When I remember I'll use it to log interesting or difficult projects.
Built to try and use multiple modern web technologies in tandem to produce a great user experience from real data. Most parts have been built in a way in which I can swap it out with more interesting methods and projects.
I'm using Next.js with react query to make the pages load nicely and tailwindcss to make them look good easily. Page content is loaded from mdx files, including preview pages locked behind AWS Cognito. The thought being I'll offload them to load from a database instead of direct files, inserting via a wysiwyg editor behind the auth.
Photos are currently loaded from the filesystem, the metadata and EXIF data is read and a small image created which are all passed back as part of the page. This allows the initial page to have a small blur image, correctly sorted, allowing the page to be loaded quickly. When the page is loaded, the images can then be lazy loaded and optimized to reduce the impact on the server and the data to the client. Opening a full image will then load an unoptimized version to allow detail to be viewed.
A small personal site I use to practice and try things out. When I remember I'll use it to log interesting or difficult projects.
Built to try and use multiple modern web technologies in tandem to produce a great user experience from real data. Most parts have been built in a way in which I can swap it out with more interesting methods and projects.
I'm using Next.js with react query to make the pages load nicely and tailwindcss to make them look good easily. Page content is loaded from mdx files, including preview pages locked behind AWS Cognito. The thought being I'll offload them to load from a database instead of direct files, inserting via a wysiwyg editor behind the auth.
Photos are currently loaded from the filesystem, the metadata and EXIF data is read and a small image created which are all passed back as part of the page. This allows the initial page to have a small blur image, correctly sorted, allowing the page to be loaded quickly. When the page is loaded, the images can then be lazy loaded and optimized to reduce the impact on the server and the data to the client. Opening a full image will then load an unoptimized version to allow detail to be viewed.

View File

@@ -1,11 +1,9 @@
import PostHeader from '@/components/post-header';
import PostHeader from '@/app/_components/post-header';
export const metadata = {
title: "Being a Developer",
date: "2020-05-12",
coverImage: "../images/being-a-developer/being-a-developer.jpg",
blurb: "My thoughts on being a \"developer\", being a \"programmer\" and the differences between them.",
shortBlurb: "My thoughts on being a developer vs being a programmer.",
tags: ["Blog", "Development"]
}

View File

@@ -1,12 +1,10 @@
import PostHeader from '@/components/post-header';
import PostHeader from '@/app/_components/post-header';
export const metadata = {
title: "Learning Kubernetes",
date: "2020-12-31",
path: "/posts/learning-Kubernetes",
coverImage: "../images/learning-kubernetes/k8s.png",
blurb: "Learning how to use Kubenetes in an environment between \"Local testing\" and \"Full server deployments\".",
shortBlurb: "Finally getting around to \"learning\" Kubernetes.",
tags: ["Blog", "Development"],
}

View File

@@ -1,11 +1,9 @@
import PostHeader from '@/components/post-header';
import PostHeader from '@/app/_components/post-header';
export const metadata = {
title: "Managing a Team Remotely",
date: "2020-10-05",
coverImage: "../images/managing-a-team-remotely/managing-a-team-remotely.jpg",
blurb: "With working remotely being a necessity at the moment, my thoughts on managing a team of developers with no physicality.",
shortBlurb: "My thoughts managing a team of developers with no physicality.",
tags: ["Blog", "Development"]
}

View File

@@ -0,0 +1,80 @@
import PostHeader from '@/app/_components/post-header';
export const metadata = {
title: "Setting Up Local Copilot",
date: "2025-07-24",
path: "/posts/setting-up-local-copilot",
blurb: "Setting up a locally running version of Copilot to experiment and learn some basic AI usage.",
tags: ["Blog", "Development"],
}
<PostHeader metadata={metadata} />
## Why
Copilot and other AI tools are generally a bit divisive. Lots of people harp on about it changing everything, and others say it's completely useless. I'm somewhere in the middle, and see it as another tool to help me do my job better. Intellisense on steroids, with some extra features. It's not going away anytime soon either so I think it's worth learning how to use.
It is however, difficult. You don't want to spend a lot on subscriptions and other services to just mess around with. 10 dollars a month isn't much, but it can quickly add up if you try out multiple of the "latest" tools. So instead, let's set up an equivalent, locally, for free.
## Tools
There are loads and loads of options out there. I was mostly after the code completion features and I wanted to be able to at least connect to it from [code-server](https://github.com/coder/code-server) instance, if not run it from that server.
I first wanted to figure out how to run my models. [Ollama](https://ollama.com/) makes it really really easy to get set up and running with loads of models. [LM Studio](https://lmstudio.ai/) seemed like another good option with a decent looking ui so I left both options open.
After playing around with a few different tools like KiloCode, Roo Code, continue.dev connecting to either Ollama or LM Studio, I didn't really get the results I wanted. The code completion either wasn't supported by the extension or it was difficult to use. I then found [Twinny](https://github.com/twinnydotdev/twinny) which seemed to be almost exactly what I wanted.
## Setup
### Ollama
One of the reasons I liked Ollama over other options is because it's so stupidly easy to run.
- Install [Docker](https://www.docker.com/) if for some reason you don't already have it.
- Ensure [CUDA drivers](https://developer.nvidia.com/cuda-toolkit) are installed if looking to utilise a Nvidia GPU.
- Run `docker run -d -v ./ollama:/root/.ollama -p 11434:11434 --gpus=all --name ollama ollama/ollama:0.10.1` to start the container, mapping an `ollama` directory in your cwd to keep downloaded models locally, attaching your GPU and exposing the port.
- You can then pull pull models with `docker exec -it ollama ollama pull bge-m3:567m`.
### Twinny
Just install Twinny from the VS Code [marketplace](https://open-vsx.org/extension/rjmacarthy/twinny) (I use code-server, so Open-vsx is my marketplace).
Configure the chat, fim and embedding providers to point at your Ollama instance.
#### Chat
Type: chat
Provider: ollama
Proto: http
Model Name: Pick your basic model here, options will change over time but any models you pull should be available and I used qwen3:8b
Hostname: localhost (or ip of Ollama server, I run it on a separate machine)
Port: If following above, 11434. Otherwise the port you exposed when running Ollama.
API Path: /v1
#### FIM Provider
Type: fim
FIM Template: Pick the one that relates to your model, i.e. codeqwen
Provider: ollama
Proto: http
Model Name: Pick your code model here, they're set up specifically for FIM, I used qwen2.5-coder:7b-base
Hostname: localhost (or ip of Ollama server, I run it on a separate machine)
Port: If following above, 11434. Otherwise the port you exposed when running Ollama.
API Path: /api/generate
#### FIM Provider
Type: embedding
Provider: ollama
Proto: http
Model Name: Pick your embedding model here, I used bge-m3:567m
Hostname: localhost (or ip of Ollama server, I run it on a separate machine)
Port: If following above, 11434. Otherwise the port you exposed when running Ollama.
API Path: /api/embed
## Additional models
Adding or switching models is as simple as running `docker exec -it ollama ollama pull <model>`, models can be found on the [Ollama site](https://ollama.com/search)
## Conclusion
This has been super simple to set up once the tools were picked, and the output is quick enough running on a remote (but on the same network) mobile RTX 4070. Being able to turn it on when doing actual coding tasks and set all up for free with no limits has been really nice. I'll probably mess around with other extensions and agentic tools next, and I'd like to be able to add docs for use like some of the other extensions.

View File

@@ -1,15 +1,11 @@
import type { MDXComponents } from 'mdx/types';
import React from 'react';
import type { MDXComponents } from "mdx/types";
import type React from "react";
export function useMDXComponents(components: MDXComponents): MDXComponents {
return {
wrapper: ({children}: { children: React.JSX.Element[]}): React.JSX.Element => {
return (
<article className='prose prose-slate dark:prose-invert mx-auto'>
{children}
</article>
);
},
...components,
};
}
return {
wrapper: ({ children }: { children: React.JSX.Element[] }): React.JSX.Element => {
return <article className="prose mx-auto first:prose-h2:mt-8">{children}</article>;
},
...components,
};
}

View File

@@ -1 +0,0 @@
export { auth as middleware } from "@/lib/auth";

23
src/server/api/root.ts Normal file
View File

@@ -0,0 +1,23 @@
import { createCallerFactory, createTRPCRouter } from "@/server/api/trpc";
import { photosRouter } from "./routers/photos/photos";
/**
* This is the primary router for your server.
*
* All routers added in /api/routers should be manually added here.
*/
export const appRouter = createTRPCRouter({
photos: photosRouter,
});
// export type definition of API
export type AppRouter = typeof appRouter;
/**
* Create a server-side caller for the tRPC API.
* @example
* const trpc = createCaller(createContext);
* const res = await trpc.post.all();
* ^? Post[]
*/
export const createCaller = createCallerFactory(appRouter);

View File

@@ -0,0 +1,6 @@
import { db } from "@/server/db";
import { photos } from "@/server/db/schema";
export async function count(): Promise<number> {
return await db.$count(photos);
}

View File

@@ -0,0 +1,60 @@
import { count } from "drizzle-orm";
import { shake } from "radash";
import { db } from "@/server/db";
import { photos } from "@/server/db/schema";
export type PhotoData = {
width: number;
height: number;
blur: `data:image/${string}`;
src: string;
camera?: string;
exif: Partial<{
exposureBiasValue: number;
fNumber: number;
isoSpeedRatings: number;
focalLength: number;
takenAt: Date;
LensModel: string;
}>;
title?: string;
description?: string;
};
export type ListOptions = {
cursor: number;
limit: number;
};
export async function list(options: ListOptions): Promise<{
images: PhotoData[];
count: number;
}> {
const currentSources = await db.select().from(photos).limit(options.limit).offset(options.cursor);
const photosCount = await db.$count(photos);
const images = currentSources.map((photo) => {
return {
width: photo.width,
height: photo.height,
blur: `data:image/jpeg;base64,${photo.blur.toString("base64")}` as `data:image/${string}`,
src: photo.src,
camera: photo.camera ?? undefined,
exif: shake({
exposureBiasValue: photo.exposureBiasValue,
fNumber: photo.fNumber,
isoSpeedRatings: photo.isoSpeedRatings,
focalLength: photo.focalLength,
takenAt: photo.takenAt,
lensModel: photo.lensModel,
}),
title: photo.title ?? undefined,
description: (photo.description as string) ?? undefined,
};
});
return {
images,
count: photosCount,
};
}

View File

@@ -0,0 +1,14 @@
import { eq } from "drizzle-orm";
import { db } from "@/server/db";
import { photos } from "@/server/db/schema";
export async function modify(mod: {
title: string;
src: string;
description: {
type: string;
content: unknown[];
};
}): Promise<void> {
await db.update(photos).set({ title: mod.title, description: mod.description }).where(eq(photos.src, mod.src));
}

View File

@@ -0,0 +1,55 @@
import { z } from "zod";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "@/server/api/trpc";
import { count } from "./count";
import { list } from "./list";
import { modify } from "./modify";
import { update } from "./update";
export const photosRouter = createTRPCRouter({
list: publicProcedure
.input(
z
.object({
limit: z.number().nonnegative().default(3),
cursor: z.number().nonnegative().default(0),
})
.optional()
.default({
limit: 3,
cursor: 0,
}),
)
.query(async ({ input }) => {
const ret = await list({
limit: input.limit,
cursor: input.cursor,
});
let next: number | undefined;
if (ret.count > input.limit + input.cursor) {
next = input.limit + input.cursor;
}
return {
data: ret.images,
next,
};
}),
count: publicProcedure.query(count),
update: publicProcedure.query(update),
modify: protectedProcedure
.input(
z.object({
title: z.string().min(3, "Title should be over 3 characters").max(128, "Title cannot be over 128 characters"),
src: z.string(),
description: z.object({
type: z.string(),
content: z.array(z.unknown()),
}),
}),
)
.mutation(async ({ input }) => {
await modify(input);
}),
});

View File

@@ -0,0 +1,85 @@
import { GetObjectCommand, ListObjectsV2Command, S3Client } from "@aws-sdk/client-s3";
import { TRPCError } from "@trpc/server";
import exifReader from "exif-reader";
import { diff, sift } from "radash";
import sharp from "sharp";
import { env } from "@/env";
import { db } from "@/server/db";
import { photos } from "@/server/db/schema";
export async function update(): Promise<string[]> {
const allPhotos = await db.select({ src: photos.src }).from(photos);
const currentSources = allPhotos.map((photo) => photo.src);
const s3Client = new S3Client({
region: "auto",
endpoint: env.S3_ENDPOINT,
credentials: {
accessKeyId: env.S3_ACCESS_KEY_ID,
secretAccessKey: env.S3_SECRET_ACCESS_KEY,
},
});
const listObjCmd = new ListObjectsV2Command({
Bucket: env.S3_BUCKET,
});
const s3Res = await s3Client.send(listObjCmd);
if (!s3Res.Contents) {
throw new TRPCError({
code: "GATEWAY_TIMEOUT",
message: "Could not get contents from Tigris",
});
}
const s3Photos = sift(
s3Res.Contents.map((obj) => {
if (!obj.Key?.endsWith("/")) {
return `https://fly.storage.tigris.dev/joemonk-photos/${obj.Key}`;
}
return null;
}),
);
const newPhotos = diff(s3Photos, currentSources);
if (newPhotos.length === 0) {
return [];
}
const photoData = newPhotos.map(async (fileName: string) => {
const getImageCmd = new GetObjectCommand({
Bucket: "joemonk-photos",
Key: fileName.replace("https://fly.storage.tigris.dev/joemonk-photos/", ""),
});
const imgRes = await s3Client.send(getImageCmd);
const image = await imgRes.Body?.transformToByteArray();
const { width, height, exif } = await sharp(image).metadata();
const blur = await sharp(image).resize({ width: 12, height: 12, fit: "inside" }).toBuffer();
const exifData = exif ? exifReader(exif) : undefined;
const photo: typeof photos.$inferInsert = {
src: fileName,
width: width ?? 10,
height: height ?? 10,
blur: blur,
camera: exifData?.Image?.Model ?? null,
exposureBiasValue: exifData?.Photo?.ExposureBiasValue ?? null,
fNumber: exifData?.Photo?.FNumber ?? null,
isoSpeedRatings: exifData?.Photo?.ISOSpeedRatings ?? null,
focalLength: exifData?.Photo?.FocalLength ?? null,
takenAt: exifData?.Photo?.DateTimeOriginal ?? null,
lensModel: exifData?.Photo?.LensModel ?? null,
};
return photo;
});
const images = await Promise.all(photoData);
await db.insert(photos).values(images);
return newPhotos;
}

130
src/server/api/trpc.ts Normal file
View File

@@ -0,0 +1,130 @@
/**
* YOU PROBABLY DON'T NEED TO EDIT THIS FILE, UNLESS:
* 1. You want to modify request context (see Part 1).
* 2. You want to create a new middleware or type of procedure (see Part 3).
*
* TL;DR - This is where all the tRPC server stuff is created and plugged in. The pieces you will
* need to use are documented accordingly near the end.
*/
import { initTRPC, TRPCError } from "@trpc/server";
import superjson from "superjson";
import { ZodError } from "zod";
import { auth } from "@/server/auth";
import { db } from "@/server/db";
/**
* 1. CONTEXT
*
* This section defines the "contexts" that are available in the backend API.
*
* These allow you to access things when processing a request, like the database, the session, etc.
*
* This helper generates the "internals" for a tRPC context. The API handler and RSC clients each
* wrap this and provides the required context.
*
* @see https://trpc.io/docs/server/context
*/
export const createTRPCContext = async (opts: { headers: Headers }) => {
const session = await auth();
return {
db,
session,
...opts,
};
};
/**
* 2. INITIALIZATION
*
* This is where the tRPC API is initialized, connecting the context and transformer. We also parse
* ZodErrors so that you get typesafety on the frontend if your procedure fails due to validation
* errors on the backend.
*/
const t = initTRPC.context<typeof createTRPCContext>().create({
transformer: superjson,
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError: error.cause instanceof ZodError ? error.cause.flatten() : null,
},
};
},
});
/**
* Create a server-side caller.
*
* @see https://trpc.io/docs/server/server-side-calls
*/
export const createCallerFactory = t.createCallerFactory;
/**
* 3. ROUTER & PROCEDURE (THE IMPORTANT BIT)
*
* These are the pieces you use to build your tRPC API. You should import these a lot in the
* "/src/server/api/routers" directory.
*/
/**
* This is how you create new routers and sub-routers in your tRPC API.
*
* @see https://trpc.io/docs/router
*/
export const createTRPCRouter = t.router;
/**
* Middleware for timing procedure execution and adding an artificial delay in development.
*
* You can remove this if you don't like it, but it can help catch unwanted waterfalls by simulating
* network latency that would occur in production but not in local development.
*/
const timingMiddleware = t.middleware(async ({ next, path }) => {
const start = Date.now();
if (t._config.isDev) {
// artificial delay in dev
const waitMs = Math.floor(Math.random() * 400) + 100;
await new Promise((resolve) => setTimeout(resolve, waitMs));
}
const result = await next();
const end = Date.now();
console.log(`[TRPC] ${path} took ${end - start}ms to execute`);
return result;
});
/**
* Public (unauthenticated) procedure
*
* This is the base piece you use to build new queries and mutations on your tRPC API. It does not
* guarantee that a user querying is authorized, but you can still access user session data if they
* are logged in.
*/
export const publicProcedure = t.procedure.use(timingMiddleware);
/**
* Protected (authenticated) procedure
*
* If you want a query or mutation to ONLY be accessible to logged in users, use this. It verifies
* the session is valid and guarantees `ctx.session.user` is not null.
*
* @see https://trpc.io/docs/procedures
*/
export const protectedProcedure = t.procedure.use(timingMiddleware).use(({ ctx, next }) => {
if (!ctx.session?.user) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next({
ctx: {
// infers the `session` as non-nullable
session: { ...ctx.session, user: ctx.session.user },
},
});
});

45
src/server/auth/config.ts Normal file
View File

@@ -0,0 +1,45 @@
import type { DefaultSession, NextAuthConfig } from "next-auth";
import { getBaseUrl } from "@/lib/base-url";
/**
* Module augmentation for `next-auth` types. Allows us to add custom properties to the `session`
* object and keep type safety.
*
* @see https://next-auth.js.org/getting-started/typescript#module-augmentation
*/
declare module "next-auth" {
interface Session extends DefaultSession {
user: {
id: string;
// ...other properties
// role: UserRole;
} & DefaultSession["user"];
}
// interface User {
// // ...other properties
// // role: UserRole;
// }
}
/**
* Options for NextAuth.js used to configure adapters, providers, callbacks, etc.
*
* @see https://next-auth.js.org/configuration/options
*/
export const authConfig = {
providers: [
{
id: "authelia",
name: "Authelia",
type: "oidc",
issuer: "https://auth.home.joemonk.co.uk",
clientId: process.env.AUTH_CLIENT_ID,
clientSecret: process.env.AUTH_CLIENT_SECRET,
wellKnown: "https://auth.home.joemonk.co.uk/.well-known/openid-configuration",
idToken: true,
},
],
trustHost: true,
redirectProxyUrl: `${getBaseUrl()}/api/auth`,
} satisfies NextAuthConfig;

10
src/server/auth/index.ts Normal file
View File

@@ -0,0 +1,10 @@
import NextAuth from "next-auth";
import { cache } from "react";
import { authConfig } from "./config";
const { auth: uncachedAuth, handlers, signIn, signOut } = NextAuth(authConfig);
const auth = cache(uncachedAuth);
export { auth, handlers, signIn, signOut };

18
src/server/db/index.ts Normal file
View File

@@ -0,0 +1,18 @@
import { type Client, createClient } from "@libsql/client";
import { drizzle } from "drizzle-orm/libsql";
import { env } from "@/env";
import * as schema from "./schema";
/**
* Cache the database connection in development. This avoids creating a new connection on every HMR
* update.
*/
const globalForDb = globalThis as unknown as {
client: Client | undefined;
};
export const client = globalForDb.client ?? createClient({ url: env.DATABASE_URL });
if (process.env.NODE_ENV !== "production") globalForDb.client = client;
export const db = drizzle(client, { schema });

20
src/server/db/schema.ts Normal file
View File

@@ -0,0 +1,20 @@
import { sqliteTable } from "drizzle-orm/sqlite-core";
export const photos = sqliteTable("photo", (d) => ({
id: d.integer({ mode: "number" }).primaryKey({ autoIncrement: true }),
src: d.text({ length: 256 }).notNull().unique(),
width: d.integer({ mode: "number" }).notNull(),
height: d.integer({ mode: "number" }).notNull(),
blur: d.blob({ mode: "buffer" }).notNull(),
camera: d.text({ length: 128 }),
title: d.text({ length: 128 }),
description: d.text({ mode: "json" }),
exposureBiasValue: d.integer({ mode: "number" }),
fNumber: d.real(),
isoSpeedRatings: d.integer({ mode: "number" }),
focalLength: d.integer({ mode: "number" }),
takenAt: d.integer({ mode: "timestamp" }),
lensModel: d.text({ length: 128 }),
}));

111
src/styles/globals.css Normal file
View File

@@ -0,0 +1,111 @@
/** biome-ignore-all lint/correctness/noUnknownProperty: Biome doesn't understand DaisyUI properties */
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@plugin "daisyui";
@plugin "daisyui/theme" {
name: "alucard";
default: true;
prefersdark: false;
color-scheme: "light";
--color-base-100: oklch(97.02% 0 0);
--color-base-200: oklch(95.20% 0.007 268.55);
--color-base-300: oklch(88.75% 0.015 264.49);
--color-base-content: oklch(23.93% 0 0);
--color-primary: oklch(64.21% 0.086 228.32);
--color-primary-content: oklch(100% 0 0);
--color-secondary: oklch(67.53% 0.129 27.41);
--color-secondary-content: oklch(23.93% 0 0);
--color-accent: oklch(50.93% 0.091 287.46);
--color-accent-content: oklch(100% 0 0);
--color-neutral: oklch(45.68% 0 0);
--color-neutral-content: oklch(100% 0 0);
--color-info: oklch(49.47% 0.122 243.83);
--color-info-content: oklch(100% 0 0);
--color-success: oklch(63.12% 0.124 141.91);
--color-success-content: oklch(0% 0 0);
--color-warning: oklch(76.96% 0.156 99.76);
--color-warning-content: oklch(0% 0 0);
--color-error: oklch(52.56% 0.199 5.45);
--color-error-content: oklch(100% 0 0);
--radius-selector: 1rem;
--radius-field: 0.5rem;
--radius-box: 1rem;
--size-selector: 0.25rem;
--size-field: 0.25rem;
--border: 1px;
--depth: 1;
--noise: 0;
}
@plugin "daisyui/theme" {
/* Nicked from the vscode soft theme https://github.com/dracula/visual-studio-code/blob/master/src/dracula.yml */
name: "dracula-soft";
default: true;
prefersdark: true;
color-scheme: "dark";
/* --color-base-50: oklch(34.02% 0.027 276.05); */
--color-base-100: oklch(28.82% 0.022 277.51);
--color-base-200: oklch(25.54% 0.019 280.49);
--color-base-300: oklch(21.99% 0.014 278.8);
--color-base-content: oklch(91% 0.02 278);
--color-primary: oklch(88.263% 0.093 212.846);
--color-primary-content: oklch(16.678% 0.024 66.558);
--color-secondary: oklch(83.392% 0.124 66.558);
--color-secondary-content: oklch(16.678% 0.024 66.558);
--color-accent: oklch(74.202% 0.148 301.883);
--color-accent-content: oklch(14.84% 0.029 301.883);
--color-neutral: oklch(38.94% 0.02 277.93);
--color-neutral-content: oklch(87.889% 0.006 275.524);
--color-info: oklch(75.461% 0.183 346.812);
--color-info-content: oklch(15.092% 0.036 346.812);
--color-success: oklch(87.099% 0.219 148.024);
--color-success-content: oklch(17.419% 0.043 148.024);
--color-warning: oklch(95.533% 0.134 112.757);
--color-warning-content: oklch(15.106% 0.026 112.757);
--color-error: oklch(68.22% 0.206 24.43);
--color-error-content: oklch(13.644% 0.041 24.43);
--radius-selector: 0.5rem;
--radius-field: 0.5rem;
--radius-box: 0.5rem;
--size-selector: 0.25rem;
--size-field: 0.25rem;
--border: 1px;
--depth: 0;
--noise: 0;
}
@theme {
--font-sans: var(--font-inter), ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--animate-spin: spin 2s linear infinite;
}
@custom-variant dark (&:where([data-theme=dracula-soft], [data-theme=dracula-soft] *));
@utility btn {
@apply shadow-none;
}
@utility pause {
animation-play-state: paused;
}
@utility play {
animation-play-state: running;
}
:root .prose {
--tw-prose-body: color-mix(in oklab, var(--color-base-content) 92%, #0000);
}

View File

@@ -1,60 +0,0 @@
"use client";
import React, { useState } from "react";
import superjson from "superjson";
import { httpBatchLink } from "@trpc/client";
import { createTRPCReact } from "@trpc/react-query";
import { getCurrentUrl } from "@/lib/current-url";
import { makeQueryClient } from "./query-client";
import { QueryClientProvider, type QueryClient } from "@tanstack/react-query";
import type { appRouter } from "./routers/_app";
export const trpc = createTRPCReact<typeof appRouter>();
let clientQueryClientSingleton: QueryClient;
function getQueryClient(): QueryClient {
if (typeof window === "undefined") {
// Server: always make a new query client
return makeQueryClient();
}
// Browser: use singleton pattern to keep the same query client
return (clientQueryClientSingleton ??= makeQueryClient());
}
function getUrl(): string {
const base = ((): string => {
if (typeof window !== "undefined") return "";
return getCurrentUrl();
})();
return `${base}/api/trpc`;
}
export function TRPCProvider(
props: Readonly<{
children: React.ReactNode;
}>
): React.JSX.Element {
// NOTE: Avoid useState when initializing the query client if you don't
// have a suspense boundary between this and the code that may
// suspend because React will throw away the client on the initial
// render if it suspends and there is no boundary
const queryClient = getQueryClient();
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
httpBatchLink({
transformer: superjson,
url: getUrl(),
}),
]
})
);
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
{props.children}
</QueryClientProvider>
</trpc.Provider>
);
}

View File

@@ -1,49 +0,0 @@
import { cache } from 'react';
import superjson from 'superjson';
import { initTRPC, TRPCError } from '@trpc/server';
import { auth } from '@/lib/auth';
interface Context {
user?: {
id?: string
name?: string | null
email?: string | null
image?: string | null
};
}
export const createTRPCContext = cache(async (): Promise<Context> => {
/**
* @see: https://trpc.io/docs/server/context
*/
const session = await auth();
return { user: session?.user };
});
// Avoid exporting the entire t-object
// since it's not very descriptive.
// For instance, the use of a t variable
// is common in i18n libraries.
const t = initTRPC.context<Context>().create({
/**
* @see https://trpc.io/docs/server/data-transformers
*/
transformer: superjson,
});
const authMiddleware = t.middleware(({ ctx, next }) => {
if (ctx.user?.name !== 'Joe') {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return next({
ctx: {
user: ctx.user,
},
});
});
// Base router and procedure helpers
export const createTRPCRouter = t.router;
export const createCallerFactory = t.createCallerFactory;
export const publicProcedure = t.procedure;
export const privateProcedure = t.procedure.use(authMiddleware);

View File

@@ -1,27 +1,20 @@
import {
defaultShouldDehydrateQuery,
QueryClient,
} from '@tanstack/react-query';
import { serialize, deserialize } from 'superjson';
import { defaultShouldDehydrateQuery, QueryClient } from "@tanstack/react-query";
import SuperJSON from "superjson";
export function makeQueryClient(): QueryClient {
return new QueryClient({
defaultOptions: {
queries: {
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
staleTime: 30 * 1000,
},
dehydrate: {
serializeData: serialize,
shouldDehydrateQuery: (query) =>
defaultShouldDehydrateQuery(query) ||
query.state.status === 'pending',
},
hydrate: {
deserializeData: deserialize,
},
},
});
}
export const createQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: {
// With SSR, we usually want to set some default staleTime
// above 0 to avoid refetching immediately on the client
staleTime: 30 * 1000,
},
dehydrate: {
serializeData: SuperJSON.serialize,
shouldDehydrateQuery: (query) => defaultShouldDehydrateQuery(query) || query.state.status === "pending",
},
hydrate: {
deserializeData: SuperJSON.deserialize,
},
},
});

70
src/trpc/react.tsx Normal file
View File

@@ -0,0 +1,70 @@
"use client";
import { type QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { httpBatchStreamLink, loggerLink } from "@trpc/client";
import { createTRPCReact } from "@trpc/react-query";
import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server";
import { useState } from "react";
import SuperJSON from "superjson";
import { getBaseUrl } from "@/lib/base-url";
import type { AppRouter } from "@/server/api/root";
import { createQueryClient } from "./query-client";
let clientQueryClientSingleton: QueryClient | undefined;
const getQueryClient = () => {
if (typeof window === "undefined") {
// Server: always make a new query client
return createQueryClient();
}
// Browser: use singleton pattern to keep the same query client
clientQueryClientSingleton ??= createQueryClient();
return clientQueryClientSingleton;
};
export const api = createTRPCReact<AppRouter>();
/**
* Inference helper for inputs.
*
* @example type HelloInput = RouterInputs['example']['hello']
*/
export type RouterInputs = inferRouterInputs<AppRouter>;
/**
* Inference helper for outputs.
*
* @example type HelloOutput = RouterOutputs['example']['hello']
*/
export type RouterOutputs = inferRouterOutputs<AppRouter>;
export function TRPCReactProvider(props: { children: React.ReactNode }) {
const queryClient = getQueryClient();
const [trpcClient] = useState(() =>
api.createClient({
links: [
loggerLink({
enabled: (op) => process.env.NODE_ENV === "development" || (op.direction === "down" && op.result instanceof Error),
}),
httpBatchStreamLink({
transformer: SuperJSON,
url: `${getBaseUrl()}/api/trpc`,
headers: () => {
const headers = new Headers();
headers.set("x-trpc-source", "nextjs-react");
return headers;
},
}),
],
}),
);
return (
<QueryClientProvider client={queryClient}>
<api.Provider client={trpcClient} queryClient={queryClient}>
{props.children}
</api.Provider>
</QueryClientProvider>
);
}

View File

@@ -1,11 +0,0 @@
// eslint-disable-next-line import/named
import { inferRouterInputs, inferRouterOutputs } from '@trpc/server';
import { createTRPCRouter } from '../init';
import { photosRouter } from './photos';
export const appRouter = createTRPCRouter({
photos: photosRouter,
});
export type RouterInput = inferRouterInputs<typeof appRouter>;
export type RouterOutput = inferRouterOutputs<typeof appRouter>;

View File

@@ -1,35 +0,0 @@
import { z } from 'zod';
import { createTRPCRouter, privateProcedure, publicProcedure } from '../init';
import { list } from './photos/list';
import { update } from './photos/update';
export const photosRouter = createTRPCRouter({
list: publicProcedure
.input(
z.object({
limit: z.number().nonnegative().default(1),
cursor: z.number().nonnegative().default(0),
})
.optional()
.default({}),
)
.query(async ({ input }) => {
const ret = await list({
limit: input.limit + 1,
cursor: input.cursor,
});
let next;
if (ret.length > input.limit) {
next = input.limit;
ret.pop();
}
return {
data: ret,
next
};
}),
update: privateProcedure.query(update)
});

View File

@@ -1,54 +0,0 @@
import { shake } from "radash";
import db from "@/db/db";
import { photosTable } from "@/db/schema/photo";
export type ImageData = {
width: number,
height: number,
blur: `data:image/${string}`,
src: string,
camera?: string,
exif: Partial<{
ExposureBiasValue: number,
FNumber: number,
ISOSpeedRatings: number,
FocalLength: number,
DateTimeOriginal: Date,
LensModel: string
}>,
title?: string,
description?: string
}
export type ListOptions = {
cursor: number,
limit: number
}
export async function list(options: ListOptions): Promise<ImageData[]> {
const currentSources = await db.select().from(photosTable)
.limit(options.limit)
.offset(options.cursor);
const images = currentSources.map((photo) => {
return {
width: photo.width,
height: photo.height,
blur: photo.blur as `data:image/${string}`,
src: photo.src,
camera: photo.camera ?? undefined,
exif: shake({
ExposureBiasValue: photo.exposureBiasValue,
FNumber: photo.fNumber,
ISOSpeedRatings: photo.isoSpeedRatings,
FocalLength: photo.focalLength,
DateTimeOriginal: photo.dateTimeOriginal,
LensModel: photo.lensModel
}),
title: photo.title ?? undefined,
description: photo.description ?? undefined
};
});
return images;
}

View File

@@ -1,82 +0,0 @@
import { S3Client, ListObjectsV2Command, GetObjectCommand } from "@aws-sdk/client-s3";
import exifReader from "exif-reader";
import { diff, sift } from "radash";
import sharp from "sharp";
import db from "@/db/db";
import { photosTable } from "@/db/schema/photo";
import { TRPCError } from "@trpc/server";
export async function update(): Promise<string[]> {
const photos = await db.select().from(photosTable);
const currentSources = photos.map((photo) => photo.src);
const s3Client = new S3Client({
region: "auto",
endpoint: `https://fly.storage.tigris.dev`,
});
const listObjCmd = new ListObjectsV2Command({
Bucket: "joemonk-photos"
});
const s3Res = await s3Client.send(listObjCmd);
if (!s3Res.Contents) {
throw new TRPCError({
code: "GATEWAY_TIMEOUT",
message: "Could not get contents from Tigris"
});
}
const s3Photos = sift(s3Res.Contents.map((obj) => {
if (!obj.Key?.endsWith('/')) {
return `https://fly.storage.tigris.dev/joemonk-photos/${obj.Key}`;
} else {
return null;
}
}));
const newPhotos = diff(s3Photos, currentSources);
if (newPhotos.length === 0) {
return [];
}
const imageData = newPhotos.map(async (fileName: string) => {
const getImageCmd = new GetObjectCommand({
Bucket: "joemonk-photos",
Key: fileName.replace("https://fly.storage.tigris.dev/joemonk-photos/", "")
});
const imgRes = await s3Client.send(getImageCmd);
const image = await imgRes.Body?.transformToByteArray();
const { width, height, exif } = await sharp(image).metadata();
const blur = await sharp(image)
.resize({ width: 12, height: 12, fit: 'inside' })
.toBuffer();
const exifData = exif ? exifReader(exif) : undefined;
const photo: typeof photosTable.$inferInsert = {
src: fileName,
width: width ?? 10,
height: height ?? 10,
blur: `data:image/jpeg;base64,${blur.toString('base64')}` as `data:image/${string}`,
camera: exifData?.Image?.Model ?? null,
exposureBiasValue: exifData?.Photo?.ExposureBiasValue ?? null,
fNumber: exifData?.Photo?.FNumber ?? null,
isoSpeedRatings: exifData?.Photo?.ISOSpeedRatings ?? null,
focalLength: exifData?.Photo?.FocalLength ?? null,
dateTimeOriginal: exifData?.Photo?.DateTimeOriginal ?? null,
lensModel: exifData?.Photo?.LensModel ?? null,
};
return photo;
});
const images = await Promise.all(imageData);
await db.insert(photosTable).values(images);
return newPhotos;
};

View File

@@ -1,14 +1,27 @@
import 'server-only'; // <-- ensure this file cannot be imported from the client
import { createHydrationHelpers } from '@trpc/react-query/rsc';
import { cache } from 'react';
import { createCallerFactory, createTRPCContext } from './init';
import { makeQueryClient } from './query-client';
import { appRouter } from './routers/_app';
// IMPORTANT: Create a stable getter for the query client that
// will return the same client during the same request.
export const getQueryClient = cache(makeQueryClient);
const caller = createCallerFactory(appRouter)(createTRPCContext);
export const { trpc, HydrateClient } = createHydrationHelpers<typeof appRouter>(
caller,
getQueryClient,
);
import "server-only";
import { createHydrationHelpers } from "@trpc/react-query/rsc";
import { headers } from "next/headers";
import { cache } from "react";
import { type AppRouter, createCaller } from "@/server/api/root";
import { createTRPCContext } from "@/server/api/trpc";
import { createQueryClient } from "./query-client";
/**
* This wraps the `createTRPCContext` helper and provides the required context for the tRPC API when
* handling a tRPC call from a React Server Component.
*/
const createContext = cache(async () => {
const heads = new Headers(await headers());
heads.set("x-trpc-source", "rsc");
return createTRPCContext({
headers: heads,
});
});
const getQueryClient = cache(createQueryClient);
const caller = createCaller(createContext);
export const { trpc: api, HydrateClient } = createHydrationHelpers<AppRouter>(caller, getQueryClient);

View File

@@ -1,56 +0,0 @@
import type { Config } from "tailwindcss";
const config: Config = {
darkMode: 'selector',
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
"./src/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
fontFamily: {
sans: ['var(--font-inter)'],
mono: ['var(--font-roboto-mono)'],
},
colors: {
// Nicked from the vs code version of the theme https://github.com/dracula/visual-studio-code/blob/master/src/dracula.yml
dracula: {
'bg-lightest': '#F8F8F2',
'bg-lighter': '#424450',
'bg-light': '#343746',
'bg': '#282A36',
'bg-dark': '#21222C',
'bg-darker': '#191A21',
'selection': '#44475A',
'comment': '#6272A4',
'cyan': '#8BE9FD',
'green': '#50FA7B',
'orange': '#FFB86C',
'pink': '#FF79C6',
'purple': '#BD93F9',
'red': '#FF5555',
'yellow': '#F1FA8C',
}
},
typography: () => ({
DEFAULT: {
css: {
h1: {
"margin-bottom": "0.4444em", // Default is (32/36)em, this is (16/36)em
},
h2: {
"margin-bottom": "0.4em", // Default is (16/20)em, this is (12/20)em
}
}
}
})
}
},
plugins: [
require('@tailwindcss/typography'),
require('tailwind-scrollbar')
],
};
export default config;

View File

@@ -1,42 +1,35 @@
{
"compilerOptions": {
"target": "ESNext",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}
"compilerOptions": {
/* Base Options: */
"esModuleInterop": true,
"skipLibCheck": true,
"target": "es2022",
"allowJs": true,
"resolveJsonModule": true,
"moduleDetection": "force",
"isolatedModules": true,
"verbatimModuleSyntax": true,
/* Strictness */
"strict": true,
"noUncheckedIndexedAccess": true,
"checkJs": true,
/* Bundled projects */
"lib": ["dom", "dom.iterable", "ES2022"],
"noEmit": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "preserve",
"plugins": [{ "name": "next" }],
"incremental": true,
/* Path Aliases */
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "**/*.cjs", "**/*.js", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}