Add a manage page with tabs

This commit is contained in:
2025-08-23 17:39:54 +01:00
parent 8806f72f2a
commit 42caeb8834
38 changed files with 798 additions and 110 deletions

View File

@@ -0,0 +1,278 @@
"use client";
import type { PhotoData } from "@/server/api/routers/photos/list";
import { api } from "@/trpc/react";
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 Tiptap from "./photo-editor";
const FormSchema = z.object({
title: z
.string()
.min(3, "Title should be over 3 characters")
.max(128, "Title cannot be over 128 characters"),
description: z.object({
type: z.string(),
content: z.array(z.unknown()),
}),
});
type IFormInput = z.infer<typeof FormSchema>;
interface DirectoryTree {
[key: string]: DirectoryTree;
}
function buildDirectoryTree(filePaths: string[]): DirectoryTree {
const root: DirectoryTree = {};
filePaths.forEach((path) => {
const parts = path.split("/").filter((p) => p.length > 0);
let current = root;
// Traverse or create nodes for each part of the path
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
if (part) {
if (!current[part]) {
current[part] = {};
}
current = current[part];
}
}
});
return root;
}
type Item = {
type: "directory" | "file";
name: string;
fullPath: string;
children?: Item[];
};
function renderTree(node: DirectoryTree, pathSoFar = ""): Item[] {
const entries = Object.entries(node);
const items: Item[] = [];
for (const [name, children] of entries) {
const fullPath = pathSoFar ? `${pathSoFar}/${name}` : name;
const isLeaf = Object.keys(children).length === 0;
if (isLeaf) {
// It's a file
items.push({ type: "file", name, fullPath });
} else {
// It's a directory
items.push({
type: "directory",
name,
fullPath,
children: renderTree(children, fullPath),
});
}
}
return items;
}
function RenderLeaf(leaf: Item[], selectImageTab: (path: string) => void) {
return leaf.map((leaf) => {
if (leaf.children?.length) {
return (
<li>
<details open>
<summary>
<DirSvg />
{leaf.name}
</summary>
<ul>{RenderLeaf(leaf.children, selectImageTab)}</ul>
</details>
</li>
);
}
return (
<li key={leaf.fullPath}>
<button type="button" onClick={() => selectImageTab(leaf.fullPath)}>
<ImageSvg />
{leaf.name}
</button>
</li>
);
});
}
export function PhotoTab(): React.JSX.Element {
const [selectedImage, setSelectedImage] = useState<PhotoData>();
const query = api.photos.list.useQuery(undefined, {
refetchOnWindowFocus: false,
});
const {
register,
handleSubmit,
control,
formState: { errors },
} = useForm<IFormInput>({
resolver: zodResolver(FormSchema),
mode: "onSubmit",
});
if (query.isLoading) {
return <p>Loading</p>;
}
if (query.error) {
return <p>{query.error.message}</p>;
}
const images = query.data?.data;
if (!images || images?.length === 0) {
return <p>No Images</p>;
}
const selectImageTab = (path: string) => {
const img = images.find(
(img) =>
img.src === `https://fly.storage.tigris.dev/joemonk-photos/${path}`,
);
setSelectedImage(img);
};
const tree = buildDirectoryTree(
images.map((img) =>
img.src.substring(
"https://fly.storage.tigris.dev/joemonk-photos/".length,
),
),
);
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)}
</ul>
<div className="w-3/4 box border border-base-300 p-2">
{selectedImage?.src ? (
<form onSubmit={handleSubmit(onSubmit)}>
<label
className={`floating-label input text-lg mb-2 w-full ${errors.title ? "input-error" : null}`}
>
<span>{`Title ${errors.title ? " - " + errors.title.message : ""}`}</span>
<input
{...register("title")}
type="text"
placeholder="Title"
defaultValue={selectedImage?.title}
/>
</label>
<Image
src={selectedImage.src}
title={selectedImage?.title}
alt={selectedImage?.title ?? "Image to modify data for"}
width={selectedImage.width}
height={selectedImage.height}
blurDataURL={selectedImage.blur}
placeholder="blur"
/>
<div className="mt-2 grid grid-cols-3">
{[
{
title: "F-Stop",
value: selectedImage.exif.fNumber?.toString(),
},
{
title: "ISO",
value: selectedImage.exif.isoSpeedRatings?.toString(),
},
{
title: "Exposure",
value: selectedImage.exif.exposureBiasValue?.toString(),
},
].map((setting) => {
return (
<div key={setting.title} className="w-full border">
<span className="px-2 w-20 inline-block">
{setting.title}
</span>
<span className="px-2">{setting.value}</span>
</div>
);
})}
</div>
<div className="mt-2 grid grid-cols-3">
{[
{
title: "Taken",
value: selectedImage.exif.takenAt?.toLocaleDateString(),
},
{
title: "Lens",
value: selectedImage.exif.LensModel,
},
{
title: "Camera",
value: selectedImage.camera,
},
].map((setting) => {
return (
<div key={setting.title} className="w-full border">
<span className="px-2 w-20 inline-block">
{setting.title}
</span>
<span className="px-2">{setting.value}</span>
</div>
);
})}
</div>
<div className="mt-2 grid grid-cols-2">
{[
{
title: "Height",
value: selectedImage.height.toString(),
},
{
title: "Width",
value: selectedImage.width.toString(),
},
].map((setting) => {
return (
<div key={setting.title} className="w-full border">
<span className="px-2 w-20 inline-block">
{setting.title}
</span>
<span className="px-2">{setting.value}</span>
</div>
);
})}
</div>
<div className="mt-2 px-2 pb-2 border">
<span>Description</span>
<Controller
control={control}
name="description"
render={({ field: { onChange } }) => (
<Tiptap onChange={onChange} />
)}
/>
</div>
<button className="button" type="submit">
Submit
</button>
</form>
) : null}
</div>
</div>
);
}