Add a manage page with tabs
This commit is contained in:
278
src/app/(root)/manage/_components/photo-tab.tsx
Normal file
278
src/app/(root)/manage/_components/photo-tab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user