Files
threetwo/src/client/components/Search/Search.tsx
2026-04-15 13:30:28 -04:00

493 lines
18 KiB
TypeScript

import React, { ReactElement, useState } from "react";
import { isNil, isEmpty, isUndefined } from "lodash";
import { detectIssueTypes } from "../../shared/utils/tradepaperback.utils";
import { Form, Field } from "react-final-form";
import Card from "../shared/Carda";
import ellipsize from "ellipsize";
import { convert } from "html-to-text";
import { useTranslation } from "react-i18next";
import "../../shared/utils/i18n.util";
import PopoverButton from "../shared/PopoverButton";
import dayjs from "dayjs";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
COMICVINE_SERVICE_URI,
LIBRARY_SERVICE_BASE_URI,
} from "../../constants/endpoints";
import axios from "axios";
import type { SearchPageProps, ComicVineSearchResult } from "../../types";
interface ComicData {
id: number;
api_detail_url: string;
image: { small_url: string; thumb_url?: string };
cover_date?: string;
issue_number?: string;
name?: string;
description?: string;
volume?: { name: string; api_detail_url: string };
start_year?: string;
count_of_issues?: number;
publisher?: { name: string };
resource_type?: string;
}
export const Search = ({}: SearchPageProps): ReactElement => {
const queryClient = useQueryClient();
const formData = {
search: "",
};
const [comicVineMetadata, setComicVineMetadata] = useState<{
sourceName?: string;
comicData?: ComicData;
}>({});
const [selectedResource, setSelectedResource] = useState("volume");
const { t } = useTranslation();
const handleResourceChange = (value: string) => {
setSelectedResource(value);
};
const {
mutate,
data: comicVineSearchResults,
isPending,
isSuccess,
} = useMutation({
mutationFn: async (data: { search: string; resource: string }) => {
const { search, resource } = data;
return await axios({
url: `${COMICVINE_SERVICE_URI}/search`,
method: "GET",
params: {
api_key: "a5fa0663683df8145a85d694b5da4b87e1c92c69",
query: search,
format: "json",
limit: "10",
offset: "0",
field_list:
"id,name,deck,api_detail_url,image,description,volume,cover_date,start_year,count_of_issues,publisher,issue_number",
resources: resource,
},
});
},
});
// add to library
const { data: additionResult, mutate: addToWantedList } = useMutation({
mutationFn: async ({
source,
comicObject,
markEntireVolumeWanted,
resourceType,
}: {
source: string;
comicObject: any;
markEntireVolumeWanted: boolean;
resourceType: string;
}) => {
let volumeInformation = {};
let issues = [];
switch (resourceType) {
case "issue":
const { id, api_detail_url, image, cover_date, issue_number } =
comicObject;
// Add issue metadata
issues.push({
id,
url: api_detail_url,
image,
coverDate: cover_date,
issueNumber: issue_number,
});
// Get volume metadata from CV
const response = await axios({
url: `${COMICVINE_SERVICE_URI}/getVolumes`,
method: "POST",
data: {
volumeURI: comicObject.volume.api_detail_url,
fieldList:
"id,name,deck,api_detail_url,image,description,start_year,year,count_of_issues,publisher,first_issue,last_issue",
},
});
// set volume metadata key
volumeInformation = response.data?.results;
break;
case "volume":
const {
id: volumeId,
api_detail_url: apiUrl,
image: volumeImage,
name,
publisher,
} = comicObject;
volumeInformation = {
id: volumeId,
url: apiUrl,
image: volumeImage,
name,
publisher,
};
break;
default:
break;
}
// Add to wanted list
return await axios({
url: `${LIBRARY_SERVICE_BASE_URI}/rawImportToDb`,
method: "POST",
data: {
importType: "new",
payload: {
importStatus: {
isImported: false, // wanted, but not acquired yet.
tagged: false,
matchedResult: {
score: "0",
},
},
wanted: {
source,
markEntireVolumeWanted,
issues,
volume: volumeInformation,
},
sourcedMetadata: { comicvine: volumeInformation },
},
},
});
},
onSuccess: () => {
// Invalidate and refetch wanted comics queries
queryClient.invalidateQueries({ queryKey: ["wantedComics"] });
},
});
const addToLibrary = (sourceName: string, comicData: ComicData) =>
setComicVineMetadata({ sourceName, comicData });
const createDescriptionMarkup = (html: string) => {
return { __html: html };
};
const onSubmit = async (values: { search: string }) => {
const formData = { ...values, resource: selectedResource };
try {
mutate(formData);
} catch (error) {
// Handle error
}
};
return (
<div>
<section>
<header className="bg-slate-200 dark:bg-slate-500">
<div className="mx-auto max-w-screen-xl px-4 py-2 sm:px-6 sm:py-8 lg:px-8 lg:py-4">
<div className="sm:flex sm:items-center sm:justify-between">
<div className="text-center sm:text-left">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white sm:text-3xl">
Search
</h1>
<p className="mt-1.5 text-sm text-gray-500 dark:text-white">
Browse your comic book collection.
</p>
</div>
</div>
</div>
</header>
<div className="mx-auto max-w-screen-sm px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
<Form
onSubmit={onSubmit}
initialValues={{
...formData,
}}
render={({ handleSubmit, form, submitting, pristine, values }) => (
<form onSubmit={handleSubmit}>
<div className="flex flex-row w-full">
<div className="flex flex-row bg-slate-300 dark:bg-slate-500 rounded-l-lg p-2 min-w-full">
<div className="w-10 text-gray-400">
<i className="icon-[solar--magnifer-bold-duotone] h-7 w-7" />
</div>
<Field name="search">
{({ input, meta }) => {
return (
<input
{...input}
className="bg-slate-300 dark:bg-slate-500 outline-none text-lg text-gray-700 w-full"
placeholder="Type an issue/volume name"
/>
);
}}
</Field>
</div>
<button
className="sm:mt-0 rounded-r-lg border border-green-400 dark:border-green-200 bg-green-200 px-3 py-1 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
type="submit"
>
Search
</button>
</div>
{/* resource type selection: volume, issue etc. */}
<div className="flex flex-row gap-3 mt-4">
<Field name="resource" type="radio" value="volume">
{({ input: volumesInput, meta }) => (
<div className="w-fit rounded-xl">
<div>
<input
{...volumesInput}
type="radio"
id="volume"
checked={selectedResource === "volume"}
onChange={() => handleResourceChange("volume")}
className="peer hidden"
/>
<label
htmlFor="volume"
className="block cursor-pointer select-none rounded-xl p-2 text-center peer-checked:bg-blue-500 peer-checked:font-bold peer-checked:text-white"
>
Volumes
</label>
</div>
</div>
)}
</Field>
<Field name="resource" type="radio" value="issue">
{({ input: issuesInput, meta }) => (
<div className="w-fit rounded-xl">
<div>
<input
{...issuesInput}
type="radio"
id="issue"
checked={selectedResource === "issue"}
onChange={() => handleResourceChange("issue")}
className="peer hidden"
/>
<label
htmlFor="issue"
className="block cursor-pointer select-none rounded-xl p-2 text-center peer-checked:bg-blue-500 peer-checked:font-bold peer-checked:text-white"
>
Issues
</label>
</div>
</div>
)}
</Field>
</div>
</form>
)}
/>
</div>
{isPending && (
<div className="max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
Loading results...
</div>
)}
{!isEmpty(comicVineSearchResults?.data?.results) ? (
<div className="mx-auto max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
{comicVineSearchResults?.data?.results?.map((result: ComicData) => {
return result.resource_type === "issue" ? (
<div
key={result.id}
className="mb-5 dark:bg-slate-400 p-4 rounded-lg"
>
<div className="flex flex-row">
<div className="mr-5 min-w-[80px] max-w-[13%]">
<Card
key={result.id}
orientation={"cover-only"}
imageUrl={result.image.small_url}
hasDetails={false}
/>
</div>
<div className="w-3/4">
<div className="text-xl">
{!isEmpty(result.volume?.name) ? (
result.volume?.name
) : (
<span className="is-size-3">No Name</span>
)}
</div>
{result.cover_date && (
<p>
<span className="tag is-light">Cover date</span>
{dayjs(result.cover_date).format("MMM D, YYYY")}
</p>
)}
<p className="tag is-warning">{result.id}</p>
<a href={result.api_detail_url}>
{result.api_detail_url}
</a>
<p className="text-sm">
{result.description ? ellipsize(
convert(result.description, {
baseElements: {
selectors: ["p", "div"],
},
}),
320,
) : ''}
</p>
<div className="mt-2">
<PopoverButton
content={`This will add ${result.volume?.name || 'this issue'} to your wanted list.`}
clickHandler={() =>
addToWantedList({
source: "comicvine",
comicObject: result,
markEntireVolumeWanted: false,
resourceType: "issue",
})
}
/>
</div>
</div>
</div>
</div>
) : (
result.resource_type === "volume" && (
<div
key={result.id}
className="mb-5 dark:bg-slate-500 p-4 rounded-lg"
>
<div className="flex flex-row">
<div className="mr-5 min-w-[80px] max-w-[13%]">
<Card
key={result.id}
orientation={"cover-only"}
imageUrl={result.image.small_url}
hasDetails={false}
/>
</div>
<div className="w-3/4">
<div className="text-xl">
{!isEmpty(result.name) ? (
result.name
) : (
<span className="text-xl">No Name</span>
)}
{result.start_year && <> ({result.start_year})</>}
</div>
<div className="flex flex-row gap-2">
{/* issue count */}
{result.count_of_issues && (
<div className="my-2">
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--documents-minimalistic-bold-duotone] w-5 h-5"></i>
</span>
<span className="text-md text-slate-500 dark:text-slate-900">
{t("issueWithCount", {
count: result.count_of_issues,
})}
</span>
</span>
</div>
)}
{/* type: TPB, one-shot, graphic novel etc. */}
{!isNil(result.description) &&
!isUndefined(result.description) && (
<>
{!isEmpty(
detectIssueTypes(result.description),
) && (
<div className="my-2">
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--book-2-line-duotone] w-5 h-5"></i>
</span>
<span className="text-md text-slate-500 dark:text-slate-900">
{
detectIssueTypes(result.description)
.displayName
}
</span>
</span>
</div>
)}
</>
)}
</div>
<span className="tag is-warning">{result.id}</span>
<p>
<a href={result.api_detail_url}>
{result.api_detail_url}
</a>
</p>
{/* description */}
<p className="text-sm">
{result.description ? ellipsize(
convert(result.description, {
baseElements: {
selectors: ["p", "div"],
},
}),
320,
) : ''}
</p>
<div className="mt-2">
<PopoverButton
content={`Adding this volume will add ${t(
"issueWithCount",
{
count: result.count_of_issues,
},
)} to your wanted list.`}
clickHandler={() =>
addToWantedList({
source: "comicvine",
comicObject: result,
markEntireVolumeWanted: true,
resourceType: "volume",
})
}
/>
</div>
</div>
</div>
</div>
)
);
})}
</div>
) : (
<div className="mx-auto mx-auto max-w-screen-md px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
<article
role="alert"
className="mt-4 rounded-lg max-w-screen-md border-s-4 border-blue-500 bg-blue-50 p-4 dark:border-s-4 dark:border-blue-600 dark:bg-blue-300 dark:text-slate-600"
>
<div>
<p> Search the ComicVine database</p>
<p>
Note that you need an instance of AirDC++ already running to
use this form to connect to it.
</p>
<p>
Search and add issues, series and trade paperbacks to your
library. Then, download them using the configured AirDC++ or
torrent clients.
</p>
</div>
</article>
</div>
)}
</section>
</div>
);
};
export default Search;