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,
|
"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
8588
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
refetchOnWindowFocus: false,
|
{},
|
||||||
});
|
{
|
||||||
|
getNextPageParam: (lastPage) => lastPage.next,
|
||||||
|
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
|
||||||
</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>
|
</form>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
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 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,
|
void photoQuery.fetchNextPage();
|
||||||
});
|
}
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
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 {
|
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);
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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" }),
|
||||||
|
|||||||
@@ -1,56 +1,56 @@
|
|||||||
/** 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";
|
||||||
|
|
||||||
@plugin "daisyui/theme" {
|
@plugin "daisyui/theme" {
|
||||||
name: "alucard";
|
name: "alucard";
|
||||||
default: true;
|
default: true;
|
||||||
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);
|
||||||
--color-info-content: oklch(100% 0 0);
|
--color-info-content: oklch(100% 0 0);
|
||||||
--color-success: oklch(63.12% 0.124 141.91);
|
--color-success: oklch(63.12% 0.124 141.91);
|
||||||
--color-success-content: oklch(0% 0 0);
|
--color-success-content: oklch(0% 0 0);
|
||||||
--color-warning: oklch(76.96% 0.156 99.76);
|
--color-warning: oklch(76.96% 0.156 99.76);
|
||||||
--color-warning-content: oklch(0% 0 0);
|
--color-warning-content: oklch(0% 0 0);
|
||||||
--color-error: oklch(52.56% 0.199 5.45);
|
--color-error: oklch(52.56% 0.199 5.45);
|
||||||
--color-error-content: oklch(100% 0 0);
|
--color-error-content: oklch(100% 0 0);
|
||||||
|
|
||||||
--radius-selector: 1rem;
|
--radius-selector: 1rem;
|
||||||
--radius-field: 0.5rem;
|
--radius-field: 0.5rem;
|
||||||
--radius-box: 1rem;
|
--radius-box: 1rem;
|
||||||
|
|
||||||
--size-selector: 0.25rem;
|
--size-selector: 0.25rem;
|
||||||
--size-field: 0.25rem;
|
--size-field: 0.25rem;
|
||||||
--border: 1px;
|
--border: 1px;
|
||||||
|
|
||||||
--depth: 1;
|
--depth: 1;
|
||||||
--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); */
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user