Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f00b8f2bcb | |||
| a2131623b5 | |||
| 784f7320a1 | |||
| 42caeb8834 | |||
| 8806f72f2a | |||
| eb0907fdfd | |||
| 813603282e | |||
| 6860d30e1c | |||
| 89875a9341 | |||
| 95f317fd75 |
9
.continue/agents/new-agent.yaml
Normal file
9
.continue/agents/new-agent.yaml
Normal 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,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
14
.gitignore
vendored
@@ -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
|
||||
2
.vscode/launch.json
vendored
2
.vscode/launch.json
vendored
@@ -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
14
.vscode/settings.json
vendored
@@ -3,4 +3,18 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:22 AS base
|
||||
FROM node:24 AS base
|
||||
|
||||
# Install dependencies only when needed
|
||||
FROM base AS deps
|
||||
|
||||
41
README.md
41
README.md
@@ -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
40
biome.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
23
docker-compose.yaml
Normal file
23
docker-compose.yaml
Normal 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
|
||||
11
docker/traefik/config/portfolio.yaml
Normal file
11
docker/traefik/config/portfolio.yaml
Normal 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
|
||||
11
docker/traefik/config/traefik.yaml
Normal file
11
docker/traefik/config/traefik.yaml
Normal file
@@ -0,0 +1,11 @@
|
||||
http:
|
||||
routers:
|
||||
traefik:
|
||||
entryPoints: http
|
||||
rule: "Host(`traefik.localhost`)"
|
||||
service: traefik
|
||||
services:
|
||||
traefik:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: http://traefik:8080
|
||||
@@ -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',
|
||||
import { env } from "@/env";
|
||||
|
||||
export default {
|
||||
schema: "./src/server/db/schema.ts",
|
||||
dialect: "sqlite",
|
||||
dbCredentials: {
|
||||
url: `${process.cwd()}/db.sql`,
|
||||
url: env.DATABASE_URL,
|
||||
},
|
||||
});
|
||||
} satisfies Config;
|
||||
|
||||
18
drizzle/0000_adorable_golden_guardian.sql
Normal file
18
drizzle/0000_adorable_golden_guardian.sql
Normal 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`);
|
||||
134
drizzle/meta/0000_snapshot.json
Normal file
134
drizzle/meta/0000_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
13
drizzle/meta/_journal.json
Normal file
13
drizzle/meta/_journal.json
Normal 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
35
next.config.js
Normal 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);
|
||||
@@ -1,36 +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",
|
||||
turbo: {
|
||||
|
||||
}
|
||||
},
|
||||
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));
|
||||
9395
package-lock.json
generated
9395
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
106
package.json
106
package.json
@@ -2,68 +2,74 @@
|
||||
"name": "next-portfolio",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"build:analyse": "ANALYZE=true npm run 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",
|
||||
"lint": "next lint",
|
||||
"lint:fix": "next lint --fix"
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.750.0",
|
||||
"@dimforge/rapier2d-compat": "^0.14.0",
|
||||
"@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/bundle-analyzer": "^15.1.7",
|
||||
"@next/mdx": "^15.1.7",
|
||||
"@pixi-essentials/object-pool": "^1.0.1",
|
||||
"@pixi/events": "^7.4.2",
|
||||
"@tailwindcss/postcss": "^4.0.8",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tanstack/react-query": "^5.66.9",
|
||||
"@tanstack/react-virtual": "^3.13.0",
|
||||
"@trpc/client": "^11.0.0-rc.802",
|
||||
"@trpc/react-query": "^11.0.0-rc.802",
|
||||
"@trpc/server": "^11.0.0-rc.802",
|
||||
"@types/better-sqlite3": "^7.6.12",
|
||||
"@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",
|
||||
"@types/node": "^22.13.5",
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"@typescript-eslint/eslint-plugin": "^8.14.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"babel-plugin-react-compiler": "beta",
|
||||
"better-sqlite3": "^11.8.1",
|
||||
"client-only": "^0.0.1",
|
||||
"drizzle-kit": "^0.30.4",
|
||||
"drizzle-orm": "^0.39.3",
|
||||
"eslint": "^9.21.0",
|
||||
"eslint-config-next": "^15.1.7",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"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.4.7",
|
||||
"glob": "^11.0.1",
|
||||
"million": "^3.1.11",
|
||||
"next": "15.2.0-canary.69",
|
||||
"next-auth": "5.0.0-beta.25",
|
||||
"pixi-filters": "^6.1.0",
|
||||
"pixi-viewport": "^6.0.3",
|
||||
"pixi.js": "^8.8.0",
|
||||
"postcss": "^8.5.3",
|
||||
"radash": "^12.1.0",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"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",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"server-only": "^0.0.1",
|
||||
"sharp": "^0.33.5",
|
||||
"sharp": "^0.34.4",
|
||||
"superjson": "^2.2.2",
|
||||
"tailwind-scrollbar": "^4.0.0",
|
||||
"tailwindcss": "^4.0.8",
|
||||
"typescript": "^5.7.3",
|
||||
"yet-another-react-lightbox": "^3.21.7",
|
||||
"zod": "^3.24.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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
module.exports = {
|
||||
plugins: ["@tailwindcss/postcss"],
|
||||
export default {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
1
reset.d.ts
vendored
Normal file
1
reset.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
import "@total-typescript/ts-reset";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,15 @@
|
||||
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'>
|
||||
<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
18
src/app/(root)/error.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import Wrapper from "./wrapper";
|
||||
|
||||
export default function Game(): React.JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<Wrapper/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import React from "react";
|
||||
|
||||
const MapGen = dynamic<Record<string, never>>(() => import('../../../../games/games/MapGen/MapGenWrapper').then((module) => module.default), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
export default function Wrapper(): React.ReactNode {
|
||||
return (
|
||||
<div className="flex flex-1">
|
||||
<MapGen/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import Wrapper from "./wrapper";
|
||||
|
||||
export default function Game(): React.JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<Wrapper/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import React from "react";
|
||||
|
||||
const TestGame = dynamic<Record<string, never>>(() => import('../../../../games/games/TestGame/TestGameWrapper').then((module) => module.default), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
export default function Wrapper(): React.ReactNode {
|
||||
return (
|
||||
<div className="flex flex-1">
|
||||
<TestGame/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
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,
|
||||
@@ -11,11 +9,7 @@ export default function RootLayout({
|
||||
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>
|
||||
<main className="mx-auto w-full flex-1 px-6 pt-8 pb-12 align-middle lg:max-w-5xl">{children}</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
|
||||
8
src/app/(root)/manage/_components/dir-svg.tsx
Normal file
8
src/app/(root)/manage/_components/dir-svg.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
12
src/app/(root)/manage/_components/file-svg.tsx
Normal file
12
src/app/(root)/manage/_components/file-svg.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
58
src/app/(root)/manage/_components/photo-editor.tsx
Normal file
58
src/app/(root)/manage/_components/photo-editor.tsx
Normal 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} />;
|
||||
}
|
||||
293
src/app/(root)/manage/_components/photo-tab.tsx
Normal file
293
src/app/(root)/manage/_components/photo-tab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
src/app/(root)/manage/page.tsx
Normal file
17
src/app/(root)/manage/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
<FilteredLightbox photoData={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"
|
||||
/>
|
||||
<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>
|
||||
</TRPCProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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`,
|
||||
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: 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);
|
||||
const Post = dynamic(async () => import(`../../../../markdown/posts/[...slug]/${(await params).slug.join("/")}.mdx`));
|
||||
return <Post />;
|
||||
}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function Post({children}: {children: React.JSX.Element}): React.JSX.Element {
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { glob } from "glob";
|
||||
import { getCurrentUrl } from "@/lib/current-url";
|
||||
import { unstable_cache } from "next/cache";
|
||||
import Link from "next/link";
|
||||
|
||||
@@ -8,34 +7,27 @@ type postDetails = {
|
||||
metadata: {
|
||||
title: string;
|
||||
date: string;
|
||||
coverImage: string;
|
||||
blurb: string;
|
||||
shortBlurb: string;
|
||||
tags: string[];
|
||||
};
|
||||
};
|
||||
|
||||
async function loadPostDetails(): Promise<postDetails[]> {
|
||||
const posts = await glob(
|
||||
`${process.cwd()}/src/markdown/posts/[[]...slug[]]/**/*.mdx`,
|
||||
{
|
||||
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;
|
||||
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: getCurrentUrl() + "/posts/" + slug.join("/"),
|
||||
link: `/posts/${slug.join("/")}`,
|
||||
metadata: mdxFile.metadata,
|
||||
};
|
||||
});
|
||||
|
||||
const postData = await Promise.all(loadPostData);
|
||||
return postData;
|
||||
return postData.sort((postA, postB) => Date.parse(postB.metadata.date) - Date.parse(postA.metadata.date));
|
||||
}
|
||||
|
||||
const getPosts = unstable_cache(loadPostDetails, ["posts"], {
|
||||
@@ -45,26 +37,30 @@ const getPosts = unstable_cache(loadPostDetails, ["posts"], {
|
||||
export default async function Posts(): Promise<React.JSX.Element> {
|
||||
const postDetails = await getPosts();
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-wrap sm:grid-cols-2">
|
||||
{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">
|
||||
<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}`}>
|
||||
<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 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>
|
||||
);
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
28
src/app/_components/auth/login.tsx
Normal file
28
src/app/_components/auth/login.tsx
Normal 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
139
src/app/_components/cv.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
12
src/app/_components/footer.tsx
Normal file
12
src/app/_components/footer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
src/app/_components/lightbox-image.tsx
Normal file
46
src/app/_components/lightbox-image.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
95
src/app/_components/lightbox.tsx
Normal file
95
src/app/_components/lightbox.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
85
src/app/_components/navbar-client.tsx
Normal file
85
src/app/_components/navbar-client.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
src/app/_components/navbar.tsx
Normal file
24
src/app/_components/navbar.tsx
Normal 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} />;
|
||||
}
|
||||
33
src/app/_components/post-header.tsx
Normal file
33
src/app/_components/post-header.tsx
Normal 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" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
24
src/app/_components/theme-switcher.tsx
Normal file
24
src/app/_components/theme-switcher.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
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');
|
||||
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;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export function GET(): Response {
|
||||
return NextResponse.json({ status: 200 }, { status: 200 });
|
||||
|
||||
@@ -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> =>
|
||||
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',
|
||||
endpoint: "/api/trpc",
|
||||
req,
|
||||
router: appRouter,
|
||||
createContext: createTRPCContext,
|
||||
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
25
src/app/error.tsx
Normal 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 |
@@ -1,7 +0,0 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@layer utilities {
|
||||
.text-balance {
|
||||
text-wrap: balance;
|
||||
}
|
||||
}
|
||||
@@ -1,40 +1,48 @@
|
||||
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",
|
||||
icons: [{ rel: "icon", url: "/favicon.ico" }],
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>): React.JSX.Element {
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-inter",
|
||||
weight: ["300", "400", "500", "600"],
|
||||
});
|
||||
|
||||
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||
return (
|
||||
// Use suppress hydration warnings to add the dark theme class on client
|
||||
<html className={`${inter.variable} font-sans`} lang="en" suppressHydrationWarning>
|
||||
<html lang="en" className={`${inter.variable} w-screen overflow-x-hidden`} suppressHydrationWarning>
|
||||
<head>
|
||||
<script id="SetTheme"
|
||||
<script
|
||||
id="SetTheme"
|
||||
// biome-ignore lint/security/noDangerouslySetInnerHtml: Doing some pre-render theming
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
if (localStorage.theme !== 'dark' || (!('theme' in localStorage) && !window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
document.documentElement.classList.remove('dark');
|
||||
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 className="flex min-h-screen flex-col">
|
||||
<TRPCReactProvider>
|
||||
<HydrateClient>{children}</HydrateClient>
|
||||
</TRPCReactProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
||||
|
||||
export default drizzle(`${process.cwd()}/db.sql`);
|
||||
@@ -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
57
src/env.js
Normal 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,
|
||||
});
|
||||
@@ -1,82 +0,0 @@
|
||||
import BaseGameApp from '@/core/BaseGameApp/BaseGameApp';
|
||||
import Registry from '@/utils/Registry';
|
||||
import Control from '@/control/Control';
|
||||
import Tile, { TileDir } from './Tile';
|
||||
import Vector from '@/games/lib/utils/Vector';
|
||||
import { min, shuffle } from 'radash';
|
||||
|
||||
const grid = new Vector(64, 256);
|
||||
|
||||
/**
|
||||
* A test "game" to start generating useful examples, building up classes as needed etc
|
||||
*/
|
||||
class TestGameApp extends BaseGameApp {
|
||||
private _control: Control;
|
||||
private _tileGrid: Tile[][] = Array.from({ length: grid.x }).map(() => Array.from({ length: grid.y }));
|
||||
private _allTiles: Tile[] = Array.from({ length: grid.x * grid.y });
|
||||
|
||||
constructor(canvas: HTMLCanvasElement | null) {
|
||||
super(canvas);
|
||||
this._control = new Control();
|
||||
}
|
||||
|
||||
protected async _start(canvas: HTMLCanvasElement): Promise<void> {
|
||||
await super._start(canvas);
|
||||
Registry.register('Control', this._control);
|
||||
|
||||
for (let x = 0; x < grid.x; x++) {
|
||||
for (let y = 0; y < grid.y; y++) {
|
||||
this._tileGrid[x][y] = new Tile({ x, y });
|
||||
this._allTiles[x + y * grid.x] = this._tileGrid[x][y];
|
||||
this._viewport.addChild(this._tileGrid[x][y]);
|
||||
}
|
||||
}
|
||||
|
||||
for (let x = 0; x < grid.x; x++) {
|
||||
for (let y = 0; y < grid.y; y++) {
|
||||
if (x > 0) {
|
||||
this._tileGrid[x][y].link(this._tileGrid[x - 1][y], TileDir.west);
|
||||
}
|
||||
if (x < grid.x - 1) {
|
||||
this._tileGrid[x][y].link(this._tileGrid[x + 1][y], TileDir.east);
|
||||
}
|
||||
|
||||
if (y > 0) {
|
||||
this._tileGrid[x][y].link(this._tileGrid[x][y - 1], TileDir.north);
|
||||
}
|
||||
if (y < grid.y - 1) {
|
||||
this._tileGrid[x][y].link(this._tileGrid[x][y + 1], TileDir.south);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let x = 0; x < grid.x; x++) {
|
||||
for (let y = 0; y < grid.y; y++) {
|
||||
this._tileGrid[x][y].rescore()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the logic of the game
|
||||
*
|
||||
* @param timeDelta - The time difference since the last update (in ms)
|
||||
*/
|
||||
protected async _update(timeDelta: number): Promise<void> {
|
||||
await super._update(timeDelta);
|
||||
|
||||
while (true) {
|
||||
|
||||
const activeTiles = this._allTiles.filter((tile) => !tile.selected && tile.score > 0);
|
||||
if (activeTiles.length === 0) {
|
||||
break;
|
||||
}
|
||||
const lowestScore = min(activeTiles, t => t.score)!.score;
|
||||
const lowestTiles = activeTiles.filter((tile) => tile.score === lowestScore);
|
||||
const selectedTile = shuffle(lowestTiles)[0];
|
||||
selectedTile.select();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default TestGameApp;
|
||||
@@ -1,24 +0,0 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import MapGenApp from './MapGenApp';
|
||||
|
||||
/**
|
||||
* The wrapper for Test Game so it can be dynamically loaded with the WebGL hook
|
||||
*/
|
||||
function TestGameWrapper(): React.ReactElement {
|
||||
const canvas = useRef<HTMLCanvasElement>(null);
|
||||
const gameCreated = useRef<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!gameCreated.current) {
|
||||
console.log('Creating game');
|
||||
gameCreated.current = true;
|
||||
new MapGenApp(canvas.current);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<canvas ref={canvas} />
|
||||
);
|
||||
}
|
||||
|
||||
export default TestGameWrapper;
|
||||
@@ -1,212 +0,0 @@
|
||||
import Container from "@/games/lib/visual/Container";
|
||||
import Graphics from "@/games/lib/visual/Graphics";
|
||||
import Text from "@/games/lib/visual/Text";
|
||||
|
||||
const TileTypes = {
|
||||
grass: "grass",
|
||||
dirt: "dirt",
|
||||
rock: "rock",
|
||||
iron: "iron",
|
||||
copper: "copper",
|
||||
};
|
||||
|
||||
export enum TileDir {
|
||||
north,
|
||||
east,
|
||||
south,
|
||||
west
|
||||
};
|
||||
|
||||
type TileData = {
|
||||
available: boolean,
|
||||
colour: number
|
||||
}
|
||||
|
||||
const tileData: { [key in keyof typeof TileTypes]: TileData } = {
|
||||
grass: {
|
||||
available: false,
|
||||
colour: 0x117c13
|
||||
},
|
||||
rock: {
|
||||
available: false,
|
||||
colour: 0xc6bfb8
|
||||
},
|
||||
dirt: {
|
||||
available: false,
|
||||
colour: 0x402905
|
||||
},
|
||||
copper: {
|
||||
available: false,
|
||||
colour: 0xb87333
|
||||
},
|
||||
iron: {
|
||||
available: false,
|
||||
colour: 0xa19d94
|
||||
},
|
||||
}
|
||||
|
||||
export default class Tile extends Container {
|
||||
private static readonly size = {
|
||||
x: 30,
|
||||
y: 30
|
||||
}
|
||||
private _graphic: Graphics = new Graphics();
|
||||
private _point: { x: number, y: number };
|
||||
private _text: Text = new Text({
|
||||
position: {
|
||||
x: Tile.size.x / 2,
|
||||
y: Tile.size.y / 2,
|
||||
}
|
||||
});
|
||||
private _choices = structuredClone(tileData);
|
||||
private _selected: null | keyof typeof tileData = null;
|
||||
public score = 0;
|
||||
public neighbors: Tile[] = [];
|
||||
|
||||
|
||||
constructor(point: { x: number, y: number }) {
|
||||
super({
|
||||
position: {
|
||||
x: point.x * Tile.size.x,
|
||||
y: point.y * Tile.size.y,
|
||||
}
|
||||
});
|
||||
|
||||
this._point = point;
|
||||
this.addChild(this._graphic, this._text);
|
||||
|
||||
this._graphic.rect(0, 0, Tile.size.x, Tile.size.y).fill({ color: 0xcccccc }).stroke();
|
||||
this._text.text = "-1";
|
||||
}
|
||||
|
||||
link(tile: Tile, dir: TileDir) {
|
||||
this.neighbors[dir] = tile;
|
||||
}
|
||||
|
||||
reset() {
|
||||
this._graphic.clear();
|
||||
this._graphic.rect(0, 0, Tile.size.x, Tile.size.y).fill({ color: 0xcccccc }).stroke();
|
||||
Object.values(this._choices).forEach((choice) => choice.available = false);
|
||||
this._text.text = "-1";
|
||||
this._point = {
|
||||
x: -1,
|
||||
y: -1
|
||||
}
|
||||
this._selected = null;
|
||||
}
|
||||
|
||||
_calculateAvailable() {
|
||||
if (!this.neighbors[TileDir.north]) {
|
||||
this._choices.grass.available = true;
|
||||
this._choices.dirt.available = true;
|
||||
return;
|
||||
}
|
||||
|
||||
this.neighbors.forEach((neighbor) => {
|
||||
if (neighbor.selected === 'grass') {
|
||||
this._choices.dirt.available = true;
|
||||
}
|
||||
|
||||
if (neighbor.selected === 'dirt') {
|
||||
this._choices.dirt.available = true;
|
||||
this._choices.rock.available = true;
|
||||
}
|
||||
|
||||
if (neighbor.selected === 'rock') {
|
||||
this._choices.rock.available = true;
|
||||
this._choices.iron.available = true;
|
||||
this._choices.copper.available = true;
|
||||
}
|
||||
|
||||
if (neighbor.selected === 'iron') {
|
||||
this._choices.rock.available = true;
|
||||
this._choices.iron.available = true;
|
||||
}
|
||||
|
||||
if (neighbor.selected === 'copper') {
|
||||
this._choices.rock.available = true;
|
||||
this._choices.copper.available = true;
|
||||
}
|
||||
})
|
||||
|
||||
if (this._point.y < 32) {
|
||||
this._choices.iron.available = false;
|
||||
}
|
||||
if (this._point.y < 64) {
|
||||
this._choices.copper.available = false;
|
||||
this._choices.dirt.available = true;
|
||||
this._choices.rock.available = true;
|
||||
}
|
||||
}
|
||||
|
||||
rescore(): number {
|
||||
if (this._selected) {
|
||||
return 0;
|
||||
}
|
||||
this._calculateAvailable();
|
||||
|
||||
this.score = Object.values(this._choices).reduce((count, choice) => {
|
||||
return choice.available ? count + 1 : count;
|
||||
}, 0);
|
||||
|
||||
this._text.text = this.score;
|
||||
return this.score;
|
||||
}
|
||||
|
||||
select(): void {
|
||||
|
||||
//const available = Object.entries(this._choices).filter((choice) => choice[1].available);
|
||||
//this._selected = shuffle(available)[0][0] as keyof typeof tileData;
|
||||
const available = Object.entries(this._choices).filter((choice) => choice[1].available) as [keyof typeof TileTypes, TileData][];
|
||||
|
||||
const weightedAvailable = available.map(([tileType, tileData]) => {
|
||||
const weightedEntry = {
|
||||
type: tileType,
|
||||
weight: 4
|
||||
};
|
||||
|
||||
if (tileType === 'grass') {
|
||||
weightedEntry.weight = 8;
|
||||
}
|
||||
else if (tileType === 'dirt') {
|
||||
if (this._point.y === 0) {
|
||||
weightedEntry.weight = 2;
|
||||
} else {
|
||||
weightedEntry.weight += 20 - Math.floor(((Math.min(this._point.y, 63) / 64) * 20) + 3);
|
||||
}
|
||||
}
|
||||
else if (tileType === 'iron') {
|
||||
weightedEntry.weight = 3;
|
||||
}
|
||||
else if (tileType === 'copper') {
|
||||
weightedEntry.weight = 1;
|
||||
}
|
||||
|
||||
return weightedEntry;
|
||||
});
|
||||
|
||||
const totalWeight = weightedAvailable.reduce((weight, avail) => weight + avail.weight, 0);
|
||||
let pick = Math.floor(Math.random() * totalWeight)
|
||||
|
||||
for (let i = 0; i < weightedAvailable.length; i++) {
|
||||
if (pick < weightedAvailable[i].weight) {
|
||||
this._selected = weightedAvailable[i].type;
|
||||
break;
|
||||
}
|
||||
pick -= weightedAvailable[i].weight;
|
||||
}
|
||||
if (this._selected === null) {
|
||||
throw new Error('Selection not done');
|
||||
}
|
||||
|
||||
this._graphic.clear();
|
||||
this._graphic.rect(0, 0, Tile.size.x, Tile.size.y).fill({ color: this._choices[this._selected].colour });
|
||||
this._text.text = "";
|
||||
|
||||
this.neighbors.forEach((neighbor) => neighbor.rescore());
|
||||
}
|
||||
|
||||
get selected() {
|
||||
return this._selected;
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import BaseGameApp from '@/core/BaseGameApp/BaseGameApp';
|
||||
import Registry from '@/utils/Registry';
|
||||
import Control from '@/control/Control';
|
||||
|
||||
import Player from './components/Player/Player';
|
||||
import Trees from './components/Trees';
|
||||
import Walls from './components/Walls/Walls';
|
||||
|
||||
/**
|
||||
* A test "game" to start generating useful examples, building up classes as needed etc
|
||||
*/
|
||||
class TestGameApp extends BaseGameApp {
|
||||
private _player!: Player;
|
||||
private _control: Control;
|
||||
|
||||
|
||||
constructor(canvas: HTMLCanvasElement | null) {
|
||||
super(canvas);
|
||||
this._control = new Control();
|
||||
}
|
||||
|
||||
protected async _start(canvas: HTMLCanvasElement): Promise<void> {
|
||||
await super._start(canvas);
|
||||
Registry.register('Control', this._control);
|
||||
this._viewport.addChild(new Trees({ numberOfTrees: 200 }));
|
||||
this._player = this._viewport.addChild(new Player());
|
||||
this._viewport.addChild(new Walls());
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the logic of the game
|
||||
*
|
||||
* @param timeDelta - The time difference since the last update (in ms)
|
||||
*/
|
||||
protected async _update(timeDelta: number): Promise<void> {
|
||||
this._player.update(timeDelta);
|
||||
await super._update(timeDelta);
|
||||
}
|
||||
}
|
||||
|
||||
export default TestGameApp;
|
||||
@@ -1,24 +0,0 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import TestGameApp from './TestGameApp';
|
||||
|
||||
/**
|
||||
* The wrapper for Test Game so it can be dynamically loaded with the WebGL hook
|
||||
*/
|
||||
function TestGameWrapper(): React.ReactElement {
|
||||
const canvas = useRef<HTMLCanvasElement>(null);
|
||||
const gameCreated = useRef<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!gameCreated.current) {
|
||||
console.log('Creating game');
|
||||
gameCreated.current = true;
|
||||
new TestGameApp(canvas.current);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<canvas ref={canvas} />
|
||||
);
|
||||
}
|
||||
|
||||
export default TestGameWrapper;
|
||||
@@ -1,132 +0,0 @@
|
||||
import Collidable from '@/control/Collidable';
|
||||
import Rapier from '@/utils/Rapier';
|
||||
import Registry from '@/utils/Registry';
|
||||
import Vector from '@/utils/Vector';
|
||||
import Container from '@/visual/Container';
|
||||
import Graphics from '@/visual/Graphics';
|
||||
|
||||
import BaseAttack from './attack/BaseAttack';
|
||||
import Forward from './attack/Forward';
|
||||
|
||||
import type { Viewport } from '@/visual/pixi';
|
||||
import type Control from '@/control/Control';
|
||||
import { assign } from 'radash';
|
||||
|
||||
type PlayerSettings = ConstructorParameters<typeof Collidable>[0]
|
||||
|
||||
|
||||
/**
|
||||
* - TODO
|
||||
*/
|
||||
class Player extends Collidable {
|
||||
private _control: Control;
|
||||
private _characterGraphics: Graphics;
|
||||
|
||||
private _attacks: BaseAttack[] = [];
|
||||
private _rapier: Rapier;
|
||||
private _collider: InstanceType<Rapier['rapier']['Collider']>;
|
||||
private _characterController: InstanceType<Rapier['rapier']['KinematicCharacterController']>;
|
||||
|
||||
constructor(settings?: PlayerSettings) {
|
||||
super(assign(structuredClone(settings ?? {}), {
|
||||
position: {
|
||||
x: 960,
|
||||
y: 540
|
||||
}
|
||||
}));
|
||||
const viewport = Registry.fetch<Viewport>('Viewport');
|
||||
this._rapier = Registry.fetch<Rapier>('Rapier');
|
||||
this._control = Registry.fetch<Control>('Control');
|
||||
|
||||
this._characterGraphics = viewport.addChild(new Graphics());
|
||||
this._characterGraphics.setStrokeStyle({
|
||||
color: 0x00DD00,
|
||||
width: 1
|
||||
}).regularPoly(-10, -10, 20, 3).fill(0x00AA00).stroke();
|
||||
this._characterGraphics.pivot.set(-10, -10);
|
||||
this.addChild(this._characterGraphics);
|
||||
|
||||
viewport.follow(this, { radius: 250 });
|
||||
viewport.addChild(this);
|
||||
|
||||
const projectileContainer = new Container();
|
||||
viewport.addChild(projectileContainer);
|
||||
const forwardAttack = new Forward(projectileContainer);
|
||||
this._attacks.push(forwardAttack);
|
||||
this.addChild(forwardAttack);
|
||||
|
||||
const trianglePoints = {
|
||||
a: new Vector(0, -20),
|
||||
b: new Vector(10 * Math.sqrt(3), 10),
|
||||
c: new Vector(-10 * Math.sqrt(3), 10)
|
||||
};
|
||||
this._collider = this._rapier.createCollider(this, 'triangle', trianglePoints);
|
||||
this._collider.setRestitution(0);
|
||||
this._collider.setTranslation(this.position);
|
||||
|
||||
this._rapier.addColliderMemberGroups(this._collider, 'player');
|
||||
this._rapier.addColliderFilterGroups(this._collider, 'enemy', 'enemyProjectile', 'wall');
|
||||
|
||||
this._characterController = this._rapier.world.createCharacterController(0.01);
|
||||
|
||||
}
|
||||
|
||||
update(timeDelta: number): void {
|
||||
let impulse = new Vector(0, 0);
|
||||
let speed = 250;
|
||||
|
||||
if (this._control.isDown('up')) {
|
||||
impulse.y -= 1;
|
||||
}
|
||||
if (this._control.isDown('down')) {
|
||||
impulse.y += 1;
|
||||
}
|
||||
if (this._control.isDown('left')) {
|
||||
impulse.x -= 1;
|
||||
}
|
||||
if (this._control.isDown('right')) {
|
||||
impulse.x += 1;
|
||||
}
|
||||
if (this._control.isDown('shift')) {
|
||||
speed *= 4;
|
||||
}
|
||||
|
||||
if (impulse.magnitude() > 0) {
|
||||
const normImpulse = impulse.normalize();
|
||||
this.rotation = Math.atan2(normImpulse.y, normImpulse.x) - Math.atan2(1, 0) + Math.PI;
|
||||
impulse = impulse.normalize().multiplyScalar(speed);
|
||||
}
|
||||
|
||||
this._characterController.computeColliderMovement(
|
||||
this._collider,
|
||||
impulse.multiplyScalar(timeDelta)
|
||||
);
|
||||
|
||||
const correctedMovement = this._characterController.computedMovement();
|
||||
this.position.add(correctedMovement);
|
||||
// console.log('impulse', impulse);
|
||||
// console.log('corrected', correctedMovement)
|
||||
//this._collider.setTranslation(this.position);
|
||||
|
||||
|
||||
if (this._control.isDown(' ')) {
|
||||
const modifiedRotation = this.rotation - (Math.PI / 2);
|
||||
const currentVector = new Vector(Math.cos(modifiedRotation), Math.sin(modifiedRotation));
|
||||
this._attacks[0].fire({
|
||||
playerVector: currentVector
|
||||
});
|
||||
}
|
||||
|
||||
this._attacks.forEach((attack) => {
|
||||
attack.update(timeDelta);
|
||||
});
|
||||
}
|
||||
|
||||
collide(collided: Collidable, colliding: boolean): void {
|
||||
if (!colliding) {
|
||||
console.log('player no longer colliding');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default Player;
|
||||
@@ -1,20 +0,0 @@
|
||||
import Container from '../../../../../lib/visual/Container';
|
||||
import type Vector from '../../../../../lib/utils/Vector';
|
||||
|
||||
type fireState = {
|
||||
playerVector: Vector
|
||||
}
|
||||
|
||||
abstract class BaseAttack extends Container {
|
||||
protected _projectileContainer: Container;
|
||||
|
||||
constructor(projectileContainer: Container) {
|
||||
super();
|
||||
this._projectileContainer = projectileContainer;
|
||||
}
|
||||
abstract fire(state: fireState): void;
|
||||
abstract update(timeDelta: number): void;
|
||||
}
|
||||
|
||||
|
||||
export default BaseAttack;
|
||||
@@ -1,159 +0,0 @@
|
||||
import Collidable from '@/control/Collidable';
|
||||
import Pool from '@/utils/Pool';
|
||||
import Registry from '@/utils/Registry';
|
||||
import Vector from '@/utils/Vector';
|
||||
import Graphics from '@/visual/Graphics';
|
||||
|
||||
import BaseAttack from './BaseAttack';
|
||||
|
||||
import type Rapier from '@/utils/Rapier';
|
||||
|
||||
/**
|
||||
* - TODO
|
||||
*/
|
||||
class ForwardProjectile extends Collidable {
|
||||
private _speed: number = 600;
|
||||
private _size: number = 5;
|
||||
private _graphic: Graphics;
|
||||
|
||||
private _lifeTime: {
|
||||
lifeTime: number,
|
||||
lifeTimeRemaining: number
|
||||
} = {
|
||||
lifeTime: 2500,
|
||||
lifeTimeRemaining: 0
|
||||
};
|
||||
private _collider!: InstanceType<Rapier['rapier']['Collider']>;
|
||||
private _rigidBody!: InstanceType<Rapier['rapier']['RigidBody']>;
|
||||
private _rapier: Rapier;
|
||||
private _alive: boolean = true;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._rapier = Registry.fetch<Rapier>('Rapier');
|
||||
this._graphic = new Graphics();
|
||||
this._graphic.circle(this._size / 2, this._size / 2, this._size).fill(0xAA0000);
|
||||
this.addChild(this._graphic);
|
||||
this.pivot.set(this._size / 2, this._size / 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* - TODO
|
||||
*/
|
||||
alive(): boolean {
|
||||
return this._lifeTime.lifeTimeRemaining > 0 && this._alive;
|
||||
}
|
||||
|
||||
/**
|
||||
* - TODO
|
||||
*/
|
||||
fire(vector: Vector): void {
|
||||
this._alive = true;
|
||||
this._addPhysics();
|
||||
this._rigidBody.setTranslation(this.position, true);
|
||||
this._rigidBody.setLinvel(vector.multiplyScalar(this._speed), true);
|
||||
this._lifeTime.lifeTimeRemaining = this._lifeTime.lifeTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* - TODO
|
||||
*
|
||||
* @param timeDelta - TODO
|
||||
*/
|
||||
update(timeDelta: number): void {
|
||||
this.position.copyFrom(this._rigidBody.translation());
|
||||
|
||||
if (this._lifeTime.lifeTimeRemaining > 0) {
|
||||
this._lifeTime.lifeTimeRemaining = Math.max(0, this._lifeTime.lifeTimeRemaining - timeDelta);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets called when we've collided with something.
|
||||
* Use to kill the projectile.
|
||||
*
|
||||
* @param collided - The Collidable this projectile has hit
|
||||
* @param colliding - Are we currently colliding?
|
||||
*/
|
||||
collide(collided: Collidable, colliding: boolean): void {
|
||||
if (colliding) {
|
||||
this._alive = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* - TODO
|
||||
*/
|
||||
private _addPhysics(): void {
|
||||
this._rigidBody = this._rapier.createRigidBody(this, 'Dynamic', false, true);
|
||||
this._collider = this._rapier.createCollider(this, 'ball', { radius: this._size }, this._rigidBody);
|
||||
this._collider.setRestitution(0);
|
||||
this._rapier.addColliderMemberGroups(this._collider, 'playerProjectile');
|
||||
this._rapier.addColliderFilterGroups(this._collider, 'enemy', 'wall');
|
||||
}
|
||||
|
||||
/**
|
||||
* - TODO
|
||||
*/
|
||||
removePhysics(): void {
|
||||
this._rapier.removeRigidBody(this._rigidBody);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* - TODO
|
||||
*/
|
||||
class Forward extends BaseAttack {
|
||||
private _projectilePool: Pool<ForwardProjectile> = new Pool(ForwardProjectile, 50);
|
||||
private _activeProjectiles: ForwardProjectile[] = [];
|
||||
private _coolDown: {
|
||||
coolDown: number,
|
||||
coolDownRemaining: number
|
||||
} = {
|
||||
coolDown: 250,
|
||||
coolDownRemaining: 0
|
||||
};
|
||||
|
||||
/**
|
||||
* - TODO
|
||||
*/
|
||||
fire(state: Parameters<BaseAttack['fire']>[0]): void {
|
||||
if (this._coolDown.coolDownRemaining > 0) {
|
||||
return;
|
||||
}
|
||||
const newBullet = this._projectilePool.allocate();
|
||||
|
||||
const newPoint = this._projectileContainer.toLocal(new Vector(0, 0), this);
|
||||
newBullet.position.copyFrom(newPoint);
|
||||
this._projectileContainer.addChild(newBullet);
|
||||
this._activeProjectiles.push(newBullet);
|
||||
|
||||
newBullet.fire(state.playerVector.clone());
|
||||
|
||||
this._coolDown.coolDownRemaining = this._coolDown.coolDown;
|
||||
}
|
||||
|
||||
/**
|
||||
* - TODO
|
||||
*/
|
||||
update(timeDelta: number): void {
|
||||
if (this._coolDown.coolDownRemaining > 0) {
|
||||
this._coolDown.coolDownRemaining = Math.max(0, this._coolDown.coolDownRemaining - timeDelta);
|
||||
}
|
||||
|
||||
this._activeProjectiles = this._activeProjectiles.filter((projectile) => {
|
||||
projectile.update(timeDelta);
|
||||
const alive = projectile.alive();
|
||||
if (!alive) {
|
||||
projectile.removePhysics();
|
||||
this._projectileContainer.removeChild(projectile);
|
||||
this._projectilePool.release(projectile);
|
||||
}
|
||||
return alive;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default Forward;
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import Pool from '@/utils/Pool';
|
||||
import Container from '@/visual/Container';
|
||||
import Graphics from '@/visual/Graphics';
|
||||
import { PIXI } from '@/visual/pixi';
|
||||
|
||||
type TreesSettings = ConstructorParameters<typeof Container>[0] & {
|
||||
numberOfTrees: number
|
||||
}
|
||||
|
||||
class Trees extends Container {
|
||||
private readonly _allTrees: Graphics[] = new Array<Graphics>(200);
|
||||
private readonly _treePool: Pool<Graphics>;
|
||||
constructor(settings: TreesSettings) {
|
||||
super(settings);
|
||||
this._treePool = new Pool(Graphics, settings.numberOfTrees);
|
||||
|
||||
for (let i = 0; i < settings.numberOfTrees; i++) {
|
||||
const tree = this.addChild(this._treePool.allocate());
|
||||
const treeColour = ((Math.random() * 80) + 70);
|
||||
tree.regularPoly(-10, 10, 20, 3).fill({ color: new PIXI.Color([0, (treeColour - 10) / 255, 0]) });
|
||||
tree.regularPoly(-10, 0, 20, 3).fill({ color: new PIXI.Color([0, treeColour / 255, 0]) });
|
||||
tree.regularPoly(-10, -10, 20, 3).fill({ color: new PIXI.Color([0, (treeColour + 10) / 255, 0]) });
|
||||
tree.position.set(Math.random() * 1920, Math.random() * 1080);
|
||||
tree.cullable = true;
|
||||
this._allTrees.push(tree);
|
||||
}
|
||||
this.cullable = true;
|
||||
}
|
||||
}
|
||||
|
||||
export default Trees;
|
||||
@@ -1,76 +0,0 @@
|
||||
import Collidable from '@/control/Collidable';
|
||||
import Registry from '@/utils/Registry';
|
||||
import Container from '@/visual/Container';
|
||||
import Graphics from '@/visual/Graphics';
|
||||
import { PIXI } from '@/visual/pixi';
|
||||
|
||||
import type Rapier from '@/utils/Rapier';
|
||||
|
||||
type WallsSettings = ConstructorParameters<typeof Container>[0] & {
|
||||
numberOfTrees: number
|
||||
}
|
||||
|
||||
type wallData = {
|
||||
graphic: Graphics,
|
||||
collider: InstanceType<Rapier['rapier']['Collider']>,
|
||||
rigidBody: InstanceType<Rapier['rapier']['RigidBody']>,
|
||||
}
|
||||
|
||||
/**
|
||||
* - TODO
|
||||
*/
|
||||
class Walls extends Collidable {
|
||||
private _rapier: Rapier;
|
||||
private _walls: wallData[] = [];
|
||||
|
||||
constructor(settings?: WallsSettings) {
|
||||
super(settings);
|
||||
this._rapier = Registry.fetch<Rapier>('Rapier');
|
||||
this._walls.push(this._createWall(10, 1110, -10, 540));
|
||||
this._walls.push(this._createWall(10, 1110, 1930, 540));
|
||||
this._walls.push(this._createWall(1950, 10, 960, -10));
|
||||
this._walls.push(this._createWall(1950, 10, 960, 1090));
|
||||
|
||||
this._walls.forEach((wall) => {
|
||||
this.addChild(wall.graphic);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* - TODO
|
||||
*
|
||||
* @param width - TODO
|
||||
* @param height
|
||||
* @param x
|
||||
* @param y
|
||||
*/
|
||||
private _createWall(width: number, height: number, x: number, y: number): wallData {
|
||||
const wall = new Graphics();
|
||||
wall.rect(-width / 2, -height / 2, width, height).fill({ color: new PIXI.Color([0, 0, 0]) });
|
||||
wall.position.set(x, y);
|
||||
|
||||
|
||||
const rigidBody = this._rapier.createRigidBody(this, 'Fixed');
|
||||
const collider = this._rapier.createCollider(this, 'cuboid', { halfHeight: height / 2, halfWidth: width / 2 }, rigidBody);
|
||||
collider.setRestitution(0);
|
||||
|
||||
rigidBody.setTranslation(this.position.add({ x, y }), true);
|
||||
|
||||
this._rapier.addColliderMemberGroups(collider, 'wall');
|
||||
this._rapier.addColliderFilterGroups(collider, 'player', 'enemy', 'enemyProjectile', 'playerProjectile');
|
||||
|
||||
return {
|
||||
graphic: wall,
|
||||
collider,
|
||||
rigidBody
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* - TODO
|
||||
*/
|
||||
collide(collided: Collidable, colliding: boolean): void {
|
||||
}
|
||||
}
|
||||
|
||||
export default Walls;
|
||||
@@ -1,7 +0,0 @@
|
||||
import Container from '@/visual/Container';
|
||||
|
||||
abstract class Collidable extends Container {
|
||||
abstract collide(collided: Collidable, colliding: boolean): void;
|
||||
}
|
||||
|
||||
export default Collidable;
|
||||
@@ -1,87 +0,0 @@
|
||||
type state = 'down' | 'up';
|
||||
|
||||
// - TODO - Pull this from the actual game creating the controls
|
||||
const GameControls = {
|
||||
down: 'down',
|
||||
up: 'up',
|
||||
left: 'left',
|
||||
right: 'right',
|
||||
shift: 'shift',
|
||||
' ': ' ',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Abstraction for game controls, the game should say what controls it expects and then we can remap the actual controls separately.
|
||||
* i.e. Both "ArrowDown" and "Down" from the keyboard listener should be the games "down" event.
|
||||
* Also handles when the keys are pressed or not.
|
||||
*/
|
||||
class Control {
|
||||
readonly gameControls = GameControls;
|
||||
private _controlMap: { [key: string]: keyof typeof GameControls } = {
|
||||
ArrowDown: GameControls.down,
|
||||
Down: GameControls.down,
|
||||
ArrowUp: GameControls.up,
|
||||
Up: GameControls.up,
|
||||
ArrowLeft: GameControls.left,
|
||||
Left: GameControls.left,
|
||||
ArrowRight: GameControls.right,
|
||||
Right: GameControls.right,
|
||||
Shift: GameControls.shift,
|
||||
' ': GameControls[' '],
|
||||
};
|
||||
|
||||
private _controlState: Map<keyof typeof GameControls, {
|
||||
state: state,
|
||||
|
||||
consumed: boolean
|
||||
}> = new Map();
|
||||
|
||||
constructor() {
|
||||
this._setKeys();
|
||||
window.addEventListener('keydown', (event) => {
|
||||
if (this._controlState.get(this._controlMap[event.key])?.state !== 'down') {
|
||||
this._controlState.set(this._controlMap[event.key], { state: 'down', consumed: false });
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('keyup', (event) => {
|
||||
if (this._controlState.get(this._controlMap[event.key])?.state !== 'up') {
|
||||
this._controlState.set(this._controlMap[event.key], { state: 'up', consumed: false });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* - TODO
|
||||
*/
|
||||
private _setKeys(): void {
|
||||
Object.keys(GameControls).forEach((key) => {
|
||||
this._controlState.set(key as keyof typeof GameControls, { state: 'up', consumed: false });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* - TODO
|
||||
*
|
||||
* @param key - TODO
|
||||
*/
|
||||
isDown(key: keyof typeof GameControls): boolean {
|
||||
return this._controlState.get(key)?.state === 'down';
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume a down key event once
|
||||
*
|
||||
* @param key - TODO
|
||||
*/
|
||||
onceDown(key: keyof typeof GameControls): boolean {
|
||||
const keyState = this._controlState.get(key);
|
||||
if (keyState && keyState.state === 'down' && !keyState.consumed) {
|
||||
keyState.consumed = true;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export default Control;
|
||||
@@ -1,96 +0,0 @@
|
||||
import { PIXI, Viewport } from '@/visual/pixi';
|
||||
import Text from '@/visual/Text';
|
||||
import Rapier from '@/utils/Rapier';
|
||||
import Registry from '@/utils/Registry';
|
||||
import Ticker from '@/utils/Ticker';
|
||||
import Vector from '@/utils/Vector';
|
||||
import config from './BaseGameAppConfig';
|
||||
|
||||
|
||||
/**
|
||||
* - TODO
|
||||
*/
|
||||
class BaseGameApp {
|
||||
protected readonly _app: PIXI.Application;
|
||||
protected readonly _ticker: Ticker = new Ticker();
|
||||
protected _fps: Text = new Text(config.fps);
|
||||
protected _viewport!: Viewport;
|
||||
protected _rapier!: Rapier;
|
||||
|
||||
constructor(canvas: HTMLCanvasElement | null) {
|
||||
if (canvas === null) {
|
||||
throw new Error('No canvas');
|
||||
}
|
||||
|
||||
this._app = new PIXI.Application();
|
||||
|
||||
void this._start(canvas);
|
||||
}
|
||||
|
||||
/**
|
||||
* - TODO
|
||||
*/
|
||||
protected async _start(canvas: HTMLCanvasElement): Promise<void> {
|
||||
await this._app.init({
|
||||
backgroundColor: 0x999999,
|
||||
canvas: canvas,
|
||||
antialias: true,
|
||||
resizeTo: canvas.parentElement as HTMLElement
|
||||
});
|
||||
|
||||
this._app.ticker.autoStart = false;
|
||||
this._app.ticker.stop();
|
||||
|
||||
this._viewport = new Viewport({
|
||||
screenWidth: this._app.canvas.width,
|
||||
screenHeight: this._app.canvas.height,
|
||||
worldWidth: 1920,
|
||||
worldHeight: 1080,
|
||||
passiveWheel: false,
|
||||
noTicker: true,
|
||||
events: this._app.renderer.events
|
||||
});
|
||||
Registry.register('Viewport', this._viewport);
|
||||
this._rapier = new Rapier(new Vector(0, 0));
|
||||
Registry.register('Rapier', this._rapier);
|
||||
|
||||
this._viewport.ensureVisible(0, 0, 1920, 1080, true);
|
||||
this._viewport.fitWorld(true);
|
||||
this._viewport.drag();
|
||||
|
||||
this._app.stage.addChild(this._viewport);
|
||||
this._app.stage.addChild(this._fps);
|
||||
|
||||
await this._rapier.setup();
|
||||
|
||||
this._ticker.ticker(1000 / 60, this._update.bind(this), this._updateComplete.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the logic of the game
|
||||
* - TODO - Figure out if this should be called before or after game updates
|
||||
*
|
||||
* @param timeDelta - The time difference since the last update (in ms)
|
||||
*/
|
||||
protected async _update(timeDelta: number): Promise<void> {
|
||||
this._rapier.debugRender();
|
||||
this._viewport.update(timeDelta);
|
||||
this._rapier.update();
|
||||
this._fps.text = this._ticker.currentTicksPerSecond;
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
/**
|
||||
* When the update is complete, kick off a render
|
||||
*
|
||||
* @param updated - Was there actually an update in the last raf?
|
||||
*/
|
||||
protected async _updateComplete(updated: boolean): Promise<void> {
|
||||
this._viewport.resize(this._app.canvas.width, this._app.canvas.height);
|
||||
this._viewport.fitWorld(true);
|
||||
this._app.renderer.render(this._app.stage);
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
export default BaseGameApp;
|
||||
@@ -1,10 +0,0 @@
|
||||
const BaseGameAppConfig = {
|
||||
fps: {
|
||||
position: {
|
||||
x: 20,
|
||||
y: 20
|
||||
}
|
||||
}
|
||||
} as const;
|
||||
|
||||
export default BaseGameAppConfig;
|
||||
@@ -1,31 +0,0 @@
|
||||
import { ObjectPoolFactory } from '@pixi-essentials/object-pool';
|
||||
|
||||
/**
|
||||
* - TODO
|
||||
*/
|
||||
class Pool<T> {
|
||||
private _pool: ObjectPoolFactory<T>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
constructor(ctor: new (...args: any[]) => T, reserve: number) {
|
||||
// Unsure why this needs the cast, probably because of the generic
|
||||
this._pool = ObjectPoolFactory.build(ctor) as ObjectPoolFactory<T>;
|
||||
this._pool.reserve(reserve);
|
||||
this._pool.startGC();
|
||||
}
|
||||
|
||||
/**
|
||||
* - TODO
|
||||
*/
|
||||
allocate(): T {
|
||||
return this._pool.allocate();
|
||||
}
|
||||
|
||||
/**
|
||||
* - TODO
|
||||
*/
|
||||
release(object: T): void {
|
||||
this._pool.release(object);
|
||||
}
|
||||
}
|
||||
|
||||
export default Pool;
|
||||
@@ -1,223 +0,0 @@
|
||||
import StoredPromise from './StoredPromise';
|
||||
import Vector from './Vector';
|
||||
import Collidable from '../control/Collidable';
|
||||
import _rapier from '@dimforge/rapier2d-compat';
|
||||
import Graphics from '@/visual/Graphics';
|
||||
import Registry from '@/utils/Registry';
|
||||
import { PIXI, type Viewport } from '@/visual/pixi';
|
||||
|
||||
/**
|
||||
* - TODO
|
||||
* https://rapier.rs/docs/user_guides/javascript/colliders#overview
|
||||
*/
|
||||
type colliderTypes = {
|
||||
ball: {
|
||||
radius: number
|
||||
},
|
||||
cuboid: {
|
||||
halfHeight: number,
|
||||
halfWidth: number
|
||||
},
|
||||
capsule: {
|
||||
halfHeight: number,
|
||||
radius: number
|
||||
},
|
||||
triangle: {
|
||||
a: Vector,
|
||||
b: Vector,
|
||||
c: Vector
|
||||
}
|
||||
}
|
||||
|
||||
const colliderGroups = {
|
||||
'wall': 0b0000_0000_0000_0001,
|
||||
'player': 0b0000_0000_0001_0000,
|
||||
'enemy': 0b0000_0000_0010_0000,
|
||||
'playerProjectile': 0b0000_0001_0000_0000,
|
||||
'enemyProjectile': 0b0000_0010_0000_0000,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* - TODO
|
||||
*/
|
||||
class Rapier {
|
||||
private _settingUp!: StoredPromise<void>;
|
||||
private _world!: _rapier.World;
|
||||
private _eventQueue!: _rapier.EventQueue;
|
||||
private _rigidBodyMap: Map<number, Collidable> = new Map();
|
||||
private _colliderMap: Map<number, Collidable> = new Map();
|
||||
private _debugLines: Graphics;
|
||||
|
||||
constructor(gravity: Vector) {
|
||||
void this._setup(gravity);
|
||||
this._debugLines = new Graphics();
|
||||
}
|
||||
|
||||
get rapier(): typeof _rapier {
|
||||
return _rapier;
|
||||
}
|
||||
|
||||
get world(): _rapier.World {
|
||||
return this._world;
|
||||
}
|
||||
|
||||
/**
|
||||
* Needed as rapier2d is a webasm module that needs to be async imported directly
|
||||
*/
|
||||
private async _setup(gravity: Vector): Promise<void> {
|
||||
this._settingUp = new StoredPromise();
|
||||
await this.rapier.init()
|
||||
// - TODO - Change integration params dt to ticker speed
|
||||
this._world = new this.rapier.World(gravity);
|
||||
this._eventQueue = new this.rapier.EventQueue(true);
|
||||
|
||||
const viewport = Registry.fetch<Viewport>('Viewport');
|
||||
viewport.addChild(this._debugLines);
|
||||
|
||||
this._settingUp.resolve();
|
||||
}
|
||||
|
||||
/**
|
||||
* - TODO
|
||||
*/
|
||||
async setup(): Promise<void> {
|
||||
await this._settingUp.promise;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* - TODO
|
||||
*/
|
||||
createRigidBody(container: Collidable, type: keyof typeof _rapier.RigidBodyType, sleep: boolean = true, CCD: boolean = false): _rapier.RigidBody {
|
||||
const rigidBodyDesc = new this.rapier.RigidBodyDesc(this.rapier.RigidBodyType[type])
|
||||
.setCanSleep(sleep)
|
||||
.setCcdEnabled(CCD);
|
||||
|
||||
const rigidBody = this._world.createRigidBody(rigidBodyDesc);
|
||||
this._rigidBodyMap.set(rigidBody.handle, container);
|
||||
return rigidBody;
|
||||
}
|
||||
|
||||
/**
|
||||
* - TODO
|
||||
*/
|
||||
createCollider<T extends keyof colliderTypes, U extends colliderTypes[T]>(container: Collidable, type: T, settings: U, rigidBody?: _rapier.RigidBody): _rapier.Collider {
|
||||
let colliderDesc: _rapier.ColliderDesc;
|
||||
if (type === 'ball') {
|
||||
const castSettings = (settings as colliderTypes['ball']);
|
||||
colliderDesc = new this.rapier.ColliderDesc(new this.rapier.Ball(castSettings.radius));
|
||||
} else if (type === 'capsule') {
|
||||
const castSettings = (settings as colliderTypes['capsule']);
|
||||
colliderDesc = new this.rapier.ColliderDesc(new this.rapier.Capsule(castSettings.halfHeight, castSettings.radius));
|
||||
} else if (type === 'cuboid') {
|
||||
const castSettings = (settings as colliderTypes['cuboid']);
|
||||
colliderDesc = new this.rapier.ColliderDesc(new this.rapier.Cuboid(castSettings.halfWidth, castSettings.halfHeight));
|
||||
} else if (type === 'triangle') {
|
||||
const castSettings = (settings as colliderTypes['triangle']);
|
||||
colliderDesc = new this.rapier.ColliderDesc(new this.rapier.Triangle(castSettings.a, castSettings.b, castSettings.c));
|
||||
} else {
|
||||
throw new Error(`Cannot create collider of type ${type}`);
|
||||
}
|
||||
colliderDesc.setActiveEvents(this.rapier.ActiveEvents.COLLISION_EVENTS);
|
||||
colliderDesc.setActiveCollisionTypes(this.rapier.ActiveCollisionTypes.DEFAULT);
|
||||
|
||||
const collider = this._world.createCollider(colliderDesc, rigidBody);
|
||||
collider.setCollisionGroups(0);
|
||||
collider.setFriction(0);
|
||||
|
||||
this._colliderMap.set(collider.handle, container);
|
||||
|
||||
return collider;
|
||||
}
|
||||
|
||||
/**
|
||||
* - TODO
|
||||
*/
|
||||
removeRigidBody(rigidBody: _rapier.RigidBody): void {
|
||||
if (rigidBody) {
|
||||
this._world.removeRigidBody(rigidBody);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* - TODO
|
||||
*/
|
||||
removeCollider(collider: _rapier.Collider): void {
|
||||
if (collider) {
|
||||
this._world.removeCollider(collider, true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the collider to member groups.
|
||||
* A member group is what groups we're in (i.e. so other groups can collide with us).
|
||||
*
|
||||
* @param collider - The collider to add the groups to
|
||||
* @param groups - The groups to add to
|
||||
*/
|
||||
addColliderMemberGroups(collider: _rapier.Collider, ...groups: (keyof typeof colliderGroups)[]): void {
|
||||
const bitmask = this._getColliderGroupsBitmask(groups) << 16;
|
||||
collider.setCollisionGroups(collider.collisionGroups() | bitmask);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the collider to filter groups.
|
||||
* A filter group is what groups we can collide with.
|
||||
*
|
||||
* @param collider - The collider to add the groups to
|
||||
* @param groups - The groups to add to
|
||||
*/
|
||||
addColliderFilterGroups(collider: _rapier.Collider, ...groups: (keyof typeof colliderGroups)[]): void {
|
||||
const bitmask = this._getColliderGroupsBitmask(groups);
|
||||
collider.setCollisionGroups(collider.collisionGroups() | bitmask);
|
||||
}
|
||||
|
||||
/**
|
||||
* - TODO
|
||||
*/
|
||||
private _getColliderGroupsBitmask(groups: (keyof typeof colliderGroups)[]): number {
|
||||
return groups.reduce((bitmask, group) => {
|
||||
return bitmask | colliderGroups[group];
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* - TODO
|
||||
*/
|
||||
update(): void {
|
||||
this._world.step(this._eventQueue);
|
||||
this._eventQueue.drainCollisionEvents((handle1, handle2, colliding) => {
|
||||
const container1 = this._colliderMap.get(handle1);
|
||||
const container2 = this._colliderMap.get(handle2);
|
||||
if (container1 && container2) {
|
||||
container1.collide(container2, colliding);
|
||||
container2.collide(container1, colliding);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
debugRender(): void {
|
||||
const { vertices, colors } = this._world.debugRender();
|
||||
|
||||
this._debugLines.clear();
|
||||
|
||||
for (let i = 0; i < vertices.length / 4; i += 1) {
|
||||
let color = new PIXI.Color([
|
||||
colors[i * 8],
|
||||
colors[i * 8 + 1],
|
||||
colors[i * 8 + 2],
|
||||
]);
|
||||
this._debugLines.setStrokeStyle({
|
||||
color: color,
|
||||
width: 2,
|
||||
alignment: 0.5,
|
||||
})
|
||||
this._debugLines.moveTo(vertices[i * 4], vertices[i * 4 + 1]);
|
||||
this._debugLines.lineTo(vertices[i * 4 + 2], vertices[i * 4 + 3]);
|
||||
this._debugLines.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default Rapier;
|
||||
@@ -1,32 +0,0 @@
|
||||
/**
|
||||
* - TODO
|
||||
*/
|
||||
class Registry {
|
||||
static _objects: Map<string, unknown> = new Map();
|
||||
|
||||
/**
|
||||
* - TODO
|
||||
*
|
||||
* @param name - TODO
|
||||
* @param obj - TODO
|
||||
*/
|
||||
static register<T>(name: string, obj: T): void {
|
||||
this._objects.set(name, obj);
|
||||
}
|
||||
|
||||
/**
|
||||
* - TODO
|
||||
*
|
||||
* @param name - TODO
|
||||
*/
|
||||
static fetch<T>(name: string): T {
|
||||
const obj = this._objects.get(name) as T;
|
||||
if (obj) {
|
||||
return obj;
|
||||
} else {
|
||||
throw new Error(`Cannot find ${name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default Registry;
|
||||
@@ -1,31 +0,0 @@
|
||||
class StoredPromise<T = void> {
|
||||
promise: null | Promise<T>;
|
||||
private _resolve: null | (( value: T | PromiseLike<T> ) => void);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
private _reject: null | (( reason?: any ) => void);
|
||||
constructor() {
|
||||
this.promise = new Promise<T>((resolve, reject) => {
|
||||
this._resolve = resolve;
|
||||
this._reject = reject;
|
||||
});
|
||||
}
|
||||
|
||||
resolve(value: T | PromiseLike<T>): void {
|
||||
this._resolve?.(value);
|
||||
this.clear();
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
reject( reason?: any ): void {
|
||||
this._reject?.(reason);
|
||||
this.clear();
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.promise = null;
|
||||
this._resolve = null;
|
||||
this._reject = null;
|
||||
}
|
||||
}
|
||||
|
||||
export default StoredPromise;
|
||||
@@ -1,75 +0,0 @@
|
||||
/**
|
||||
* A ticker class to call a callback on a set time\
|
||||
* Is catch-up tick based so will call update multiple times
|
||||
*/
|
||||
class Ticker {
|
||||
private _tickUpdateCb: (timeDelta: number) => Promise<void> = async () => {};
|
||||
private _tickCompleteCb: (updated: boolean) => Promise<void> = async () => {};
|
||||
private _voidUpdate: ( frameTime: number ) => void;
|
||||
private _tickCounter: number[] = [];
|
||||
private _tickTime = 0;
|
||||
private _lastFrameTime = 0;
|
||||
private _currentTotalDelta = 0;
|
||||
private _ticksPerSecond = 0;
|
||||
|
||||
constructor() {
|
||||
this._voidUpdate = (frameTime: number): void => {
|
||||
void this._update(frameTime);
|
||||
};
|
||||
}
|
||||
|
||||
get currentTicksPerSecond(): number {
|
||||
return this._ticksPerSecond;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the ticker to update every tickTime\
|
||||
* Will use requestAnimationFrame and catch up if needed\
|
||||
* Use the tick callback for logic updates and the complete callback for visual representations of that logic
|
||||
*/
|
||||
ticker(tickTime: number, tickCb: (timeDelta: number) => Promise<void>, completeCb: (updated: boolean) => Promise<void>): void {
|
||||
this._tickTime = tickTime;
|
||||
this._lastFrameTime = performance.now();
|
||||
this._tickUpdateCb = tickCb;
|
||||
this._tickCompleteCb = completeCb;
|
||||
requestAnimationFrame(this._voidUpdate);
|
||||
}
|
||||
|
||||
/**
|
||||
* The update loop on raf, call the update multiple times to catch up, then call raf again
|
||||
*/
|
||||
private async _update(currentFrameTime: number): Promise<void> {
|
||||
const delta = currentFrameTime - this._lastFrameTime;
|
||||
this._lastFrameTime = currentFrameTime;
|
||||
this._currentTotalDelta += delta;
|
||||
|
||||
let updated = false;
|
||||
/**
|
||||
* - TODO - This min number probably needs to change, and reset the time differently
|
||||
* This way will reset on the next frame, do we want to allow a min frame for update/render purposes but run all the ticks?
|
||||
* Probably depends on the game.
|
||||
*/
|
||||
let ticks = Math.min(Math.floor(this._currentTotalDelta / this._tickTime), 100);
|
||||
this._currentTotalDelta = this._currentTotalDelta % this._tickTime;
|
||||
while (ticks > 0) {
|
||||
updated = true;
|
||||
ticks -= 1;
|
||||
await this._tickUpdateCb(this._tickTime);
|
||||
}
|
||||
|
||||
if (updated) {
|
||||
this._tickCounter.push(currentFrameTime);
|
||||
const minusOneSecond = currentFrameTime - 1000;
|
||||
while (this._tickCounter[0] < minusOneSecond) {
|
||||
this._tickCounter.shift();
|
||||
}
|
||||
|
||||
this._ticksPerSecond = this._tickCounter.length;
|
||||
}
|
||||
await this._tickCompleteCb(updated);
|
||||
|
||||
requestAnimationFrame(this._voidUpdate);
|
||||
}
|
||||
}
|
||||
|
||||
export default Ticker;
|
||||
@@ -1,7 +0,0 @@
|
||||
import { PIXI } from '@/visual/pixi';
|
||||
|
||||
class Vector extends PIXI.Point {
|
||||
|
||||
}
|
||||
|
||||
export default Vector;
|
||||
9
src/games/lib/utils/pixi-object-pool.d.ts
vendored
9
src/games/lib/utils/pixi-object-pool.d.ts
vendored
@@ -1,9 +0,0 @@
|
||||
declare module '@pixi-essentials/object-pool' {
|
||||
class ObjectPoolFactory<T> {
|
||||
static build: (ctor: new (args: unknown[]) => T) => ObjectPoolFactory<T>;
|
||||
reserve: (reserve: number) => void;
|
||||
startGC: () => void;
|
||||
allocate: () => T;
|
||||
release: (obj: T) => void;
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import Vector from '@/utils/Vector';
|
||||
import { PIXI } from './pixi';
|
||||
import { VisualBase, VisualBaseSettings } from './VisualBase';
|
||||
|
||||
type ContainerSettings = VisualBaseSettings;
|
||||
|
||||
class Container extends PIXI.Container {
|
||||
constructor(settings?: ContainerSettings) {
|
||||
super();
|
||||
VisualBase.applySettings(this, settings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch this container's parent to a new parent, keeping it's positioning
|
||||
*/
|
||||
addToNewParent(newParent: Container): void {
|
||||
const localPoint = newParent.toLocal(new Vector(0, 0), this);
|
||||
this.position.set(localPoint.x, localPoint.y);
|
||||
newParent.addChild(this);
|
||||
}
|
||||
}
|
||||
|
||||
export default Container;
|
||||
@@ -1,13 +0,0 @@
|
||||
import { PIXI } from './pixi';
|
||||
import { VisualBase, VisualBaseSettings } from './VisualBase';
|
||||
|
||||
type GraphicsSettings = VisualBaseSettings;
|
||||
|
||||
class Graphics extends PIXI.Graphics {
|
||||
constructor(settings?: GraphicsSettings ) {
|
||||
super();
|
||||
VisualBase.applySettings(this, settings);
|
||||
}
|
||||
}
|
||||
|
||||
export default Graphics;
|
||||
@@ -1,29 +0,0 @@
|
||||
import { assign } from 'radash';
|
||||
import { PIXI } from './pixi';
|
||||
import { VisualBase, VisualBaseSettings } from './VisualBase';
|
||||
|
||||
type TextSettings = VisualBaseSettings & {
|
||||
text?: string | number,
|
||||
style?: string
|
||||
}
|
||||
|
||||
class Text extends PIXI.Text {
|
||||
static styles: { [key: string]: (Partial<PIXI.TextStyle> | PIXI.TextStyle) } = {
|
||||
default: {
|
||||
fontSize: 16,
|
||||
fill: 0x000000,
|
||||
align: 'center',
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
constructor(settings?: TextSettings, style?: Partial<PIXI.TextStyle> | PIXI.TextStyle) {
|
||||
super({
|
||||
text: settings?.text,
|
||||
style: assign(Text.styles[settings?.style ?? 'default'], style ?? {})
|
||||
})
|
||||
VisualBase.applySettings(this, settings);
|
||||
}
|
||||
}
|
||||
|
||||
export default Text;
|
||||
@@ -1,93 +0,0 @@
|
||||
import { PIXI } from './pixi';
|
||||
|
||||
type pointOrNumber = {
|
||||
x: number,
|
||||
y: number
|
||||
} | number | undefined | PIXI.Point;
|
||||
|
||||
type VisualBaseSettings = {
|
||||
angle?: number,
|
||||
pivot?: pointOrNumber
|
||||
position?: {
|
||||
x: number,
|
||||
y: number
|
||||
},
|
||||
rotation?: number,
|
||||
scale?: pointOrNumber
|
||||
skew?: pointOrNumber,
|
||||
width?: number,
|
||||
height?: number,
|
||||
alpha?: number,
|
||||
visible?: boolean,
|
||||
interactive?: boolean,
|
||||
zIndex?: number
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* - TODO
|
||||
*/
|
||||
class VisualBase {
|
||||
static applySettings<T extends PIXI.Container>(applyTo: T, settings?: VisualBaseSettings): void {
|
||||
if (settings?.angle) {
|
||||
applyTo.angle = settings.angle;
|
||||
}
|
||||
|
||||
if (settings?.position) {
|
||||
applyTo?.position.set(settings.position.x, settings.position.y);
|
||||
}
|
||||
|
||||
if (settings?.rotation) {
|
||||
applyTo.rotation = settings.rotation;
|
||||
}
|
||||
|
||||
if (settings?.interactive) {
|
||||
applyTo.interactive = settings.interactive;
|
||||
}
|
||||
|
||||
if (settings?.zIndex) {
|
||||
applyTo.zIndex = settings.zIndex;
|
||||
}
|
||||
|
||||
if (settings?.alpha) {
|
||||
applyTo.alpha = settings.alpha;
|
||||
}
|
||||
|
||||
if (settings?.visible) {
|
||||
applyTo.visible = settings.visible;
|
||||
}
|
||||
|
||||
if (settings?.position) {
|
||||
applyTo?.position.set(settings.position.x, settings.position.y);
|
||||
}
|
||||
|
||||
applyPointPrimitiveOrObject('pivot', applyTo, settings?.pivot);
|
||||
applyPointPrimitiveOrObject('scale', applyTo, settings?.scale);
|
||||
applyPointPrimitiveOrObject('skew', applyTo, settings?.skew);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* - TODO
|
||||
*
|
||||
* @param prop - TODO
|
||||
* @param applyTo - TODO
|
||||
* @param setting - TODO
|
||||
*/
|
||||
function applyPointPrimitiveOrObject<T extends PIXI.Container>(prop: 'skew' | 'pivot' | 'scale', applyTo: T, setting: pointOrNumber): void {
|
||||
if (setting) {
|
||||
if (typeof setting === 'object') {
|
||||
applyTo[prop].set(setting.x, setting.y);
|
||||
} else {
|
||||
applyTo[prop].set(setting);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
VisualBase
|
||||
};
|
||||
|
||||
export type {
|
||||
VisualBaseSettings
|
||||
};
|
||||
@@ -1,11 +0,0 @@
|
||||
// - TODO - Clean this up
|
||||
import * as PIXI from 'pixi.js';
|
||||
import * as Events from '@pixi/events';
|
||||
import { Viewport } from 'pixi-viewport';
|
||||
|
||||
import 'pixi.js/math-extras';
|
||||
|
||||
// Filters
|
||||
import * as Filters from 'pixi-filters';
|
||||
|
||||
export { PIXI, Events, Viewport, Filters };
|
||||
@@ -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
7
src/lib/base-url.ts
Normal 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";
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user