493 lines
18 KiB
TypeScript
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;
|