No idea where I'm at with this, I think somewhat near the end though

This commit is contained in:
2025-10-18 00:35:28 +01:00
parent a2131623b5
commit f00b8f2bcb
54 changed files with 1635 additions and 2048 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

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
24

2
.vscode/launch.json vendored
View File

@@ -11,7 +11,7 @@
"name": "Next.js: debug client-side",
"type": "chrome",
"request": "launch",
"url": "https://3000.vscode.home.joemonk.co.uk/"
"url": "http://3000.vscode.localhost/"
},
{
"name": "Next.js: debug full stack",

14
.vscode/settings.json vendored
View File

@@ -4,5 +4,17 @@
"https://json.schemastore.org/github-workflow.json": "file:///workspace/next-portfolio/.gitea/workflows/deploy.yaml"
},
"editor.formatOnSave": true,
"typescript.format.enable": 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,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.0.6/schema.json",
"$schema": "https://biomejs.dev/schemas/2.2.5/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
@@ -10,7 +10,8 @@
},
"formatter": {
"enabled": true,
"indentStyle": "tab"
"indentStyle": "tab",
"lineWidth": 320
},
"linter": {
"enabled": true,
@@ -19,7 +20,8 @@
},
"domains": {
"next": "recommended",
"react": "recommended"
"react": "recommended",
"project": "recommended"
}
},
"javascript": {

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

@@ -6,7 +6,7 @@ CREATE TABLE `photo` (
`blur` blob NOT NULL,
`camera` text(128),
`title` text(128),
`description` text(1024),
`description` text,
`exposureBiasValue` integer,
`fNumber` real,
`isoSpeedRatings` integer,

View File

@@ -1,10 +0,0 @@
CREATE TABLE `challenges` (
`token` text PRIMARY KEY NOT NULL,
`data` text NOT NULL,
`expires` integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE `tokens` (
`key` text PRIMARY KEY NOT NULL,
`expires` integer NOT NULL
);

View File

@@ -1,132 +1,134 @@
{
"version": "6",
"dialect": "sqlite",
"id": "246363f6-a664-4ec6-b43d-0033fe21ff8c",
"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(1024)",
"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": {}
}
}
"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

@@ -1,187 +0,0 @@
{
"version": "6",
"dialect": "sqlite",
"id": "40ac544c-a258-4628-b121-7ed0403516d7",
"prevId": "246363f6-a664-4ec6-b43d-0033fe21ff8c",
"tables": {
"challenges": {
"name": "challenges",
"columns": {
"token": {
"name": "token",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"data": {
"name": "data",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires": {
"name": "expires",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"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(1024)",
"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": {}
},
"tokens": {
"name": "tokens",
"columns": {
"key": {
"name": "key",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"expires": {
"name": "expires",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

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

2156
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,54 +12,54 @@
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio",
"dev": "next dev --turbopack",
"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.839.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.3.4",
"@next/mdx": "^15.5.4",
"@t3-oss/env-nextjs": "^0.13.8",
"@tailwindcss/typography": "^0.5.16",
"@tanstack/react-query": "^5.81.5",
"@tiptap/extension-typography": "^3.2.2",
"@tiptap/pm": "^3.2.2",
"@tiptap/react": "^3.2.2",
"@tiptap/starter-kit": "^3.2.2",
"@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/bun": "^1.2.17",
"@types/mdx": "^2.0.13",
"babel-plugin-react-compiler": "^19.1.0-rc.2",
"daisyui": "^5.0.43",
"daisyui": "^5.1.18",
"drizzle-orm": "^0.44.2",
"exif-reader": "^2.0.2",
"framer-motion": "^12.19.2",
"framer-motion": "^12.23.21",
"glob": "^11.0.3",
"next": "^15.3.4",
"next-auth": "5.0.0-beta.29",
"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.2",
"sharp": "^0.34.4",
"superjson": "^2.2.2",
"yet-another-react-lightbox": "^3.23.4",
"zod": "^3.25.67"
"zod": "^4.1.11"
},
"devDependencies": {
"@biomejs/biome": "2.0.6",
"@biomejs/biome": "2.2.5",
"@tailwindcss/postcss": "^4.1.11",
"@types/node": "24.5.2",
"@types/react": "^19.1.8",

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

@@ -9,9 +9,7 @@ export default function RootLayout({
return (
<>
<NavBar />
<main className="mx-auto w-full flex-1 px-6 pt-8 pb-12 align-middle lg:max-w-5xl">
{children}
</main>
<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

@@ -1,19 +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"
>
<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"
/>
<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

@@ -1,13 +1,6 @@
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"
>
<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"

View File

@@ -7,24 +7,10 @@ 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 {
Editor,
EditorContent,
useEditor,
useEditorState,
type Content,
} from "@tiptap/react";
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>;
}) {
export default function Tiptap({ onChange, initContent, editorRef }: { onChange: (args: unknown) => void; initContent?: Content; editorRef: React.RefObject<Editor | null> }) {
const editor = useEditor({
extensions: [
Text,

View File

@@ -1,9 +1,11 @@
"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 { useRef, useState } 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";
@@ -11,14 +13,10 @@ import { api } from "@/trpc/react";
import DirSvg from "./dir-svg";
import ImageSvg from "./file-svg";
import Tiptap from "./photo-editor";
import type { Editor } from "@tiptap/react";
// - 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"),
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()),
@@ -85,15 +83,9 @@ function renderTree(node: DirectoryTree, pathSoFar = ""): Item[] {
return items;
}
function RenderLeaf(
leaf: Item[],
selectImageTab: (path: string) => void,
selectedImage: PhotoData | undefined,
) {
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;
const selectedLeaf = `https://fly.storage.tigris.dev/joemonk-photos/${leaf.fullPath}` === selectedImage?.src;
if (leaf.children?.length) {
return (
<li key={leaf.fullPath}>
@@ -109,11 +101,7 @@ function RenderLeaf(
}
return (
<li key={leaf.fullPath}>
<button
type="button"
className={selectedLeaf ? "active" : ""}
onClick={() => selectImageTab(leaf.fullPath)}
>
<button type="button" className={selectedLeaf ? "active" : ""} onClick={() => selectImageTab(leaf.fullPath)}>
<ImageSvg />
{leaf.name}
</button>
@@ -122,10 +110,27 @@ function RenderLeaf(
});
}
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(
{},
{
@@ -136,7 +141,6 @@ export function PhotoTab(): React.JSX.Element {
},
);
const modifyMutate = api.photos.modify.useMutation();
const {
register,
handleSubmit,
@@ -155,177 +159,134 @@ export function PhotoTab(): React.JSX.Element {
return <p>{listQuery.error.message}</p>;
}
const images = listQuery.data?.pages.flatMap((data) => data.data);
if (!images || images?.length === 0) {
return <p>No Images</p>;
}
if (listQuery.hasNextPage) {
listQuery.fetchNextPage();
}
const selectImage = (path: string) => {
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 });
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 tree = buildDirectoryTree(images?.map((img) => img.src.substring("https://fly.storage.tigris.dev/joemonk-photos/".length)) ?? []);
const renderedTree = renderTree(tree);
return (
<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">
{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}`}
<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,
}),
)}
>
<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>
<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}
<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

@@ -4,21 +4,10 @@ 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"
/>
<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
/>
<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>

View File

@@ -9,18 +9,7 @@ export default async function Photos(): Promise<React.JSX.Element> {
<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"
/>
<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

@@ -9,12 +9,9 @@ export async function generateStaticParams(): Promise<
slug: string[];
}[]
> {
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 postData = posts.map((post) => ({
slug: [post.split("/").at(-1)?.slice(0, -4) ?? ""],
@@ -30,11 +27,6 @@ export default async function Post({
slug: string[];
};
}): Promise<React.JSX.Element> {
const Post = dynamic(
async () =>
import(
`../../../../markdown/posts/[...slug]/${(await params).slug.join("/")}.mdx`
),
);
const Post = dynamic(async () => import(`../../../../markdown/posts/[...slug]/${(await params).slug.join("/")}.mdx`));
return <Post />;
}

View File

@@ -13,18 +13,13 @@ type postDetails = {
};
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: string) => {
const slug = [post.split("/").at(-1)?.slice(0, -4)];
const mdxFile = (await import(
`../../../../src/markdown/posts/[...slug]/${slug.join("/")}.mdx`
)) as postDetails;
const mdxFile = (await import(`../../../../src/markdown/posts/[...slug]/${slug.join("/")}.mdx`)) as postDetails;
return {
link: `/posts/${slug.join("/")}`,
metadata: mdxFile.metadata,
@@ -32,10 +27,7 @@ async function loadPostDetails(): Promise<postDetails[]> {
});
const postData = await Promise.all(loadPostData);
return postData.sort(
(postA, postB) =>
Date.parse(postB.metadata.date) - Date.parse(postA.metadata.date),
);
return postData.sort((postA, postB) => Date.parse(postB.metadata.date) - Date.parse(postA.metadata.date));
}
const getPosts = unstable_cache(loadPostDetails, ["posts"], {

View File

@@ -19,15 +19,8 @@ export default async function LogIn(): Promise<React.JSX.Element | undefined> {
}
}}
>
<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" : ""
}`}
/>
<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>

View File

@@ -30,24 +30,11 @@ const content: ExperienceContent[] = [
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.
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.
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.
</>
),
},
@@ -68,29 +55,13 @@ const content: ExperienceContent[] = [
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.
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.
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.
</>
),
},
@@ -114,11 +85,7 @@ const content: ExperienceContent[] = [
},
];
function Experience({
content,
}: {
content: ExperienceContent;
}): React.JSX.Element {
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">
@@ -132,9 +99,7 @@ function Experience({
<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 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>
@@ -147,9 +112,7 @@ 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>
<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>
@@ -157,12 +120,8 @@ export default function Cv(): React.JSX.Element {
<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.
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">
@@ -171,10 +130,7 @@ export default function Cv(): React.JSX.Element {
</div>
<div className="flex flex-col gap-4 py-2">
{content.map((expContent) => (
<Experience
content={expContent}
key={`${expContent.company}_${expContent.title}`}
/>
<Experience content={expContent} key={`${expContent.company}_${expContent.title}`} />
))}
</div>
</div>

View File

@@ -2,29 +2,14 @@
import Image from "next/image";
import type React from "react";
import {
isImageFitCover,
isImageSlide,
useLightboxProps,
useLightboxState,
} from "yet-another-react-lightbox";
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 {
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 },
@@ -34,17 +19,9 @@ export default function LightboxImage({
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 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;
const height = !cover ? Math.round(Math.min(rect.height, (rect.width / slide.width) * slide.height)) : rect.height;
return (
<div style={{ position: "relative", width, height }}>
@@ -62,11 +39,7 @@ export default function LightboxImage({
cursor: click ? "pointer" : undefined,
}}
sizes={`${Math.ceil((width / window.innerWidth) * 100)}vw`}
onClick={
offset === 0
? (): void => click?.({ index: currentIndex })
: undefined
}
onClick={offset === 0 ? (): void => click?.({ index: currentIndex }) : undefined}
/>
</div>
);

View File

@@ -14,13 +14,7 @@ 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 {
export function Lightbox({ photoData, children }: { photoData: PhotoData[]; children: React.JSX.Element[] }): React.JSX.Element {
const [active, setActive] = useState<number | null>(null);
return (
@@ -58,10 +52,7 @@ export function Lightbox({
);
}
export default function FilteredLightbox(props: {
photoData: PhotoData[];
children: React.JSX.Element[];
}): React.JSX.Element {
export default function FilteredLightbox(props: { photoData: PhotoData[]; children: React.JSX.Element[] }): React.JSX.Element {
const photoQuery = api.photos.list.useInfiniteQuery(
{
limit: 1,
@@ -76,7 +67,10 @@ export default function FilteredLightbox(props: {
],
pageParams: [0],
},
getNextPageParam: (lastPage) => lastPage.next,
getNextPageParam: (lastPage) => {
console.log(lastPage);
return lastPage.next ? lastPage.next > 0 : null;
},
},
);
@@ -88,33 +82,13 @@ export default function FilteredLightbox(props: {
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"
/>
));
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 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

@@ -1,15 +1,6 @@
"use client";
import {
Bars3Icon,
HomeModernIcon,
XMarkIcon,
} from "@heroicons/react/24/outline";
import {
AnimatePresence,
domAnimation,
LazyMotion,
motion,
} from "framer-motion";
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";
@@ -25,10 +16,7 @@ type NavBarClientProps = {
};
// TODO
export default function NavBarClient({
LogIn,
navigation,
}: NavBarClientProps): React.JSX.Element {
export default function NavBarClient({ LogIn, navigation }: NavBarClientProps): React.JSX.Element {
const [open, setOpen] = useState(false);
const pathname = usePathname();
@@ -54,16 +42,9 @@ export default function NavBarClient({
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" />
)}
{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="/"
>
<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">
@@ -71,9 +52,7 @@ export default function NavBarClient({
<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" : ""
}`}
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}
@@ -89,23 +68,10 @@ export default function NavBarClient({
</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"
>
<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}
>
<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>
))}

View File

@@ -16,7 +16,6 @@ export default async function NavBar(): Promise<React.JSX.Element> {
let nav = structuredClone(defaultNavigation);
const session = await auth();
console.log(session);
if (session?.user) {
nav = nav.concat(structuredClone(authedNavigation));
}

View File

@@ -11,9 +11,7 @@ type PostHeaderProps = {
};
// TODO
export default function PostHeader({
metadata,
}: PostHeaderProps): React.JSX.Element {
export default function PostHeader({ metadata }: PostHeaderProps): React.JSX.Element {
return (
<>
<h1 className="mb-2">{metadata.title}</h1>

View File

@@ -16,11 +16,7 @@ export default function ThemeSwitcher(): React.JSX.Element {
};
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}
>
<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

@@ -23,9 +23,7 @@ const handler = (req: NextRequest) =>
onError:
process.env.NODE_ENV === "development"
? ({ path, error }) => {
console.error(
`❌ tRPC failed on ${path ?? "<no-path>"}: ${error.message}`,
);
console.error(`❌ tRPC failed on ${path ?? "<no-path>"}: ${error.message}`);
}
: undefined,
});

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>
);
}

View File

@@ -18,15 +18,9 @@ const inter = Inter({
weight: ["300", "400", "500", "600"],
});
export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return (
<html
lang="en"
className={`${inter.variable} w-screen overflow-x-hidden`}
suppressHydrationWarning
>
<html lang="en" className={`${inter.variable} w-screen overflow-x-hidden`} suppressHydrationWarning>
<head>
<script
id="SetTheme"
@@ -37,9 +31,9 @@ export default function RootLayout({
document.documentElement.setAttribute('data-theme', localStorage.theme)
} else {
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.setAttribute('data-theme', 'dark')
document.documentElement.setAttribute('data-theme', 'dracula-soft')
} else {
document.documentElement.setAttribute('data-theme', 'light')
document.documentElement.setAttribute('data-theme', 'alucard')
}
}`,
}}

View File

@@ -7,19 +7,16 @@ export const env = createEnv({
* isn't built with invalid env vars.
*/
server: {
AUTH_SECRET:
process.env.NODE_ENV === "production"
? z.string()
: z.string().optional(),
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.string().url(),
NODE_ENV: z
.enum(["development", "test", "production"])
.default("development"),
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(),
PORT: z.number({ coerce: true }).int().default(3000),
S3_ENDPOINT: z.string(),
S3_BUCKET: z.string(),
PORT: z.coerce.number().int().default(3000),
},
/**
@@ -43,6 +40,8 @@ export const env = createEnv({
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,
},
/**

View File

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

View File

@@ -3,16 +3,8 @@ 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 mx-auto first:prose-h2:mt-8">
{children}
</article>
);
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

@@ -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

@@ -1,3 +1,4 @@
import { count } from "drizzle-orm";
import { shake } from "radash";
import { db } from "@/server/db";
import { photos } from "@/server/db/schema";
@@ -25,12 +26,12 @@ export type ListOptions = {
limit: number;
};
export async function list(options: ListOptions): Promise<PhotoData[]> {
const currentSources = await db
.select()
.from(photos)
.limit(options.limit)
.offset(options.cursor);
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 {
@@ -48,9 +49,12 @@ export async function list(options: ListOptions): Promise<PhotoData[]> {
lensModel: photo.lensModel,
}),
title: photo.title ?? undefined,
description: photo.description ?? undefined,
description: (photo.description as string) ?? undefined,
};
});
return images;
return {
images,
count: photosCount,
};
}

View File

@@ -10,8 +10,5 @@ export async function modify(mod: {
content: unknown[];
};
}): Promise<void> {
await db
.update(photos)
.set({ title: mod.title, description: mod.description })
.where(eq(photos.src, mod.src));
await db.update(photos).set({ title: mod.title, description: mod.description }).where(eq(photos.src, mod.src));
}

View File

@@ -1,50 +1,47 @@
import { z } from "zod";
import {
createTRPCRouter,
publicProcedure,
protectedProcedure,
} from "@/server/api/trpc";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "@/server/api/trpc";
import { count } from "./count";
import { list } from "./list";
import { update } from "./update";
import { modify } from "./modify";
import { update } from "./update";
export const photosRouter = createTRPCRouter({
list: publicProcedure
.input(
z
.object({
limit: z.number().nonnegative().default(1),
limit: z.number().nonnegative().default(3),
cursor: z.number().nonnegative().default(0),
})
.optional()
.default({}),
.default({
limit: 3,
cursor: 0,
}),
)
.query(async ({ input }) => {
const ret = await list({
limit: input.limit + 1,
limit: input.limit,
cursor: input.cursor,
});
let next: number | undefined;
if (ret.length > input.limit) {
next = input.limit;
ret.pop();
if (ret.count > input.limit + input.cursor) {
next = input.limit + input.cursor;
}
return {
data: ret,
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"),
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(),

View File

@@ -1,26 +1,27 @@
import {
GetObjectCommand,
ListObjectsV2Command,
S3Client,
} from "@aws-sdk/client-s3";
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().from(photos);
const allPhotos = await db.select({ src: photos.src }).from(photos);
const currentSources = allPhotos.map((photo) => photo.src);
const s3Client = new S3Client({
region: "auto",
endpoint: "https://fly.storage.tigris.dev",
endpoint: env.S3_ENDPOINT,
credentials: {
accessKeyId: env.S3_ACCESS_KEY_ID,
secretAccessKey: env.S3_SECRET_ACCESS_KEY,
},
});
const listObjCmd = new ListObjectsV2Command({
Bucket: "joemonk-photos",
Bucket: env.S3_BUCKET,
});
const s3Res = await s3Client.send(listObjCmd);
@@ -49,18 +50,13 @@ export async function update(): Promise<string[]> {
const photoData = newPhotos.map(async (fileName: string) => {
const getImageCmd = new GetObjectCommand({
Bucket: "joemonk-photos",
Key: fileName.replace(
"https://fly.storage.tigris.dev/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 blur = await sharp(image).resize({ width: 12, height: 12, fit: "inside" }).toBuffer();
const exifData = exif ? exifReader(exif) : undefined;
const photo: typeof photos.$inferInsert = {

View File

@@ -50,8 +50,7 @@ const t = initTRPC.context<typeof createTRPCContext>().create({
...shape,
data: {
...shape.data,
zodError:
error.cause instanceof ZodError ? error.cause.flatten() : null,
zodError: error.cause instanceof ZodError ? error.cause.flatten() : null,
},
};
},
@@ -118,16 +117,14 @@ export const publicProcedure = t.procedure.use(timingMiddleware);
*
* @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 },
},
});
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 },
},
});
});

View File

@@ -36,8 +36,7 @@ export const authConfig = {
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",
wellKnown: "https://auth.home.joemonk.co.uk/.well-known/openid-configuration",
idToken: true,
},
],

View File

@@ -12,8 +12,7 @@ const globalForDb = globalThis as unknown as {
client: Client | undefined;
};
export const client =
globalForDb.client ?? createClient({ url: env.DATABASE_URL });
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 });

View File

@@ -10,7 +10,7 @@ export const photos = sqliteTable("photo", (d) => ({
camera: d.text({ length: 128 }),
title: d.text({ length: 128 }),
description: d.blob({ mode: "json" }),
description: d.text({ mode: "json" }),
exposureBiasValue: d.integer({ mode: "number" }),
fNumber: d.real(),
isoSpeedRatings: d.integer({ mode: "number" }),

View File

@@ -49,7 +49,7 @@
@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: false;
default: true;
prefersdark: true;
color-scheme: "dark";
@@ -90,9 +90,8 @@
}
@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";
--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] *));
@@ -100,7 +99,13 @@
@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) !important;
--tw-prose-body: color-mix(in oklab, var(--color-base-content) 92%, #0000);
}

View File

@@ -1,7 +1,4 @@
import {
defaultShouldDehydrateQuery,
QueryClient,
} from "@tanstack/react-query";
import { defaultShouldDehydrateQuery, QueryClient } from "@tanstack/react-query";
import SuperJSON from "superjson";
export const createQueryClient = () =>
@@ -14,9 +11,7 @@ export const createQueryClient = () =>
},
dehydrate: {
serializeData: SuperJSON.serialize,
shouldDehydrateQuery: (query) =>
defaultShouldDehydrateQuery(query) ||
query.state.status === "pending",
shouldDehydrateQuery: (query) => defaultShouldDehydrateQuery(query) || query.state.status === "pending",
},
hydrate: {
deserializeData: SuperJSON.deserialize,

View File

@@ -6,6 +6,7 @@ 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";
@@ -44,9 +45,7 @@ export function TRPCReactProvider(props: { children: React.ReactNode }) {
api.createClient({
links: [
loggerLink({
enabled: (op) =>
process.env.NODE_ENV === "development" ||
(op.direction === "down" && op.result instanceof Error),
enabled: (op) => process.env.NODE_ENV === "development" || (op.direction === "down" && op.result instanceof Error),
}),
httpBatchStreamLink({
transformer: SuperJSON,
@@ -69,9 +68,3 @@ export function TRPCReactProvider(props: { children: React.ReactNode }) {
</QueryClientProvider>
);
}
function getBaseUrl() {
if (typeof window !== "undefined") return window.location.origin;
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`;
return "https://3000.vscode.home.joemonk.co.uk";
}

View File

@@ -24,7 +24,4 @@ const createContext = cache(async () => {
const getQueryClient = cache(createQueryClient);
const caller = createCaller(createContext);
export const { trpc: api, HydrateClient } = createHydrationHelpers<AppRouter>(
caller,
getQueryClient,
);
export const { trpc: api, HydrateClient } = createHydrationHelpers<AppRouter>(caller, getQueryClient);

View File

@@ -30,13 +30,6 @@
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
"**/*.cjs",
"**/*.js",
".next/types/**/*.ts"
],
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "**/*.cjs", "**/*.js", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}