Set up photo editor and clean up styling
This commit is contained in:
10
drizzle/0001_familiar_gambit.sql
Normal file
10
drizzle/0001_familiar_gambit.sql
Normal 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
|
||||
);
|
||||
187
drizzle/meta/0001_snapshot.json
Normal file
187
drizzle/meta/0001_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,13 @@
|
||||
"when": 1746743224792,
|
||||
"tag": "0000_harsh_toad_men",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "6",
|
||||
"when": 1756863378002,
|
||||
"tag": "0001_familiar_gambit",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
8588
package-lock.json
generated
Normal file
8588
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -61,6 +61,7 @@
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.0.6",
|
||||
"@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",
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { useEditor, EditorContent, useEditorState } from "@tiptap/react";
|
||||
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 Bold from "@tiptap/extension-bold";
|
||||
import Italic from "@tiptap/extension-italic";
|
||||
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";
|
||||
|
||||
export default function Tiptap({
|
||||
onChange,
|
||||
initContent,
|
||||
}: {
|
||||
onChange: (args: unknown) => void;
|
||||
initContent?: Content;
|
||||
}) {
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
@@ -35,6 +42,7 @@ export default function Tiptap({
|
||||
class: "py-1 px-2",
|
||||
},
|
||||
},
|
||||
content: initContent,
|
||||
});
|
||||
|
||||
const editorState = useEditorState({
|
||||
@@ -53,7 +61,6 @@ export default function Tiptap({
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
console.log(editorState?.currentContent);
|
||||
onChange(editorState?.currentContent);
|
||||
}, [editorState?.currentContent, onChange]);
|
||||
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
"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 type React from "react";
|
||||
import { useState } from "react";
|
||||
import ImageSvg from "./file-svg";
|
||||
import DirSvg from "./dir-svg";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/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";
|
||||
|
||||
// - TODO - Pull this from trpc
|
||||
const FormSchema = z.object({
|
||||
title: z
|
||||
.string()
|
||||
@@ -83,24 +84,35 @@ function renderTree(node: DirectoryTree, pathSoFar = ""): Item[] {
|
||||
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) => {
|
||||
const selectedLeaf =
|
||||
`https://fly.storage.tigris.dev/joemonk-photos/${leaf.fullPath}` ===
|
||||
selectedImage?.src;
|
||||
if (leaf.children?.length) {
|
||||
return (
|
||||
<li>
|
||||
<li key={leaf.fullPath}>
|
||||
<details open>
|
||||
<summary>
|
||||
<DirSvg />
|
||||
{leaf.name}
|
||||
</summary>
|
||||
<ul>{RenderLeaf(leaf.children, selectImageTab)}</ul>
|
||||
<ul>{RenderLeaf(leaf.children, selectImageTab, selectedImage)}</ul>
|
||||
</details>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<li key={leaf.fullPath}>
|
||||
<button type="button" onClick={() => selectImageTab(leaf.fullPath)}>
|
||||
<button
|
||||
type="button"
|
||||
className={selectedLeaf ? "active" : ""}
|
||||
onClick={() => selectImageTab(leaf.fullPath)}
|
||||
>
|
||||
<ImageSvg />
|
||||
{leaf.name}
|
||||
</button>
|
||||
@@ -111,9 +123,16 @@ function RenderLeaf(leaf: Item[], selectImageTab: (path: string) => void) {
|
||||
|
||||
export function PhotoTab(): React.JSX.Element {
|
||||
const [selectedImage, setSelectedImage] = useState<PhotoData>();
|
||||
const query = api.photos.list.useQuery(undefined, {
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
const listQuery = api.photos.list.useInfiniteQuery(
|
||||
{},
|
||||
{
|
||||
getNextPageParam: (lastPage) => lastPage.next,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
},
|
||||
);
|
||||
const modifyMutate = api.photos.modify.useMutation();
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -125,18 +144,21 @@ export function PhotoTab(): React.JSX.Element {
|
||||
mode: "onSubmit",
|
||||
});
|
||||
|
||||
if (query.isLoading) {
|
||||
if (listQuery.isLoading) {
|
||||
return <p>Loading</p>;
|
||||
}
|
||||
if (query.error) {
|
||||
return <p>{query.error.message}</p>;
|
||||
if (listQuery.error) {
|
||||
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) {
|
||||
return <p>No Images</p>;
|
||||
}
|
||||
if (listQuery.hasNextPage) {
|
||||
listQuery.fetchNextPage();
|
||||
}
|
||||
|
||||
const selectImageTab = (path: string) => {
|
||||
const selectImage = (path: string) => {
|
||||
const img = images.find(
|
||||
(img) =>
|
||||
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 onSubmit = (data: IFormInput) => {
|
||||
console.log(data);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex w-full gap-2">
|
||||
<ul className="menu menu-xs bg-base-200 box w-1/4">
|
||||
{RenderLeaf(renderedTree, selectImageTab)}
|
||||
<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">
|
||||
{RenderLeaf(renderedTree, selectImage, selectedImage)}
|
||||
</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 ? (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<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>
|
||||
<span>{`Title ${errors.title ? ` - ${errors.title.message}` : ""}`}</span>
|
||||
<input
|
||||
{...register("title")}
|
||||
type="text"
|
||||
@@ -263,13 +289,30 @@ export function PhotoTab(): React.JSX.Element {
|
||||
control={control}
|
||||
name="description"
|
||||
render={({ field: { onChange } }) => (
|
||||
<Tiptap onChange={onChange} />
|
||||
<Tiptap
|
||||
onChange={onChange}
|
||||
initContent={selectedImage.description}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<button className="button" type="submit">
|
||||
Submit
|
||||
</button>
|
||||
<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>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { PhotoTab } from "./_components/photo-tab";
|
||||
export default async function Photos(): Promise<React.JSX.Element> {
|
||||
return (
|
||||
<div className="mx-auto">
|
||||
<div className="tabs tabs-lift">
|
||||
<div role="tablist" className="tabs tabs-lift">
|
||||
<input
|
||||
type="radio"
|
||||
name="admin_tabs"
|
||||
|
||||
@@ -53,7 +53,7 @@ export default async function Posts(): Promise<React.JSX.Element> {
|
||||
<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 gap-2 pb-2">
|
||||
<div className="flex flex-row flex-wrap gap-2 pb-2">
|
||||
{post.metadata.tags.map((tag) => {
|
||||
return (
|
||||
<div key={`${post.link}_${tag}`}>
|
||||
@@ -64,10 +64,7 @@ export default async function Posts(): Promise<React.JSX.Element> {
|
||||
</div>
|
||||
<p>{post.metadata.blurb}</p>
|
||||
<div className="card-actions justify-end pt-2">
|
||||
<Link
|
||||
className="btn btn-primary hover:bg-primary/10"
|
||||
href={post.link}
|
||||
>
|
||||
<Link className="btn btn-primary" href={post.link}>
|
||||
Read
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
73
src/app/_components/lightbox-image.tsx
Normal file
73
src/app/_components/lightbox-image.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -2,16 +2,11 @@
|
||||
import Image from "next/image";
|
||||
import type React from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
import YARL, {
|
||||
isImageFitCover,
|
||||
isImageSlide,
|
||||
useLightboxProps,
|
||||
useLightboxState,
|
||||
} from "yet-another-react-lightbox";
|
||||
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";
|
||||
@@ -19,66 +14,8 @@ import { api, type RouterOutputs } from "@/trpc/react";
|
||||
|
||||
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({
|
||||
photoData: photoData,
|
||||
photoData,
|
||||
children,
|
||||
}: {
|
||||
photoData: PhotoData[];
|
||||
@@ -88,17 +25,18 @@ export function Lightbox({
|
||||
|
||||
return (
|
||||
<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) => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={"lightbox_img"}
|
||||
key={`lightbox_${image.key}`}
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
setActive(index);
|
||||
}}
|
||||
>
|
||||
<div className="relative">{image}</div>
|
||||
{image}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -110,9 +48,9 @@ export function Lightbox({
|
||||
slides={photoData}
|
||||
render={{
|
||||
// @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
|
||||
thumbnail: NextJsImage,
|
||||
thumbnail: LightboxImage,
|
||||
}}
|
||||
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: {
|
||||
photoData: PhotoData[];
|
||||
children: React.JSX.Element[];
|
||||
}): React.JSX.Element {
|
||||
//const [photoData, setImageData] = useState(props.photoData);
|
||||
const [photoData] = useState(props.photoData);
|
||||
const photoQuery = api.photos.list.useInfiniteQuery(
|
||||
{
|
||||
limit: 1,
|
||||
@@ -152,62 +80,42 @@ export default function FilteredLightbox(props: {
|
||||
},
|
||||
);
|
||||
|
||||
const refreshQuery = api.photos.update.useQuery(undefined, {
|
||||
enabled: false,
|
||||
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();
|
||||
function handleNextPage(): void {
|
||||
if (!photoQuery.isLoading) {
|
||||
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="h-60 w-80 object-contain"
|
||||
sizes="100vw"
|
||||
loading="lazy"
|
||||
width={data.width}
|
||||
height={data.height}
|
||||
blurDataURL={data.blur}
|
||||
placeholder="blur"
|
||||
/>
|
||||
))
|
||||
.filter((data) => !!data);
|
||||
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"
|
||||
/>
|
||||
));
|
||||
|
||||
refreshQuery.error ? console.log(refreshQuery.error) : null;
|
||||
return (
|
||||
<>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div>
|
||||
<label htmlFor="src">Src:</label>
|
||||
<input id="src" type="text" />
|
||||
</div>
|
||||
<button type="submit">Submit</button>
|
||||
</form>
|
||||
<Lightbox photoData={photoData}>{...children}</Lightbox>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
console.log("refetch");
|
||||
void refreshQuery.refetch();
|
||||
}}
|
||||
className="btn btn-primary mx-auto p-4 mt-8 flex"
|
||||
onClick={handleNextPage}
|
||||
>
|
||||
Refresh
|
||||
{photoQuery.isLoading
|
||||
? "Loading"
|
||||
: photoQuery.hasNextPage
|
||||
? "Load next page"
|
||||
: "No more photos"}
|
||||
</button>
|
||||
{refreshQuery.data ? JSON.stringify(refreshQuery.data) : "No data"}
|
||||
{refreshQuery.error ? JSON.stringify(refreshQuery.error) : "No Error"}
|
||||
<Lightbox photoData={photoData}>{...children}</Lightbox>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ export default function PostHeader({
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
</div>
|
||||
<div className="mb-2 flex gap-2">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { handlers } from "@/server/auth";
|
||||
import { NextRequest } from "next/server";
|
||||
import { handlers } from "@/server/auth";
|
||||
|
||||
const reqWithTrustedOrigin = (req: NextRequest): NextRequest => {
|
||||
const proto = req.headers.get("x-forwarded-proto");
|
||||
|
||||
17
src/server/api/routers/photos/modify/index.ts
Normal file
17
src/server/api/routers/photos/modify/index.ts
Normal 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));
|
||||
}
|
||||
@@ -2,18 +2,19 @@ import { z } from "zod";
|
||||
|
||||
import {
|
||||
createTRPCRouter,
|
||||
protectedProcedure,
|
||||
publicProcedure,
|
||||
protectedProcedure,
|
||||
} from "@/server/api/trpc";
|
||||
import { list } from "./list";
|
||||
import { update } from "./update";
|
||||
import { modify } from "./modify";
|
||||
|
||||
export const photosRouter = createTRPCRouter({
|
||||
list: publicProcedure
|
||||
.input(
|
||||
z
|
||||
.object({
|
||||
limit: z.number().nonnegative().default(2),
|
||||
limit: z.number().nonnegative().default(1),
|
||||
cursor: z.number().nonnegative().default(0),
|
||||
})
|
||||
.optional()
|
||||
@@ -37,4 +38,21 @@ export const photosRouter = createTRPCRouter({
|
||||
};
|
||||
}),
|
||||
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);
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -10,7 +10,7 @@ export const photos = sqliteTable("photo", (d) => ({
|
||||
|
||||
camera: d.text({ length: 128 }),
|
||||
title: d.text({ length: 128 }),
|
||||
description: d.text({ length: 1024 }),
|
||||
description: d.blob({ mode: "json" }),
|
||||
exposureBiasValue: d.integer({ mode: "number" }),
|
||||
fNumber: d.real(),
|
||||
isoSpeedRatings: d.integer({ mode: "number" }),
|
||||
|
||||
@@ -1,56 +1,56 @@
|
||||
/** biome-ignore-all lint/correctness/noUnknownProperty: Biome doesn't understand DaisyUI properties */
|
||||
@import "tailwindcss";
|
||||
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
@plugin "daisyui";
|
||||
|
||||
@plugin "daisyui/theme" {
|
||||
name: "alucard";
|
||||
default: true;
|
||||
prefersdark: false;
|
||||
color-scheme: "light";
|
||||
name: "alucard";
|
||||
default: true;
|
||||
prefersdark: false;
|
||||
color-scheme: "light";
|
||||
|
||||
--color-base-100: oklch(97.02% 0.000 0);
|
||||
--color-base-200: oklch(95.20% 0.007 268.55);
|
||||
--color-base-300: oklch(88.75% 0.015 264.49);
|
||||
--color-base-content: oklch(23.93% 0.000 0);
|
||||
--color-base-100: oklch(97.02% 0 0);
|
||||
--color-base-200: oklch(95.20% 0.007 268.55);
|
||||
--color-base-300: oklch(88.75% 0.015 264.49);
|
||||
--color-base-content: oklch(23.93% 0 0);
|
||||
|
||||
--color-primary: oklch(64.21% 0.086 228.32);
|
||||
--color-primary-content: oklch(100% 0 0);
|
||||
--color-secondary: oklch(67.53% 0.129 27.41);
|
||||
--color-secondary-content: oklch(23.93% 0.000 0);
|
||||
--color-accent: oklch(50.93% 0.091 287.46);
|
||||
--color-accent-content: oklch(100% 0 0);
|
||||
--color-neutral: oklch(45.68% 0.000 0);
|
||||
--color-neutral-content: oklch(100% 0 0);
|
||||
--color-primary: oklch(64.21% 0.086 228.32);
|
||||
--color-primary-content: oklch(100% 0 0);
|
||||
--color-secondary: oklch(67.53% 0.129 27.41);
|
||||
--color-secondary-content: oklch(23.93% 0 0);
|
||||
--color-accent: oklch(50.93% 0.091 287.46);
|
||||
--color-accent-content: oklch(100% 0 0);
|
||||
--color-neutral: oklch(45.68% 0 0);
|
||||
--color-neutral-content: oklch(100% 0 0);
|
||||
|
||||
--color-info: oklch(49.47% 0.122 243.83);
|
||||
--color-info-content: oklch(100% 0 0);
|
||||
--color-success: oklch(63.12% 0.124 141.91);
|
||||
--color-success-content: oklch(0% 0 0);
|
||||
--color-warning: oklch(76.96% 0.156 99.76);
|
||||
--color-warning-content: oklch(0% 0 0);
|
||||
--color-error: oklch(52.56% 0.199 5.45);
|
||||
--color-error-content: oklch(100% 0 0);
|
||||
--color-info: oklch(49.47% 0.122 243.83);
|
||||
--color-info-content: oklch(100% 0 0);
|
||||
--color-success: oklch(63.12% 0.124 141.91);
|
||||
--color-success-content: oklch(0% 0 0);
|
||||
--color-warning: oklch(76.96% 0.156 99.76);
|
||||
--color-warning-content: oklch(0% 0 0);
|
||||
--color-error: oklch(52.56% 0.199 5.45);
|
||||
--color-error-content: oklch(100% 0 0);
|
||||
|
||||
--radius-selector: 1rem;
|
||||
--radius-field: 0.5rem;
|
||||
--radius-box: 1rem;
|
||||
--radius-selector: 1rem;
|
||||
--radius-field: 0.5rem;
|
||||
--radius-box: 1rem;
|
||||
|
||||
--size-selector: 0.25rem;
|
||||
--size-field: 0.25rem;
|
||||
--border: 1px;
|
||||
--size-selector: 0.25rem;
|
||||
--size-field: 0.25rem;
|
||||
--border: 1px;
|
||||
|
||||
--depth: 1;
|
||||
--noise: 0;
|
||||
--depth: 1;
|
||||
--noise: 0;
|
||||
}
|
||||
|
||||
|
||||
@plugin "daisyui/theme" {
|
||||
/* Nicked from the vscode soft theme https://github.com/dracula/visual-studio-code/blob/master/src/dracula.yml */
|
||||
name: "dracula-soft";
|
||||
default: false;
|
||||
prefersdark: false;
|
||||
prefersdark: true;
|
||||
color-scheme: "dark";
|
||||
|
||||
/* --color-base-50: oklch(34.02% 0.027 276.05); */
|
||||
|
||||
@@ -6,12 +6,10 @@ import { createTRPCReact } from "@trpc/react-query";
|
||||
import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server";
|
||||
import { useState } from "react";
|
||||
import SuperJSON from "superjson";
|
||||
|
||||
import type { AppRouter } from "@/server/api/root";
|
||||
import { createQueryClient } from "./query-client";
|
||||
import { env } from "@/env";
|
||||
|
||||
let clientQueryClientSingleton: QueryClient | undefined = undefined;
|
||||
let clientQueryClientSingleton: QueryClient | undefined;
|
||||
const getQueryClient = () => {
|
||||
if (typeof window === "undefined") {
|
||||
// Server: always make a new query client
|
||||
|
||||
Reference in New Issue
Block a user