Set up photo editor and clean up styling

This commit is contained in:
2025-09-21 19:08:08 +01:00
parent 42caeb8834
commit 784f7320a1
19 changed files with 9067 additions and 1435 deletions

1222
bun.lock

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,187 @@
{
"version": "6",
"dialect": "sqlite",
"id": "40ac544c-a258-4628-b121-7ed0403516d7",
"prevId": "246363f6-a664-4ec6-b43d-0033fe21ff8c",
"tables": {
"challenges": {
"name": "challenges",
"columns": {
"token": {
"name": "token",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"data": {
"name": "data",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires": {
"name": "expires",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"photo": {
"name": "photo",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"src": {
"name": "src",
"type": "text(256)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"width": {
"name": "width",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"height": {
"name": "height",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"blur": {
"name": "blur",
"type": "blob",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"camera": {
"name": "camera",
"type": "text(128)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text(128)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text(1024)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"exposureBiasValue": {
"name": "exposureBiasValue",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"fNumber": {
"name": "fNumber",
"type": "real",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"isoSpeedRatings": {
"name": "isoSpeedRatings",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"focalLength": {
"name": "focalLength",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"takenAt": {
"name": "takenAt",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"lensModel": {
"name": "lensModel",
"type": "text(128)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"photo_src_unique": {
"name": "photo_src_unique",
"columns": ["src"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"tokens": {
"name": "tokens",
"columns": {
"key": {
"name": "key",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"expires": {
"name": "expires",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -8,6 +8,13 @@
"when": 1746743224792, "when": 1746743224792,
"tag": "0000_harsh_toad_men", "tag": "0000_harsh_toad_men",
"breakpoints": true "breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1756863378002,
"tag": "0001_familiar_gambit",
"breakpoints": true
} }
] ]
} }

8588
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -61,6 +61,7 @@
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.0.6", "@biomejs/biome": "2.0.6",
"@tailwindcss/postcss": "^4.1.11", "@tailwindcss/postcss": "^4.1.11",
"@types/node": "24.5.2",
"@types/react": "^19.1.8", "@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6", "@types/react-dom": "^19.1.6",
"drizzle-kit": "^0.31.4", "drizzle-kit": "^0.31.4",

View File

@@ -1,19 +1,26 @@
"use client"; "use client";
import { useEditor, EditorContent, useEditorState } from "@tiptap/react"; import Bold from "@tiptap/extension-bold";
import Document from "@tiptap/extension-document"; import Document from "@tiptap/extension-document";
import Italic from "@tiptap/extension-italic";
import Paragraph from "@tiptap/extension-paragraph"; import Paragraph from "@tiptap/extension-paragraph";
import Text from "@tiptap/extension-text"; import Text from "@tiptap/extension-text";
import Bold from "@tiptap/extension-bold";
import Italic from "@tiptap/extension-italic";
import Typography from "@tiptap/extension-typography"; import Typography from "@tiptap/extension-typography";
import { UndoRedo, Placeholder } from "@tiptap/extensions"; import { Placeholder, UndoRedo } from "@tiptap/extensions";
import {
EditorContent,
useEditor,
useEditorState,
type Content,
} from "@tiptap/react";
import { useEffect } from "react"; import { useEffect } from "react";
export default function Tiptap({ export default function Tiptap({
onChange, onChange,
initContent,
}: { }: {
onChange: (args: unknown) => void; onChange: (args: unknown) => void;
initContent?: Content;
}) { }) {
const editor = useEditor({ const editor = useEditor({
extensions: [ extensions: [
@@ -35,6 +42,7 @@ export default function Tiptap({
class: "py-1 px-2", class: "py-1 px-2",
}, },
}, },
content: initContent,
}); });
const editorState = useEditorState({ const editorState = useEditorState({
@@ -53,7 +61,6 @@ export default function Tiptap({
}); });
useEffect(() => { useEffect(() => {
console.log(editorState?.currentContent);
onChange(editorState?.currentContent); onChange(editorState?.currentContent);
}, [editorState?.currentContent, onChange]); }, [editorState?.currentContent, onChange]);

View File

@@ -1,17 +1,18 @@
"use client"; "use client";
import type { PhotoData } from "@/server/api/routers/photos/list";
import { api } from "@/trpc/react"; import { zodResolver } from "@hookform/resolvers/zod";
import Image from "next/image"; import Image from "next/image";
import type React from "react"; import type React from "react";
import { useState } from "react"; import { useState } from "react";
import ImageSvg from "./file-svg";
import DirSvg from "./dir-svg";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import z from "zod"; 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"; import Tiptap from "./photo-editor";
// - TODO - Pull this from trpc
const FormSchema = z.object({ const FormSchema = z.object({
title: z title: z
.string() .string()
@@ -83,24 +84,35 @@ function renderTree(node: DirectoryTree, pathSoFar = ""): Item[] {
return items; return items;
} }
function RenderLeaf(leaf: Item[], selectImageTab: (path: string) => void) { function RenderLeaf(
leaf: Item[],
selectImageTab: (path: string) => void,
selectedImage: PhotoData | undefined,
) {
return leaf.map((leaf) => { return leaf.map((leaf) => {
const selectedLeaf =
`https://fly.storage.tigris.dev/joemonk-photos/${leaf.fullPath}` ===
selectedImage?.src;
if (leaf.children?.length) { if (leaf.children?.length) {
return ( return (
<li> <li key={leaf.fullPath}>
<details open> <details open>
<summary> <summary>
<DirSvg /> <DirSvg />
{leaf.name} {leaf.name}
</summary> </summary>
<ul>{RenderLeaf(leaf.children, selectImageTab)}</ul> <ul>{RenderLeaf(leaf.children, selectImageTab, selectedImage)}</ul>
</details> </details>
</li> </li>
); );
} }
return ( return (
<li key={leaf.fullPath}> <li key={leaf.fullPath}>
<button type="button" onClick={() => selectImageTab(leaf.fullPath)}> <button
type="button"
className={selectedLeaf ? "active" : ""}
onClick={() => selectImageTab(leaf.fullPath)}
>
<ImageSvg /> <ImageSvg />
{leaf.name} {leaf.name}
</button> </button>
@@ -111,9 +123,16 @@ function RenderLeaf(leaf: Item[], selectImageTab: (path: string) => void) {
export function PhotoTab(): React.JSX.Element { export function PhotoTab(): React.JSX.Element {
const [selectedImage, setSelectedImage] = useState<PhotoData>(); const [selectedImage, setSelectedImage] = useState<PhotoData>();
const query = api.photos.list.useQuery(undefined, { const listQuery = api.photos.list.useInfiniteQuery(
{},
{
getNextPageParam: (lastPage) => lastPage.next,
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
}); refetchOnMount: false,
refetchOnReconnect: false,
},
);
const modifyMutate = api.photos.modify.useMutation();
const { const {
register, register,
@@ -125,18 +144,21 @@ export function PhotoTab(): React.JSX.Element {
mode: "onSubmit", mode: "onSubmit",
}); });
if (query.isLoading) { if (listQuery.isLoading) {
return <p>Loading</p>; return <p>Loading</p>;
} }
if (query.error) { if (listQuery.error) {
return <p>{query.error.message}</p>; return <p>{listQuery.error.message}</p>;
} }
const images = query.data?.data; const images = listQuery.data?.pages.flatMap((data) => data.data);
if (!images || images?.length === 0) { if (!images || images?.length === 0) {
return <p>No Images</p>; return <p>No Images</p>;
} }
if (listQuery.hasNextPage) {
listQuery.fetchNextPage();
}
const selectImageTab = (path: string) => { const selectImage = (path: string) => {
const img = images.find( const img = images.find(
(img) => (img) =>
img.src === `https://fly.storage.tigris.dev/joemonk-photos/${path}`, img.src === `https://fly.storage.tigris.dev/joemonk-photos/${path}`,
@@ -153,22 +175,26 @@ export function PhotoTab(): React.JSX.Element {
); );
const renderedTree = renderTree(tree); const renderedTree = renderTree(tree);
const onSubmit = (data: IFormInput) => {
console.log(data);
};
return ( return (
<div className="flex w-full gap-2"> <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-1/4"> <ul className="menu menu-xs bg-base-200 box w-full md:w-1/4">
{RenderLeaf(renderedTree, selectImageTab)} {RenderLeaf(renderedTree, selectImage, selectedImage)}
</ul> </ul>
<div className="w-3/4 box border border-base-300 p-2"> <div className="md:w-3/4 box border border-base-300 p-2 w-full">
{selectedImage?.src ? ( {selectedImage?.src ? (
<form onSubmit={handleSubmit(onSubmit)}> <form
onSubmit={handleSubmit((data) =>
modifyMutate.mutate({
title: data.title,
description: data.description,
src: selectedImage.src,
}),
)}
>
<label <label
className={`floating-label input text-lg mb-2 w-full ${errors.title ? "input-error" : null}`} className={`floating-label input text-lg mb-2 w-full ${errors.title ? "input-error" : null}`}
> >
<span>{`Title ${errors.title ? " - " + errors.title.message : ""}`}</span> <span>{`Title ${errors.title ? ` - ${errors.title.message}` : ""}`}</span>
<input <input
{...register("title")} {...register("title")}
type="text" type="text"
@@ -263,13 +289,30 @@ export function PhotoTab(): React.JSX.Element {
control={control} control={control}
name="description" name="description"
render={({ field: { onChange } }) => ( render={({ field: { onChange } }) => (
<Tiptap onChange={onChange} /> <Tiptap
onChange={onChange}
initContent={selectedImage.description}
/>
)} )}
/> />
</div> </div>
<button className="button" type="submit"> <div className="flex flex-row items-center">
Submit <button
className="btn btn-primary flex self-center m-4"
type="submit"
>
Save
</button> </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> </form>
) : null} ) : null}
</div> </div>

View File

@@ -3,7 +3,7 @@ import { PhotoTab } from "./_components/photo-tab";
export default async function Photos(): Promise<React.JSX.Element> { export default async function Photos(): Promise<React.JSX.Element> {
return ( return (
<div className="mx-auto"> <div className="mx-auto">
<div className="tabs tabs-lift"> <div role="tablist" className="tabs tabs-lift">
<input <input
type="radio" type="radio"
name="admin_tabs" name="admin_tabs"

View File

@@ -53,7 +53,7 @@ export default async function Posts(): Promise<React.JSX.Element> {
<div className="card-body"> <div className="card-body">
<h1 className="card-title">{post.metadata.title}</h1> <h1 className="card-title">{post.metadata.title}</h1>
<time dateTime={post.metadata.date}>{post.metadata.date}</time> <time dateTime={post.metadata.date}>{post.metadata.date}</time>
<div className="flex flex-row gap-2 pb-2"> <div className="flex flex-row flex-wrap gap-2 pb-2">
{post.metadata.tags.map((tag) => { {post.metadata.tags.map((tag) => {
return ( return (
<div key={`${post.link}_${tag}`}> <div key={`${post.link}_${tag}`}>
@@ -64,10 +64,7 @@ export default async function Posts(): Promise<React.JSX.Element> {
</div> </div>
<p>{post.metadata.blurb}</p> <p>{post.metadata.blurb}</p>
<div className="card-actions justify-end pt-2"> <div className="card-actions justify-end pt-2">
<Link <Link className="btn btn-primary" href={post.link}>
className="btn btn-primary hover:bg-primary/10"
href={post.link}
>
Read Read
</Link> </Link>
</div> </div>

View File

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

View File

@@ -2,16 +2,11 @@
import Image from "next/image"; import Image from "next/image";
import type React from "react"; import type React from "react";
import { useState } from "react"; import { useState } from "react";
import YARL from "yet-another-react-lightbox";
import YARL, {
isImageFitCover,
isImageSlide,
useLightboxProps,
useLightboxState,
} from "yet-another-react-lightbox";
import Captions from "yet-another-react-lightbox/plugins/captions"; import Captions from "yet-another-react-lightbox/plugins/captions";
import Thumbnails from "yet-another-react-lightbox/plugins/thumbnails"; import Thumbnails from "yet-another-react-lightbox/plugins/thumbnails";
import Zoom from "yet-another-react-lightbox/plugins/zoom"; 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/styles.css";
import "yet-another-react-lightbox/plugins/thumbnails.css"; import "yet-another-react-lightbox/plugins/thumbnails.css";
import "yet-another-react-lightbox/plugins/captions.css"; import "yet-another-react-lightbox/plugins/captions.css";
@@ -19,66 +14,8 @@ import { api, type RouterOutputs } from "@/trpc/react";
type PhotoData = RouterOutputs["photos"]["list"]["data"][number]; type PhotoData = RouterOutputs["photos"]["list"]["data"][number];
function NextJsImage({
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="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({ export function Lightbox({
photoData: photoData, photoData,
children, children,
}: { }: {
photoData: PhotoData[]; photoData: PhotoData[];
@@ -88,17 +25,18 @@ export function Lightbox({
return ( return (
<div className="mx-auto"> <div className="mx-auto">
<div className="flex flex-row flex-wrap justify-center"> <div className="flex flex-row flex-wrap justify-center gap-8">
{children.map((image, index) => { {children.map((image, index) => {
return ( return (
<button <button
type="button" type="button"
key={"lightbox_img"} key={`lightbox_${image.key}`}
className="cursor-pointer"
onClick={() => { onClick={() => {
setActive(index); setActive(index);
}} }}
> >
<div className="relative">{image}</div> {image}
</button> </button>
); );
})} })}
@@ -110,9 +48,9 @@ export function Lightbox({
slides={photoData} slides={photoData}
render={{ render={{
// @ts-expect-error - Todo // @ts-expect-error - Todo
slide: (args) => NextJsImage({ ...args, unoptimized: true }), slide: (args) => LightboxImage({ ...args, unoptimized: true }),
// @ts-expect-error - Todo - This just passes the slide through, but it doesn't know the type // @ts-expect-error - Todo - This just passes the slide through, but it doesn't know the type
thumbnail: NextJsImage, thumbnail: LightboxImage,
}} }}
plugins={[Thumbnails, Zoom, Captions]} plugins={[Thumbnails, Zoom, Captions]}
/> />
@@ -120,20 +58,10 @@ export function Lightbox({
); );
} }
interface FormElements extends HTMLFormControlsCollection {
src: HTMLInputElement;
}
interface UsernameFormElement extends HTMLFormElement {
readonly elements: FormElements;
}
// TODO
export default function FilteredLightbox(props: { export default function FilteredLightbox(props: {
photoData: PhotoData[]; photoData: PhotoData[];
children: React.JSX.Element[]; children: React.JSX.Element[];
}): React.JSX.Element { }): React.JSX.Element {
//const [photoData, setImageData] = useState(props.photoData);
const [photoData] = useState(props.photoData);
const photoQuery = api.photos.list.useInfiniteQuery( const photoQuery = api.photos.list.useInfiniteQuery(
{ {
limit: 1, limit: 1,
@@ -152,62 +80,42 @@ export default function FilteredLightbox(props: {
}, },
); );
const refreshQuery = api.photos.update.useQuery(undefined, { function handleNextPage(): void {
enabled: false, if (!photoQuery.isLoading) {
retry: false,
});
function handleSubmit(event: React.FormEvent<UsernameFormElement>): void {
event.preventDefault();
// const photoData = props.photoData;
// setImageData(
// photoData.filter(
// (data) => data.src === event.currentTarget.elements.src.value
// )
// );
void photoQuery.fetchNextPage(); void photoQuery.fetchNextPage();
} }
}
const children = photoQuery.data.pages const photoData = photoQuery.data.pages.flatMap((data) => data.data);
.flatMap((data) => data.data)
.map((data) => ( const children = photoData.map((data) => (
<Image <Image
key={data.src} key={data.src}
alt={data.src} alt={data.src}
src={data.src} src={data.src}
className="h-60 w-80 object-contain" className="h-60 w-80"
sizes="100vw"
loading="lazy" loading="lazy"
width={data.width} width={data.width}
height={data.height} height={data.height}
blurDataURL={data.blur} blurDataURL={data.blur}
placeholder="blur" placeholder="blur"
/> />
)) ));
.filter((data) => !!data);
refreshQuery.error ? console.log(refreshQuery.error) : null;
return ( return (
<> <>
<form onSubmit={handleSubmit}> <Lightbox photoData={photoData}>{...children}</Lightbox>
<div>
<label htmlFor="src">Src:</label>
<input id="src" type="text" />
</div>
<button type="submit">Submit</button>
</form>
<button <button
type="button" type="button"
onClick={() => { className="btn btn-primary mx-auto p-4 mt-8 flex"
console.log("refetch"); onClick={handleNextPage}
void refreshQuery.refetch();
}}
> >
Refresh {photoQuery.isLoading
? "Loading"
: photoQuery.hasNextPage
? "Load next page"
: "No more photos"}
</button> </button>
{refreshQuery.data ? JSON.stringify(refreshQuery.data) : "No data"}
{refreshQuery.error ? JSON.stringify(refreshQuery.error) : "No Error"}
<Lightbox photoData={photoData}>{...children}</Lightbox>
</> </>
); );
} }

View File

@@ -17,7 +17,7 @@ export default function PostHeader({
return ( return (
<> <>
<h1 className="mb-2">{metadata.title}</h1> <h1 className="mb-2">{metadata.title}</h1>
<div className="mb-4 text-primary-content/80"> <div className="mb-2">
<time dateTime={metadata.date}>{metadata.date}</time> <time dateTime={metadata.date}>{metadata.date}</time>
</div> </div>
<div className="mb-2 flex gap-2"> <div className="mb-2 flex gap-2">

View File

@@ -1,5 +1,5 @@
import { handlers } from "@/server/auth";
import { NextRequest } from "next/server"; import { NextRequest } from "next/server";
import { handlers } from "@/server/auth";
const reqWithTrustedOrigin = (req: NextRequest): NextRequest => { const reqWithTrustedOrigin = (req: NextRequest): NextRequest => {
const proto = req.headers.get("x-forwarded-proto"); const proto = req.headers.get("x-forwarded-proto");

View File

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

View File

@@ -2,18 +2,19 @@ import { z } from "zod";
import { import {
createTRPCRouter, createTRPCRouter,
protectedProcedure,
publicProcedure, publicProcedure,
protectedProcedure,
} from "@/server/api/trpc"; } from "@/server/api/trpc";
import { list } from "./list"; import { list } from "./list";
import { update } from "./update"; import { update } from "./update";
import { modify } from "./modify";
export const photosRouter = createTRPCRouter({ export const photosRouter = createTRPCRouter({
list: publicProcedure list: publicProcedure
.input( .input(
z z
.object({ .object({
limit: z.number().nonnegative().default(2), limit: z.number().nonnegative().default(1),
cursor: z.number().nonnegative().default(0), cursor: z.number().nonnegative().default(0),
}) })
.optional() .optional()
@@ -37,4 +38,21 @@ export const photosRouter = createTRPCRouter({
}; };
}), }),
update: publicProcedure.query(update), update: publicProcedure.query(update),
modify: protectedProcedure
.input(
z.object({
title: z
.string()
.min(3, "Title should be over 3 characters")
.max(128, "Title cannot be over 128 characters"),
src: z.string(),
description: z.object({
type: z.string(),
content: z.array(z.unknown()),
}),
}),
)
.mutation(async ({ input }) => {
await modify(input);
}),
}); });

View File

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

View File

@@ -1,5 +1,6 @@
/** biome-ignore-all lint/correctness/noUnknownProperty: Biome doesn't understand DaisyUI properties */ /** biome-ignore-all lint/correctness/noUnknownProperty: Biome doesn't understand DaisyUI properties */
@import "tailwindcss"; @import "tailwindcss";
@plugin "@tailwindcss/typography"; @plugin "@tailwindcss/typography";
@plugin "daisyui"; @plugin "daisyui";
@@ -10,18 +11,18 @@
prefersdark: false; prefersdark: false;
color-scheme: "light"; color-scheme: "light";
--color-base-100: oklch(97.02% 0.000 0); --color-base-100: oklch(97.02% 0 0);
--color-base-200: oklch(95.20% 0.007 268.55); --color-base-200: oklch(95.20% 0.007 268.55);
--color-base-300: oklch(88.75% 0.015 264.49); --color-base-300: oklch(88.75% 0.015 264.49);
--color-base-content: oklch(23.93% 0.000 0); --color-base-content: oklch(23.93% 0 0);
--color-primary: oklch(64.21% 0.086 228.32); --color-primary: oklch(64.21% 0.086 228.32);
--color-primary-content: oklch(100% 0 0); --color-primary-content: oklch(100% 0 0);
--color-secondary: oklch(67.53% 0.129 27.41); --color-secondary: oklch(67.53% 0.129 27.41);
--color-secondary-content: oklch(23.93% 0.000 0); --color-secondary-content: oklch(23.93% 0 0);
--color-accent: oklch(50.93% 0.091 287.46); --color-accent: oklch(50.93% 0.091 287.46);
--color-accent-content: oklch(100% 0 0); --color-accent-content: oklch(100% 0 0);
--color-neutral: oklch(45.68% 0.000 0); --color-neutral: oklch(45.68% 0 0);
--color-neutral-content: oklch(100% 0 0); --color-neutral-content: oklch(100% 0 0);
--color-info: oklch(49.47% 0.122 243.83); --color-info: oklch(49.47% 0.122 243.83);
@@ -45,12 +46,11 @@
--noise: 0; --noise: 0;
} }
@plugin "daisyui/theme" { @plugin "daisyui/theme" {
/* Nicked from the vscode soft theme https://github.com/dracula/visual-studio-code/blob/master/src/dracula.yml */ /* Nicked from the vscode soft theme https://github.com/dracula/visual-studio-code/blob/master/src/dracula.yml */
name: "dracula-soft"; name: "dracula-soft";
default: false; default: false;
prefersdark: false; prefersdark: true;
color-scheme: "dark"; color-scheme: "dark";
/* --color-base-50: oklch(34.02% 0.027 276.05); */ /* --color-base-50: oklch(34.02% 0.027 276.05); */

View File

@@ -6,12 +6,10 @@ import { createTRPCReact } from "@trpc/react-query";
import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server"; import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server";
import { useState } from "react"; import { useState } from "react";
import SuperJSON from "superjson"; import SuperJSON from "superjson";
import type { AppRouter } from "@/server/api/root"; import type { AppRouter } from "@/server/api/root";
import { createQueryClient } from "./query-client"; import { createQueryClient } from "./query-client";
import { env } from "@/env";
let clientQueryClientSingleton: QueryClient | undefined = undefined; let clientQueryClientSingleton: QueryClient | undefined;
const getQueryClient = () => { const getQueryClient = () => {
if (typeof window === "undefined") { if (typeof window === "undefined") {
// Server: always make a new query client // Server: always make a new query client