Merge pull request 'Deploy' (#1) from Deploy into main
Some checks failed
Build and deploy / deploy (push) Failing after 26s

Reviewed-on: #1
This commit is contained in:
joe
2024-12-17 18:29:26 +00:00
35 changed files with 3859 additions and 5048 deletions

38
.dockerignore Normal file
View File

@@ -0,0 +1,38 @@
# flyctl launch added from .gitignore
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
node_modules
.pnp
**/.pnp.js
**/.yarn/install-state.gz
# testing
coverage
# next.js
.next
out
# production
build
# misc
**/.DS_Store
**/*.pem
# debug
**/npm-debug.log*
**/yarn-debug.log*
**/yarn-error.log*
# local env files
**/.env*.local
# vercel
**/.vercel
# typescript
**/*.tsbuildinfo
**/next-env.d.ts
fly.toml

View File

@@ -2,35 +2,50 @@ name: Build and deploy
run-name: Build and deploy
on:
push:
# branches:
# - main
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
steps:
-
name: Checkout
- name: Checkout
uses: actions/checkout@v4
with:
github-server-url: 'https://gitea.home.joemonk.co.uk'
-
name: Set up docker
- name: Set up docker
run: 'curl -fsSL https://get.docker.com | sh'
-
name: Set up Docker Buildx
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
-
name: Login to private registry
- name: Login to gitea registry
uses: docker/login-action@v3
with:
registry: 'gitea.home.joemonk.co.uk/${{ github.repository }}'
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
-
name: Build and push
- name: Login to flyio registry
uses: docker/login-action@v3
with:
registry: 'registry.fly.io'
username: x
password: ${{ secrets.FLY_API_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: 'gitea.home.joemonk.co.uk/${{ gitea.repository }}:latest'
tags: |
'gitea.home.joemonk.co.uk/${{ gitea.repository }}:latest'
'gitea.home.joemonk.co.uk/${{ gitea.repository }}:${{ gitea.sha }}'
'registry.fly.io/${{ gitea.repository }}:${{ gitea.sha }}'
- uses: superfly/flyctl-actions/setup-flyctl@master
- run: flyctl deploy --remote-only -i registry.fly.io/${{ gitea.repository }}:${{ gitea.sha }}
env:
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}

View File

@@ -39,6 +39,7 @@ RUN chown nextjs:nodejs .next
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder /app/db.sql ./db.sql
USER nextjs

BIN
db.sql

Binary file not shown.

10
drizzle.config.ts Normal file
View File

@@ -0,0 +1,10 @@
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
out: './drizzle',
schema: './src/db/schema',
dialect: 'sqlite',
dbCredentials: {
url: `${process.cwd()}/db.sql`,
},
});

30
fly.toml Normal file
View File

@@ -0,0 +1,30 @@
# fly.toml app configuration file generated for joemonk on 2024-11-13T18:49:43Z
#
# See https://fly.io/docs/reference/configuration/ for information about how to use this file.
#
app = 'joemonk'
primary_region = 'lhr'
[build]
[http_service]
internal_port = 3000
force_https = true
auto_stop_machines = 'stop'
auto_start_machines = true
min_machines_running = 0
processes = ['app']
[[http_service.checks]]
grace_period = "15s"
interval = "120s"
method = "GET"
timeout = "5s"
path = "/api/status"
protocol = "http"
[[vm]]
memory = '1gb'
cpu_kind = 'shared'
cpus = 1

View File

@@ -6,16 +6,16 @@ const nextConfig = {
pageExtensions: ["js", "jsx", "md", "mdx", "ts", "tsx"],
experimental: {
reactCompiler: true,
ppr: true,
ppr: "incremental",
},
serverExternalPackages: ["typeorm"],
serverExternalPackages: ["typeorm", "better-sqlite3"],
reactStrictMode: true,
output: "standalone",
images: {
remotePatterns: [
{
protocol: "https",
hostname: "fly.storage.tigris.dev"
hostname: "fly.storage.tigris.dev",
},
],
},

7772
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,44 +8,54 @@
"build:analyse": "ANALYZE=true npm run build",
"start": "next start",
"lint": "next lint",
"lint:fix": "next lint -- --fix"
"lint:fix": "next lint --fix"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.663.0",
"@heroicons/react": "^2.1.5",
"@mdx-js/loader": "^3.0.1",
"@mdx-js/react": "^3.0.1",
"@next/bundle-analyzer": "^14.2.13",
"@next/mdx": "^14.2.13",
"@aws-sdk/client-s3": "^3.712.0",
"@heroicons/react": "^2.2.0",
"@mdx-js/loader": "^3.1.0",
"@mdx-js/react": "^3.1.0",
"@next/bundle-analyzer": "^15.1.0",
"@next/mdx": "^15.1.0",
"@tailwindcss/typography": "^0.5.15",
"@tanstack/react-query": "^5.62.7",
"@tanstack/react-virtual": "^3.11.1",
"@trpc/client": "^11.0.0-rc.660",
"@trpc/react-query": "^11.0.0-rc.660",
"@trpc/server": "^11.0.0-rc.660",
"@types/better-sqlite3": "^7.6.12",
"@types/mdx": "^2.0.13",
"@types/node": "^22.6.1",
"@types/react": "^18.3.9",
"@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^8.7.0",
"@types/node": "^22.10.2",
"@types/react": "^19.0.1",
"@types/react-dom": "^19.0.2",
"@typescript-eslint/eslint-plugin": "^8.18.0",
"autoprefixer": "^10.4.20",
"babel-plugin-react-compiler": "^0.0.0-experimental-6067d4e-20240924",
"better-sqlite3": "^9.6.0",
"eslint": "^9.11.1",
"eslint-config-next": "^15.0.0-rc.0",
"babel-plugin-react-compiler": "beta",
"better-sqlite3": "^11.7.0",
"client-only": "^0.0.1",
"drizzle-kit": "^0.30.1",
"drizzle-orm": "^0.38.2",
"eslint": "^9.17.0",
"eslint-config-next": "^15.1.0",
"exif-reader": "^2.0.1",
"framer-motion": "^11.5.6",
"framer-motion": "^11.14.4",
"glob": "^11.0.0",
"million": "^3.1.11",
"next": "^15.0.0-rc.0",
"next-auth": "^5.0.0-beta",
"postcss": "^8.4.47",
"next": "15.1.1-canary.5",
"next-auth": "5.0.0-beta.25",
"postcss": "^8.4.49",
"radash": "^12.1.0",
"react": "^19.0.0-rc-04bd67a4-20240924",
"react-dom": "^19.0.0-rc-04bd67a4-20240924",
"react": "19.0.0",
"react-dom": "19.0.0",
"react-zoom-pan-pinch": "^3.6.1",
"reflect-metadata": "^0.2.2",
"server-only": "^0.0.1",
"sharp": "^0.33.5",
"superjson": "^2.2.2",
"tailwind-scrollbar": "^3.1.0",
"tailwindcss": "^3.4.13",
"typeorm": "^0.3.20",
"typescript": "^5.6.2",
"yet-another-react-lightbox": "^3.21.6"
"tailwindcss": "^3.4.16",
"typescript": "^5.7.2",
"yet-another-react-lightbox": "^3.21.7",
"zod": "^3.24.1"
}
}

View File

@@ -1,16 +1,17 @@
import { signIn } from "@/lib/auth"
import { signIn } from "@/lib/auth";
import type React from "react";
export default function Auth(props: {
searchParams: { callbackUrl: string | undefined }
}) {
searchParams: Promise<{ callbackUrl: string | undefined }>
}): React.JSX.Element {
return (
<form
className="w-40 mx-auto"
action={async () => {
"use server"
"use server";
await signIn("authelia", {
redirectTo: props.searchParams?.callbackUrl ?? "",
})
redirectTo: (await props.searchParams)?.callbackUrl ?? "",
});
}}
>
<button type="submit"
@@ -19,5 +20,5 @@ export default function Auth(props: {
<span>Sign in with Authelia</span>
</button>
</form>
)
);
}

View File

@@ -1,8 +1,5 @@
import { SessionProvider } from "next-auth/react";
import NavBar from '@/components/navbar';
import Footer from '@/components/footer';
import LogIn from "@/components/auth/login";
import "../globals.css";

View File

@@ -1,18 +1,16 @@
import Image from "next/image";
import Lightbox from "@/components/lightbox";
import { type GetPhotos } from "@/app/api/photos/route";
async function getImageData(): Promise<GetPhotos> {
const res = await fetch(`http://localhost:3000/api/photos`, { next: { revalidate: false, tags: ['photos'] } });
return res.json() as Promise<GetPhotos>;
}
import FilteredLightbox from "@/components/lightbox";
import { trpc } from "@/trpc/server";
import { TRPCProvider } from "@/trpc/client";
export default async function Photos(): Promise<React.JSX.Element> {
const {data: imageData} = await getImageData();
const { data: images } = await trpc.photos.list();
return (
<Lightbox imageData={imageData.images}>
{imageData.images.map((image) => (
<div className="mx-auto">
<TRPCProvider>
<FilteredLightbox imageData={images}>
{images.map((image) => (
<Image
key={image.src}
alt={image.src}
@@ -26,6 +24,8 @@ export default async function Photos(): Promise<React.JSX.Element> {
placeholder="blur"
/>
))}
</Lightbox>
</FilteredLightbox>
</TRPCProvider>
</div>
);
}

View File

@@ -1,24 +1,32 @@
import { glob } from "glob";
import dynamic from "next/dynamic";
import dynamic, { LoaderComponent } from "next/dynamic";
import 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`, {
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)]
slug: [post.split("/").at(-1)!.slice(0, -4)],
}));
return slugs;
}
export default async function Post({params}: {params: { slug: string[] }}): Promise<React.JSX.Element> {
const mdxFile = await import(`../../../../markdown/posts/[...slug]/${params.slug.join('/')}.mdx`)
const Post = dynamic(async () => mdxFile);
return (
<Post/>
);
export default async function Post({
params,
}: {
params: Promise<{ slug: string[] }>;
}): Promise<React.JSX.Element> {
const mdxFile = await import(
`../../../../markdown/posts/[...slug]/${(await params).slug.join("/")}.mdx`
) as LoaderComponent<unknown>;
const Post = dynamic(() => mdxFile);
return <Post />;
}

View File

@@ -4,42 +4,43 @@ import { unstable_cache } from "next/cache";
import Link from "next/link";
type postDetails = {
link: string,
link: string;
metadata: {
title: string,
date: string,
coverImage: string,
blurb: string,
shortBlurb: string,
tags: string[]
}
}
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`)
const slug = [post.split("/").at(-1)!.slice(0, -4)];
const mdxFile = await import(
`../../../../src/markdown/posts/[...slug]/${slug.join("/")}.mdx`
) as postDetails;
return {
link: getCurrentUrl() + "/posts/" + slug.join("/"),
metadata: mdxFile.metadata,
link: getCurrentUrl() + '/posts/' + slug.join('/')
}
};
});
const postData = await Promise.all(loadPostData);
return postData;
}
const getPosts = unstable_cache(
loadPostDetails,
['posts'],
{
revalidate: false
}
)
const getPosts = unstable_cache(loadPostDetails, ["posts"], {
revalidate: false,
});
export default async function Posts(): Promise<React.JSX.Element> {
const postDetails = await getPosts();
@@ -56,17 +57,17 @@ export default async function Posts(): Promise<React.JSX.Element> {
{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>
<span className="select-none text-sm me-2 px-2.5 py-1 rounded border border-dracula-pink dark:bg-dracula-bg-darker dark:text-dracula-pink">
{tag}
</span>
</div>
)
);
})}
</div>
<p>
{post.metadata.blurb}
</p>
<p>{post.metadata.blurb}</p>
</div>
</div>
)
);
})}
</div>
);

View File

@@ -2,21 +2,21 @@ import { NextRequest } from "next/server";
import { handlers } from "@/lib/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
}
const envOrigin = `${proto}://${host}`
const { href, origin } = req.nextUrl
return new NextRequest(href.replace(origin, envOrigin), req)
console.warn("Missing x-forwarded-proto or x-forwarded-host headers.");
return req;
}
const envOrigin = `${proto}://${host}`;
const { href, origin } = req.nextUrl;
return new NextRequest(href.replace(origin, envOrigin), req);
};
export const GET = (req: NextRequest) => {
return handlers.GET(reqWithTrustedOrigin(req))
}
export const GET = (req: NextRequest): Promise<Response> => {
return handlers.GET(reqWithTrustedOrigin(req));
};
export const POST = (req: NextRequest) => {
return handlers.POST(reqWithTrustedOrigin(req))
}
export const POST = (req: NextRequest): Promise<Response> => {
return handlers.POST(reqWithTrustedOrigin(req));
};

View File

@@ -1,86 +0,0 @@
import { S3Client, ListObjectsV2Command, GetObjectCommand } from "@aws-sdk/client-s3";
import exifReader from "exif-reader";
import { NextResponse } from "next/server";
import { diff, sift } from "radash";
import sharp from "sharp";
import PhotoDataSource from "@/data-source";
import { Photo } from "@/entity/photo";
import { auth } from "@/lib/auth";
export type GetPhotosUpdate = {
status: number,
s3Photos: string[]
}
export const GET = auth(async function GET(req): Promise<Response> {
if (!req.auth) {
return NextResponse.json({ message: "Not authenticated" }, { status: 401 })
}
const dataSource = await PhotoDataSource.dataSource;
const photoRepository = dataSource.getRepository(Photo);
const currentSources = (await photoRepository.find({
select: {
src: true
}
})).map((photo) => photo.src);
const s3Client = new S3Client();
const listObjCmd = new ListObjectsV2Command({
Bucket: "joemonk-photos"
});
const s3Res = await s3Client.send(listObjCmd);
if (!s3Res.Contents) {
return NextResponse.json({ status: 500 })
}
const s3Photos = sift(s3Res.Contents.map((obj) => {
if (!obj.Key?.endsWith('/')) {
return `https://fly.storage.tigris.dev/joemonk-photos/${obj.Key}`;
} else {
return null;
}
}));
const newPhotos = diff(s3Photos, currentSources);
const imageData = newPhotos.map(async (fileName: string) => {
const getImageCmd = new GetObjectCommand({
Bucket: "joemonk-photos",
Key: fileName.replace("https://fly.storage.tigris.dev/joemonk-photos/", "")
})
const imgRes = await s3Client.send(getImageCmd);
const image = await imgRes.Body?.transformToByteArray();
const { width, height, exif } = await sharp(image).metadata();
const blur = await sharp(image)
.resize({ width: 12, height: 12, fit: 'inside' })
.toBuffer();
const exifData = exif ? exifReader(exif) : undefined;
const photo = new Photo();
photo.src = fileName;
photo.width = width ?? 10;
photo.height = height ?? 10;
photo.blur = `data:image/jpeg;base64,${blur.toString('base64')}` as `data:image/${string}`;
photo.camera = exifData?.Image?.Model ?? null;
photo.exposureBiasValue = exifData?.Photo?.ExposureBiasValue ?? null;
photo.fNumber = exifData?.Photo?.FNumber ?? null;
photo.isoSpeedRatings = exifData?.Photo?.ISOSpeedRatings ?? null;
photo.focalLength = exifData?.Photo?.FocalLength ?? null;
photo.dateTimeOriginal = exifData?.Photo?.DateTimeOriginal ?? null;
photo.lensModel = exifData?.Photo?.LensModel ?? null;
return photo;
});
const images = await Promise.all(imageData);
await photoRepository.save(images);
return NextResponse.json<GetPhotosUpdate>({ status: 200, s3Photos: newPhotos });
});

View File

@@ -0,0 +1,11 @@
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { createTRPCContext } from '@/trpc/init';
import { appRouter } from '@/trpc/routers/_app';
const handler = (req: Request): Promise<Response> =>
fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: appRouter,
createContext: createTRPCContext,
});
export { handler as GET, handler as POST };

View File

@@ -8,13 +8,13 @@ export default async function LogIn(): Promise<React.JSX.Element | undefined> {
return (
<form
action={async () => {
"use server"
"use server";
if (session?.user) {
await signOut({
redirectTo: `${getCurrentUrl()}/`
})
});
} else {
await signIn("authelia")
await signIn("authelia");
}
}}
>

View File

@@ -15,9 +15,22 @@ 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 ImageData } from "@/app/api/photos/route";
import type { RouterOutput } from "@/trpc/routers/_app";
import { trpc } from "@/trpc/client";
function NextJsImage({ slide, offset, rect, unoptimized = false }: {slide: ImageData, offset: number, rect: {width: number, height: number}, unoptimized: boolean}): React.JSX.Element {
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 },
@@ -29,13 +42,13 @@ function NextJsImage({ slide, offset, rect, unoptimized = false }: {slide: Image
const width = !cover
? Math.round(
Math.min(rect.width, (rect.height / slide.height) * slide.width),
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),
Math.min(rect.height, (rect.width / slide.width) * slide.height)
)
: rect.height;
@@ -56,38 +69,141 @@ function NextJsImage({ slide, offset, rect, unoptimized = false }: {slide: Image
}}
sizes={`${Math.ceil((width / window.innerWidth) * 100)}vw`}
onClick={
offset === 0 ? (): void => click?.({ index: currentIndex }) : undefined
offset === 0
? (): void => click?.({ index: currentIndex })
: undefined
}
/>
</div>
);
}
export default function MyLightbox({imageData, children}: {imageData: ImageData[], children: React.JSX.Element[]}): React.JSX.Element {
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) => (
<button key={`lightbox_img_${index}`} onClick={(() => {
{children.map((image, index) => {
return (
<button
key={`lightbox_img_${index}`}
onClick={() => {
setActive(index);
})}>
<div className="relative">
{image}
</div>
}}
>
<div className="relative">{image}</div>
</button>
))}
);
})}
</div>
<YARL
open={typeof active === 'number'}
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
render={{ slide: (args) => NextJsImage({...args, unoptimized: true }), thumbnail: NextJsImage }}
thumbnail: NextJsImage,
}}
plugins={[Thumbnails, Zoom, Captions]}
/>
</div>
);
}
interface FormElements extends HTMLFormControlsCollection {
src: HTMLInputElement;
}
interface UsernameFormElement extends HTMLFormElement {
readonly elements: FormElements;
}
export default function FilteredLightbox(props: {
imageData: ImageData[];
children: React.JSX.Element[];
}): React.JSX.Element {
//const [imageData, setImageData] = useState(props.imageData);
const [imageData] = useState(props.imageData);
const photoQuery = trpc.photos.list.useInfiniteQuery(
{
limit: 1,
},
{
initialData: {
pages: [
{
data: props.imageData,
next: props.imageData.length,
},
],
pageParams: [0],
},
getNextPageParam: (lastPage) => lastPage.next,
}
);
const refreshQuery = trpc.photos.update.useQuery(undefined, {
enabled: false,
retry: false,
});
function handleSubmit(event: React.FormEvent<UsernameFormElement>): void {
event.preventDefault();
// const imageData = props.imageData;
// setImageData(
// imageData.filter(
// (data) => data.src === event.currentTarget.elements.src.value
// )
// );
void photoQuery.fetchNextPage();
}
const children = photoQuery.data.pages
.flatMap((data) => data.data)
.map((data) => (
<Image
key={data.src}
alt={data.src}
src={data.src}
className="object-contain h-60 w-80"
sizes="100vw"
loading="lazy"
width={data.width}
height={data.height}
blurDataURL={data.blur}
placeholder="blur"
/>
))
.filter((data) => !!data);
return (
<>
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="src">Src:</label>
<input id="src" type="text" />
</div>
<button type="submit">Submit</button>
</form>
<button
onClick={() => {
void refreshQuery.refetch();
}}
>
Refresh
</button>
{refreshQuery.data ? JSON.stringify(refreshQuery.data) : "\nNot"}
{refreshQuery.error ? JSON.stringify(refreshQuery.error) : "\nNo Error"}
<Lightbox imageData={imageData}>{...children}</Lightbox>
</>
);
}

View File

@@ -1,21 +1,33 @@
'use client';
import { useMemo, useState } from 'react';
import Link from 'next/link';
import { HomeModernIcon, Bars3Icon, XMarkIcon } from '@heroicons/react/24/outline';
import { AnimatePresence, m, LazyMotion, domAnimation } from "framer-motion";
import { usePathname } from 'next/navigation';
import ThemeSwitcher from './theme-switcher';
"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,
LogIn: React.JSX.Element;
navigation: {
name: string;
href: string;
current: boolean;
}[]
}
}[];
};
export default function NavBarClient({LogIn, navigation}: NavBarClientProps): React.JSX.Element {
export default function NavBarClient({
LogIn,
navigation,
}: NavBarClientProps): React.JSX.Element {
const [open, setOpen] = useState(false);
const pathname = usePathname();
@@ -27,7 +39,7 @@ export default function NavBarClient({LogIn, navigation}: NavBarClientProps): Re
current.current = true;
}
return nav;
}, [pathname]);
}, [pathname, navigation]);
return (
<nav className="dark:bg-dracula-bg-darker border-b-2 dark:border-dracula-purple">
@@ -35,32 +47,38 @@ export default function NavBarClient({LogIn, navigation}: NavBarClientProps): Re
<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)}>
<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'/>
<XMarkIcon className="rounded-sm dark:stroke-dracula-cyan h-8 w-auto" />
) : (
<Bars3Icon 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
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'>
<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' : ''
item.current ? "dark:border-b-dracula-pink" : ""
}`}
aria-current={item.current ? 'page' : undefined}
aria-current={item.current ? "page" : undefined}
>
{item.name}
</Link>
))}
</div>
</div>
<div className='space-x-4 flex'>
<div className="space-x-4 flex">
<ThemeSwitcher />
{LogIn}
</div>
@@ -68,28 +86,28 @@ export default function NavBarClient({LogIn, navigation}: NavBarClientProps): Re
</div>
<AnimatePresence>
{open ? (
<m.div
<motion.div
initial={{ height: 0 }}
animate={{ height: "auto" }}
transition={{ duration: 0.15, ease: 'linear' }}
transition={{ duration: 0.15, ease: "linear" }}
exit={{ height: 0 }}
className='sm:hidden overflow-hidden'
className="sm:hidden overflow-hidden"
>
<div className='flex flex-col space-y-1 py-1'>
<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' : ''
item.current ? "dark:border-l-dracula-pink" : ""
}`}
aria-current={item.current ? 'page' : undefined}
aria-current={item.current ? "page" : undefined}
>
{item.name}
</Link>
))}
</div>
</m.div>
</motion.div>
) : null}
</AnimatePresence>
</LazyMotion>

View File

@@ -12,7 +12,7 @@ const defaultNavigation = [
const authedNavigation = [
{ name: 'Manage', href: '/manage', current: false },
]
];
export default async function NavBar(): Promise<React.JSX.Element> {
const session = await auth();

View File

@@ -22,7 +22,7 @@ export default function PostHeader({metadata}: PostHeaderProps): React.JSX.Eleme
<>
<span className="select-none text-sm me-2 px-2.5 py-1 rounded border border-dracula-pink dark:bg-dracula-bg-darker dark:text-dracula-pink">{tag}</span>
</>
)
);
})}
</div>
</>

View File

@@ -1,32 +0,0 @@
import { DataSource } from "typeorm";
import { Photo } from "./entity/photo";
const dataSource = new DataSource({
type: "better-sqlite3",
database: "db.sql",
entities: [Photo],
migrations: ["./migrations"],
})
export default class PhotoDataSource {
private static _dataSource: DataSource | null = null;
static get dataSource(): Promise<DataSource> {
if (PhotoDataSource._dataSource === null) {
return PhotoDataSource.initDataSource();
} else {
return Promise.resolve(PhotoDataSource._dataSource);
}
}
static async initDataSource(): Promise<DataSource> {
if (!PhotoDataSource._dataSource || !PhotoDataSource._dataSource.isInitialized) {
const ds = await dataSource.initialize();
console.log('Photo data source initialized')
PhotoDataSource._dataSource = ds;
}
return PhotoDataSource._dataSource;
}
}
PhotoDataSource.initDataSource();

3
src/db/db.ts Normal file
View File

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

22
src/db/schema/photo.ts Normal file
View File

@@ -0,0 +1,22 @@
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(),
}
);

View File

@@ -1,48 +0,0 @@
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm"
@Entity()
export class Photo {
@PrimaryGeneratedColumn()
id!: number
@Column("text", { unique: true })
src!: string;
@Column()
width!: number
@Column()
height!: number
@Column("blob")
blur!: string
@Column("text", { nullable: true })
camera: string | null = null;
// Manually input data
@Column("text", { nullable: true })
title: string | null = null;
@Column("text", { nullable: true })
description: string | null = null;
// Exif data
@Column("int", { nullable: true })
exposureBiasValue: number | null = null
@Column("float", { nullable: true })
fNumber: number | null = null
@Column("int", { nullable: true })
isoSpeedRatings: number | null = null
@Column("int", { nullable: true })
focalLength: number | null = null
@Column("date", { nullable: true })
dateTimeOriginal: Date | null = null
@Column("text", { nullable: true })
lensModel: string | null = null
}

View File

@@ -13,4 +13,4 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
}],
trustHost: true,
redirectProxyUrl: `${getCurrentUrl()}/api/auth`,
})
});

60
src/trpc/client.tsx Normal file
View File

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

49
src/trpc/init.ts Normal file
View File

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

27
src/trpc/query-client.ts Normal file
View File

@@ -0,0 +1,27 @@
import {
defaultShouldDehydrateQuery,
QueryClient,
} from '@tanstack/react-query';
import { serialize, deserialize } from 'superjson';
export function makeQueryClient(): QueryClient {
return new QueryClient({
defaultOptions: {
queries: {
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
staleTime: 30 * 1000,
},
dehydrate: {
serializeData: serialize,
shouldDehydrateQuery: (query) =>
defaultShouldDehydrateQuery(query) ||
query.state.status === 'pending',
},
hydrate: {
deserializeData: deserialize,
},
},
});
}

11
src/trpc/routers/_app.ts Normal file
View File

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

View File

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

View File

@@ -1,7 +1,6 @@
import { NextResponse } from "next/server";
import { shake } from "radash";
import PhotoDataSource from "@/data-source";
import { Photo } from "@/entity/photo";
import db from "@/db/db";
import { photosTable } from "@/db/schema/photo";
export type ImageData = {
width: number,
@@ -21,17 +20,16 @@ export type ImageData = {
description?: string
}
export type GetPhotos = {
status: number,
data: {
images: ImageData[]
}
export type ListOptions = {
cursor: number,
limit: number
}
export async function GET(): Promise<Response> {
const dataSource = await PhotoDataSource.dataSource;
const photoRepository = dataSource.getRepository(Photo);
const currentSources = await photoRepository.find();
export async function list(options: ListOptions): Promise<ImageData[]> {
const currentSources = await db.select().from(photosTable)
.limit(options.limit)
.offset(options.cursor);
const images = currentSources.map((photo) => {
return {
width: photo.width,
@@ -49,8 +47,8 @@ export async function GET(): Promise<Response> {
}),
title: photo.title ?? undefined,
description: photo.description ?? undefined
}
})
};
});
return NextResponse.json<GetPhotos>({ status: 200, data: { images } });
return images;
}

View File

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

14
src/trpc/server.ts Normal file
View File

@@ -0,0 +1,14 @@
import 'server-only'; // <-- ensure this file cannot be imported from the client
import { createHydrationHelpers } from '@trpc/react-query/rsc';
import { cache } from 'react';
import { createCallerFactory, createTRPCContext } from './init';
import { makeQueryClient } from './query-client';
import { appRouter } from './routers/_app';
// IMPORTANT: Create a stable getter for the query client that
// will return the same client during the same request.
export const getQueryClient = cache(makeQueryClient);
const caller = createCallerFactory(appRouter)(createTRPCContext);
export const { trpc, HydrateClient } = createHydrationHelpers<typeof appRouter>(
caller,
getQueryClient,
);